Compare commits

...

418 Commits

Author SHA1 Message Date
dswbx b44b87923b Merge pull request #118 from bknd-io/fix/cf-d1-introspect-issue
fix d1 introspect issue by excluding `_cf_METADATA`
2025-03-26 09:21:59 +01:00
dswbx 6e6c65b375 fix d1 introspect issue by excluding _cf_METADATA 2025-03-26 09:19:54 +01:00
dswbx c9c00adf6c docs: added database overview 2025-03-25 14:21:00 +01:00
dswbx ee15150c78 Merge pull request #109 from bknd-io/release/0.10
Release 0.10
2025-03-25 13:04:25 +01:00
dswbx f8f5ef9c98 bump version to 0.10 2025-03-25 13:03:15 +01:00
dswbx ec015b7849 docs: add postgres and sqlocal instructions 2025-03-25 13:02:09 +01:00
dswbx 67e0374c04 add change set to mutator insert/update after event 2025-03-21 19:32:24 +01:00
dswbx 9380091ba9 Merge pull request #116 from bknd-io/fix/s3-upload-in-node
fix s3 media upload in node environments by adding content length to request
2025-03-21 18:05:04 +01:00
dswbx aa66a81b27 Merge pull request #114 from bknd-io/feat/admin-colors-and-resize
admin ui: started color centralization + made sidebar resizable
2025-03-21 18:04:26 +01:00
dswbx 0c15ec1434 hide oauth client details with type password 2025-03-21 18:03:45 +01:00
dswbx 7b8c7f1ae4 fix s3 media upload in node environments by adding content length to request 2025-03-21 18:01:44 +01:00
dswbx f6996b1953 admin ui: started color centralization + made sidebar resizable 2025-03-18 10:56:39 +01:00
dswbx ea2aa7c76c fix react example styling 2025-03-15 16:44:58 +01:00
dswbx 2531c2d8d4 updated react example with todo example 2025-03-15 16:37:03 +01:00
dswbx 0e81e14421 bump rc version 2025-03-15 15:08:04 +01:00
dswbx b994c2dfb0 bump rc version 2025-03-15 15:07:29 +01:00
dswbx 4085d569a8 Merge pull request #113 from bknd-io/feat/data-sqlocal-connection
add sqlocal connection including example
2025-03-15 14:43:03 +01:00
dswbx 622a7b2b9a add sqlocal connection including example 2025-03-15 14:40:41 +01:00
dswbx 5697b7891a update cli description for run 2025-03-14 15:37:51 +01:00
dswbx 6a6a7a9f26 Merge pull request #112 from bknd-io/feat/react-router-adapter
replaced remix with react-router
2025-03-14 15:36:27 +01:00
dswbx b763826754 replaced remix with react-router 2025-03-14 15:32:43 +01:00
dswbx b3a0ecbe6d Merge pull request #111 from bknd-io/feat/cli-telemetry
added basic cli telemetry
2025-03-14 11:46:17 +01:00
dswbx 181443b320 fix is_toggled test 2025-03-14 11:45:02 +01:00
dswbx 6015acb914 added basic cli telemetry 2025-03-14 11:40:56 +01:00
dswbx a55d93b5ff data schema: disable primary field 2025-03-14 10:03:40 +01:00
dswbx 1ed94716bd Merge pull request #105 from bknd-io/feat/add-postgres-and-prepare-others
support for postgres and others
2025-03-14 09:57:44 +01:00
dswbx 178838e06c remove postgres test from main package 2025-03-14 09:55:00 +01:00
dswbx c4aaa5a90b move postgres to a separate package 2025-03-14 09:42:51 +01:00
dswbx b1b2e97a5a Merge remote-tracking branch 'origin/release/0.10' into feat/add-postgres-and-prepare-others
# Conflicts:
#	app/package.json
#	bun.lock
2025-03-14 08:06:50 +01:00
dswbx e9f2da82d7 Merge pull request #110 from bknd-io/fix/nextjs-linting-and-adapter
fix/nextjs-linting-and-adapter
2025-03-14 07:59:38 +01:00
dswbx 9cba9ada84 Merge remote-tracking branch 'origin/release/0.10' into fix/nextjs-linting-and-adapter 2025-03-14 07:56:37 +01:00
dswbx c8dcf964bc Merge pull request #103 from bknd-io/feat/remove-admin-config
remove admin config, improve theme handling
2025-03-14 07:55:43 +01:00
dswbx 1ea22248b4 fix nextjs linting hints and improve adapter to support config as function 2025-03-14 07:55:05 +01:00
dswbx 4f01004262 update override in examples 2025-03-11 19:53:31 +01:00
dswbx 051c508254 add override to skeleton 2025-03-11 19:52:44 +01:00
dswbx a72b11e9fb fix tests 2025-03-11 17:45:27 +01:00
dswbx 96781f5d3d removed admin config from server, theme is now client side, fixed module manager migrations 2025-03-11 17:41:33 +01:00
dswbx de89e26ffb Merge remote-tracking branch 'origin/release/0.10' into feat/remove-admin-config
# Conflicts:
#	app/src/modules/server/AdminController.tsx
#	app/src/ui/Admin.tsx
2025-03-11 13:56:27 +01:00
dswbx 166a7d4d0a Merge pull request #108 from bknd-io/feat/update-react-deps
upgrade dependencies
2025-03-11 13:52:21 +01:00
dswbx 640f015812 upgrade tailwind to v4 2025-03-11 13:43:40 +01:00
dswbx d90f14c707 upgrade hook form resolvers 2025-03-11 10:17:07 +01:00
dswbx 0922d90aff upgrade hook form 2025-03-11 10:11:57 +01:00
dswbx 81ca7c0e28 upgrade typebox 2025-03-11 10:08:56 +01:00
dswbx 4f87f6857f upgrade liquid, fix editor themes 2025-03-11 09:57:05 +01:00
dswbx 0dd29c3b07 fix test typings 2025-03-11 09:38:14 +01:00
dswbx eb451a8e1a upgrade kysely and hono 2025-03-11 09:29:08 +01:00
dswbx 37df32b51b upgrade fast-xml-parser (prepare to replace) 2025-03-11 09:27:40 +01:00
dswbx 722d0bd29e upgrade cf json schema, liquid, xyflow, jotai 2025-03-11 09:02:38 +01:00
dswbx 03d3ab8b80 upgrade swr and wouter 2025-03-11 08:59:33 +01:00
dswbx dc2d39fa14 upgrade dev dependencies 2025-03-11 08:57:01 +01:00
dswbx 368c60d7c7 upgrade vite to 6 2025-03-11 08:51:37 +01:00
dswbx 8df30f4dae upgrade to react 19 2025-03-11 08:44:49 +01:00
dswbx 2d7257d2bc updated react-form to latest 2025-03-11 06:49:38 +01:00
dswbx 7e2de1bd0c add a fixed release to docker build 2025-03-10 13:05:09 +01:00
dswbx b786fbcd13 Merge pull request #107 from bknd-io/hotfix/0.9.1-docker-open
hotfix 0.9.1 docker fails due to trying to open a browser
2025-03-10 12:37:02 +01:00
dswbx eabae5d3df hotfix 0.9.1 docker fails due to trying to open a browser 2025-03-10 12:28:38 +01:00
dswbx a4cb012ce8 added batching for postgres 2025-03-07 19:40:58 +01:00
dswbx 3704cad53a added BaseIntrospector and reorganized connections 2025-03-07 17:20:37 +01:00
dswbx 25a3cb9655 ignore pg test for now (missing env on ci) 2025-03-07 15:04:44 +01:00
dswbx a5c422d45d initialized postgres support 2025-03-07 15:02:19 +01:00
dswbx 8550aef606 update nextjs example to add credentials 2025-03-07 08:56:35 +01:00
dswbx 01cb9d21b0 update nextjs example's readme 2025-03-05 09:37:23 +01:00
dswbx c5ce79102e bump 0.9 and update readme with entity prefix 2025-03-05 09:15:16 +01:00
dswbx 67ab3d2e38 Merge pull request #93 from bknd-io/release/0.9
Release 0.9
2025-03-05 09:13:33 +01:00
dswbx b1b88e37f1 updated readme 2025-03-05 09:10:49 +01:00
dswbx 7e61ff9995 fix refine fn typing 2025-03-05 08:35:57 +01:00
dswbx 58f9ea4cd7 Merge pull request #104 from bknd-io/feat/additional-data-api-methods
feat/additional-data-api-methods
2025-03-05 08:24:19 +01:00
dswbx dde5ad365e reduce api bundle size by omitting lodash 2025-03-05 08:23:40 +01:00
dswbx dda02807c1 fix typing to allow PrimaryFieldType as Primitive in where 2025-03-05 08:14:15 +01:00
dswbx ef629321ab added readOneBy, updateMany, deleteMany, exists 2025-03-05 08:02:57 +01:00
dswbx 4f52537ea0 updated nextjs example 2025-03-04 14:39:32 +01:00
dswbx e82b72275b moved flash message, removed theme from admin controller 2025-03-04 11:21:33 +01:00
dswbx ab73b02138 refactor console verbosity and internal env handling 2025-03-04 11:18:14 +01:00
dswbx 36fba4588f Merge pull request #102 from bknd-io/feat/aws-lambda-adapter
added aws lambda adapter + improvements to handle concurrency
2025-03-03 16:59:16 +01:00
dswbx ad353e59c9 added aws adapter docs 2025-03-03 16:56:48 +01:00
dswbx 675a39ad5c added aws lambda adapter + improvements to handle concurrency 2025-03-03 15:42:42 +01:00
dswbx 1a72b9e84b Merge pull request #89 from bknd-io/feat/app-api-exp-for-nextjs
optimized local api instantiation to prepare for nextjs app router
2025-03-03 07:16:01 +01:00
dswbx 27a282ea6b Merge pull request #101 from bknd-io/fix/add-cache-directives-for-media
added missing cache directives to media adapter serving
2025-03-03 07:15:03 +01:00
dswbx 6740e20f72 Merge pull request #100 from bknd-io/fix/minor-admin-ui-fixes
fix active sidebar active item, added error boundaries for relational form fields, fixed schema diagram for m:n relations
2025-03-03 07:14:43 +01:00
dswbx 6c1eea76e5 added missing cache directives to media adapter serving 2025-03-03 07:14:01 +01:00
dswbx 5ca21b6c01 fix active sidebar active item, added error boundaries for relational form fields, fixed schema diagram for m:n relations 2025-03-03 07:13:04 +01:00
dswbx 00f52eb096 minor fixes for nextjs 2025-03-03 07:10:32 +01:00
dswbx 09074f6591 updated nextjs example with app router 2025-02-27 20:09:03 +01:00
dswbx 2d5da63eb2 Merge remote-tracking branch 'origin/release/0.9' into feat/app-api-exp-for-nextjs
# Conflicts:
#	app/src/adapter/nextjs/nextjs.adapter.ts
#	app/src/index.ts
2025-02-27 14:21:00 +01:00
dswbx 3bf3bb32a4 fix field date error in modal, adjust notification spacing, added entity quick switcher 2025-02-27 14:07:49 +01:00
dswbx 3e28124494 fix many to many reference query 2025-02-27 11:43:12 +01:00
dswbx dd48962901 added auth strategies migration, fixed rebuild of modules on migrations 2025-02-27 10:08:22 +01:00
dswbx 7743f71a11 added format command and added trailing commas to reduce conflicts 2025-02-26 20:06:03 +01:00
dswbx 88b5359f1c Merge pull request #99 from bknd-io/feat/data-api-bulk-update
feat/data-api-bulk-update
2025-02-26 19:55:22 +01:00
dswbx 6acbe1189e use new bun text lock file 2025-02-26 19:54:40 +01:00
dswbx 7c1fb86b20 Merge pull request #98 from bknd-io/feat/auth-ui-improvements
feat/auth-ui-improvements
2025-02-26 19:52:39 +01:00
dswbx 36e033b0de Merge remote-tracking branch 'origin/release/0.9' into feat/auth-ui-improvements
# Conflicts:
#	app/package.json
2025-02-26 19:51:41 +01:00
dswbx 4ca68e837c added auth getting started box 2025-02-26 19:50:35 +01:00
dswbx 9a683c8e35 add auth enabling hints 2025-02-26 18:44:16 +01:00
dswbx 4a2dbf8f79 added bulk updates to data controller 2025-02-26 18:04:07 +01:00
dswbx 4d75cfe0ea updated data api docs 2025-02-26 18:03:50 +01:00
dswbx b6d28bbf42 feat/ci-tests (#97) 2025-02-26 17:15:51 +01:00
dswbx 2a9c1be151 added auth strategies form + add ability to disable strategies 2025-02-26 14:53:32 +01:00
dswbx d4a6a9326f fix schema form 2025-02-26 08:22:05 +01:00
dswbx de854eec3a added new settings UI for auth 2025-02-25 13:59:44 +01:00
dswbx 253174c14e Merge pull request #96 from bknd-io/feat/report-query-without-index
added index on media entity_id, report query with unindexed fields
2025-02-25 08:17:54 +01:00
dswbx 9a741f49a0 added index on media entity_id, report query with unindexed fields 2025-02-22 22:09:50 +01:00
dswbx b8f87a2097 Merge pull request #92 from bknd-io/fix/many-to-many-respect-with-select
fix ManyToManyRelation to respect `select` from `with` query
2025-02-22 13:23:58 +01:00
dswbx 4625151db8 fix relations test with new select 2025-02-22 13:23:37 +01:00
dswbx 280d2dccfb Merge pull request #95 from bknd-io/feat/media-more-mime-and-add-dropzone-checker
media more mime types and add dropzone mime checker
2025-02-22 13:20:35 +01:00
dswbx 3015385290 Merge pull request #94 from bknd-io/feat/improve-console-with-timestamps
improve console with timestamps
2025-02-22 13:19:46 +01:00
dswbx 147d6b7ff7 added timestamps to app console logs 2025-02-22 13:18:23 +01:00
dswbx 82fba39684 added timestamps to app console logs 2025-02-22 13:17:43 +01:00
dswbx f801d3a556 media: added more mime types, added mime type check on dropzone 2025-02-22 13:14:32 +01:00
dswbx 837b0a3d43 fix ManyToManyRelation to respect select from with query 2025-02-21 11:12:58 +01:00
dswbx 9ecfcb3e05 Merge pull request #91 from bknd-io/release/0.8.1
Fix Release 0.8.1
2025-02-21 08:18:32 +01:00
dswbx fb3dbd343b bump fix release version to 0.8.1 2025-02-21 08:18:13 +01:00
dswbx 060df210f9 fix Api import in App 2025-02-21 08:14:22 +01:00
dswbx fb725be0ed dev: only report env once 2025-02-21 07:44:07 +01:00
dswbx 1a95b4c8bb Merge remote-tracking branch 'origin/release/0.8.1' into release/0.8.1
# Conflicts:
#	app/src/ui/elements/media/Dropzone.tsx
2025-02-21 07:41:45 +01:00
dswbx cfa950aa46 fix media upload without name, add data result to uploaded payload 2025-02-21 07:40:43 +01:00
dswbx 5771298bfa Merge pull request #90 from bknd-io/fix/media-mime-guessing-on-entity
fix media mime guessing when uploading to entity
2025-02-20 11:34:36 +01:00
dswbx 94c40de011 fix media mime guessing when uploading to entity 2025-02-20 11:32:24 +01:00
dswbx 6f776aeff7 optimized local api instantiation to prepare for nextjs app router 2025-02-20 10:02:44 +01:00
dswbx eaa7276173 update docs on new cli starters 2025-02-19 21:27:04 +01:00
dswbx 89871a8ef7 Merge pull request #75 from bknd-io/release/0.8
Release 0.8
2025-02-19 19:06:16 +01:00
dswbx 7f0f6bec08 bump version 0.8.0 2025-02-19 19:05:29 +01:00
dswbx 8e1ad98964 Merge pull request #88 from bknd-io/refactor/improve-remix-example
refactor/improve-remix-example
2025-02-19 17:44:10 +01:00
dswbx a29e824ecc minor fixes in build and console 2025-02-19 17:41:48 +01:00
dswbx b436758fe6 updated remix integration docs 2025-02-19 14:28:22 +01:00
dswbx ee7dcd44f4 updated remix adapter for non-middleware env, fixed console mute 2025-02-19 14:13:29 +01:00
dswbx f2e5815e24 adjusted remix example 2025-02-19 14:12:40 +01:00
dswbx 335fe42a22 adjusted nextjs example theme 2025-02-18 20:14:08 +01:00
dswbx a01b99be27 updated App options "seed", changed examples to file db 2025-02-18 20:01:08 +01:00
dswbx a4e46a2768 Merge pull request #87 from bknd-io/fix/many-to-many
fix persisting of many to many entity
2025-02-18 19:03:56 +01:00
dswbx f494735a79 fix persisting of many to many entity 2025-02-18 15:43:19 +01:00
dswbx 400db84dd5 change default mode of cloudflare example to "warm", suppress listener info 2025-02-18 13:26:26 +01:00
dswbx 964bacd824 updated examples package.json's and README.md's 2025-02-18 13:19:35 +01:00
dswbx 7994eabe7d export RepoQueryIn from client 2025-02-18 12:54:13 +01:00
dswbx dec382b49d cli create: add default database name for cloudflare 2025-02-18 10:49:37 +01:00
dswbx 93cee4f8aa Merge pull request #83 from bknd-io/refactor/data-api-entity-prefix
prefixed data entity endpoints with /entity
2025-02-18 10:22:31 +01:00
dswbx e99abb09b6 Merge branch 'release/0.8' into refactor/data-api-entity-prefix 2025-02-18 10:22:15 +01:00
dswbx a5ab102b34 Merge pull request #86 from bknd-io/feat/cli-prefer-file
cli run will prefer data file now, improved console coloring
2025-02-18 10:21:34 +01:00
dswbx 1ca6c5b43d Merge pull request #85 from bknd-io/refactor/app-options-manager
refactor app constructor params to move manager into app options
2025-02-18 10:21:27 +01:00
dswbx 64658184ac Merge pull request #84 from bknd-io/feat/debug-routes
added deboug routes cli action
2025-02-18 10:21:17 +01:00
dswbx 61ad4a3109 Merge pull request #82 from bknd-io/feat/ui-admin-theme
add admin "system" theme option that checks user prefs
2025-02-18 10:21:02 +01:00
dswbx e12ed1e4b3 cli run will prefer data file now, improved console coloring 2025-02-18 10:11:59 +01:00
dswbx e103d3ac33 refactor app constructor params to move manager into app options 2025-02-18 09:36:59 +01:00
dswbx e2217c411a added deboug routes cli action 2025-02-18 09:25:34 +01:00
dswbx 3e6d381239 prefixed data entity endpoints with /entity 2025-02-18 09:12:12 +01:00
dswbx 0823b182f6 admin: added "system" theme option, check user pref 2025-02-18 08:20:41 +01:00
dswbx bd362607ae cli create: fix cloudflare overriding name in wrangler 2025-02-17 20:46:20 +01:00
dswbx 61ac31eba9 Merge pull request #81 from bknd-io/feat/app-auth-context
feat/app-auth-context
2025-02-14 20:33:12 +01:00
dswbx 21a8f56c48 moved auth ctx to request context, cleaned up system controller 2025-02-14 20:32:09 +01:00
dswbx fb76f8d789 improve media mime type inferring + added uploadToEntity in media api 2025-02-14 18:02:43 +01:00
dswbx 4bd201d454 cli create: updated templates, reworked examples to be standalone 2025-02-14 14:56:35 +01:00
dswbx b412da836c cli create: fix installing deps spinner 2025-02-14 14:08:15 +01:00
dswbx 425465becd Merge pull request #79 from bknd-io/feat/cli-starters
added cli-starters
2025-02-14 13:57:48 +01:00
dswbx 3b487ade2a finalize initial starters 2025-02-14 13:55:46 +01:00
dswbx 7d3c76d7c1 Merge remote-tracking branch 'origin/release/0.8' into feat/cli-starters 2025-02-14 11:54:15 +01:00
dswbx fd297c6c50 bump rc version 2025-02-14 11:48:53 +01:00
dswbx 149a6d768c Merge pull request #80 from bknd-io/fix/api-and-media
fix/api-and-media
2025-02-14 11:42:50 +01:00
dswbx 24e69eec90 fix s3 adapter 2025-02-14 11:42:26 +01:00
dswbx 9c38c8f2ad improved Dropzone error handling 2025-02-14 11:21:11 +01:00
dswbx f0bdb3a75e improved typing, renamed functions for clarity 2025-02-14 11:04:50 +01:00
dswbx c4e505582b updated media api and added tests, fixed body limit 2025-02-14 10:42:36 +01:00
dswbx cc938db4b8 add default fetcher fallback for ModuleApi 2025-02-13 11:24:35 +01:00
dswbx 68277a68d5 cloudflare update assets dir 2025-02-13 09:12:08 +01:00
dswbx 8e72b5b615 init create cli, added node and partially cloudflare 2025-02-13 08:51:48 +01:00
dswbx 07422f30ba Merge pull request #78 from bknd-io/feat/improve-cli-with-env
cli: fallback to env if present
2025-02-12 09:54:29 +01:00
dswbx d84731d89e cli: try to fallback to env if present 2025-02-12 09:52:18 +01:00
dswbx 4afea14893 Merge pull request #77 from bknd-io/feat/conn-remove-type
update app create config to accept libsql config directly
2025-02-12 09:39:16 +01:00
dswbx 2c6a4d2bed update app create config to accept libsql config directly 2025-02-12 09:21:51 +01:00
dswbx f466dd166d Merge pull request #73 from bknd-io/feat/cf-d1-and-r2
adding d1 and r2 for cloudflare environments
2025-02-12 09:04:47 +01:00
dswbx 6d8f86b2c9 Merge pull request #76 from bknd-io/feat/mm-revert-on-error
run schema mutations in safe proxy and revert to previous on error
2025-02-12 09:04:24 +01:00
dswbx c8fa704e32 running schema mutations in safe proxy and revert to previous on error 2025-02-12 09:01:56 +01:00
dswbx 61d06973a4 ignore d1 system tables, deploy verified 2025-02-12 07:29:56 +01:00
dswbx 7a6321f4ce added r2 binding support to cf adapter 2025-02-11 10:04:53 +01:00
dswbx 4dde67ca21 update README package sizes 2025-02-10 17:56:08 +01:00
dswbx f9c612e0fa Merge pull request #74 from bknd-io/fix/ui-modal-and-media-reload
fixing ui modal styling and make sure schema reloads on media setting changes
2025-02-10 17:49:53 +01:00
dswbx d7e772efd9 fixing ui modal styling and make sure schema reloads on media setting changes 2025-02-10 17:47:56 +01:00
dswbx be39d1c374 adding d1 for cloudflare environments 2025-02-10 17:46:22 +01:00
dswbx 1e5c0dbc22 Merge pull request #72 from bknd-io/fix/0.7.1
fix: popover regression & dropzone rerender lost state
2025-02-10 14:53:14 +01:00
dswbx d79da9d204 fix: popover regression & dropzone rerender lost state 2025-02-10 14:52:14 +01:00
dswbx 09d35d3cc0 Merge pull request #60 from bknd-io/release/0.7
Release 0.7
2025-02-08 16:38:07 +01:00
dswbx f7e6928dba form: fix popover, improve form types 2025-02-08 15:43:02 +01:00
dswbx 3fa682bfe1 form: fix switch required, add root form error to media settings 2025-02-08 13:27:47 +01:00
dswbx 97b0ff24c8 Merge pull request #71 from bknd-io/feat/elements-native-form
feat/elements-native-form
2025-02-08 12:01:35 +01:00
dswbx bb964dc835 added native reactive form to elements 2025-02-08 11:59:43 +01:00
dswbx 5840ec6b89 Merge pull request #70 from bknd-io/feat/optimize-form-renders
Feat/optimize form renders
2025-02-08 11:59:13 +01:00
dswbx f29641c702 using path instead of pointer, replaced lodash usage 2025-02-08 09:18:24 +01:00
dswbx 2e3ee65aa7 added more input field types and improved typing 2025-02-07 17:28:01 +01:00
dswbx 11a6f5c9b9 hide rerender scanner 2025-02-07 16:12:39 +01:00
dswbx 324d641410 form rerenders optimized 2025-02-07 16:11:21 +01:00
dswbx 02e7e1ca95 initially reduced form rerenders 2025-02-06 17:12:43 +01:00
dswbx 78d4f928ba fix nextjs node adapter import 2025-02-06 15:19:25 +01:00
dswbx 0a5cc2ad98 fix bknd theme 2025-02-06 15:00:54 +01:00
dswbx e14d528386 Merge pull request #68 from bknd-io/feat/media-settings-ui
Improved media settings UI/UX
2025-02-06 13:59:24 +01:00
dswbx 15003b004b Merge pull request #67 from bknd-io/fix/api-query-with-string-object
fix/api-query-with-string-object
2025-02-06 13:58:50 +01:00
dswbx 9a4c2bd530 finalize new media settings ui 2025-02-06 13:58:29 +01:00
dswbx ab61a1392c Merge pull request #69 from bknd-io/fix/admin-config-secrets-missing
fix clearing schema on unauthorized secrets fetching
2025-02-06 09:01:47 +01:00
dswbx 53d55448e6 fix clearing schema on unauthorized secrets fetching 2025-02-06 09:01:11 +01:00
dswbx 46cf310ad6 finalize new media settings 2025-02-05 17:54:48 +01:00
dswbx 8418231c43 improved media settings implementation 2025-02-05 16:11:53 +01:00
dswbx 4b3493a6f5 changed media settings to new form 2025-02-05 11:04:37 +01:00
dswbx f432473ed9 initial json schema form implementation 2025-02-05 10:31:08 +01:00
dswbx 8f0cef1f37 clear console log 2025-02-01 12:15:48 +01:00
dswbx a8e78f984d fix repo stringified string parsing 2025-02-01 12:12:22 +01:00
dswbx babeaae8d1 Merge pull request #66 from bknd-io/fix/auth-api-include-cookie
also sending cookies on json auth requests
2025-02-01 09:15:16 +01:00
dswbx 45d44f8177 Merge remote-tracking branch 'origin/release/0.7' into fix/auth-api-include-cookie
# Conflicts:
#	app/src/auth/authenticate/Authenticator.ts
2025-02-01 09:15:01 +01:00
dswbx db476080ad also sending cookies on json auth requests 2025-02-01 09:13:42 +01:00
dswbx 158818520b Merge pull request #65 from bknd-io/feat/auth-redirect-param
feat/auth-redirect-param
2025-01-29 20:30:52 +01:00
dswbx 4755288fed improve fallbacks for redirection 2025-01-29 20:30:27 +01:00
dswbx 10db267164 added a redirect param to password strategy 2025-01-29 20:26:06 +01:00
dswbx dfe7d55057 Merge pull request #64 from bknd-io/feat/improved-api-construction
updated API instantiation, and update user on verify
2025-01-29 14:49:00 +01:00
dswbx c2b3316fcb updated API instantiation, and update user on verify 2025-01-29 14:44:32 +01:00
dswbx 86ba055f5e fix entity table select 2025-01-28 09:10:38 +01:00
dswbx 9d6ccda20d Merge pull request #63 from bknd-io/fix/api-and-adapter
fix/api-and-adapter
2025-01-28 08:16:56 +01:00
dswbx 39c419c320 add built state and skip auth cookie if not signed in for local calls 2025-01-25 19:30:03 +01:00
dswbx 2d6d83ccb2 add fetcher option to api to allow local calls 2025-01-25 19:29:36 +01:00
dswbx ba1eb5fe08 remove adapter re-exports 2025-01-25 19:28:39 +01:00
dswbx 123389786d bump 0.7 rc version 2025-01-25 14:19:23 +01:00
dswbx b65bb3b826 Merge pull request #62 from bknd-io/feat/adapter-optimize-imports
optimize adapter imports to deduplicate
2025-01-25 14:14:25 +01:00
dswbx 5b40d5eaf4 optimize adapter imports to deduplicate 2025-01-25 14:13:34 +01:00
dswbx 89b1fd3909 Merge pull request #61 from bknd-io/feat/cf-static-assets
adapter(cloudflare): add supports for the new assets feature
2025-01-25 13:48:52 +01:00
dswbx aa7eac258b adapter(cloudflare): add supports for the new assets feature 2025-01-25 13:40:28 +01:00
dswbx 392a06c486 Merge pull request #59 from bknd-io/feat/adapter-auth-env
Adapter: verified auth & env-specific construction
2025-01-25 13:36:59 +01:00
dswbx 1beac5043b updated cloudflare adapter docs to match new args 2025-01-25 12:33:05 +01:00
dswbx 475488c910 Merge remote-tracking branch 'origin/feat/adapter-api-async' into feat/adapter-auth-env 2025-01-25 11:55:15 +01:00
dswbx 9ddacb7ae5 added adapter exports, generalized env-depending config construction 2025-01-25 11:54:19 +01:00
dswbx 3d1d52cbf8 Merge pull request #58 from bknd-io/release/0.6.2
Release 0.6.2
2025-01-25 09:40:44 +01:00
dswbx acc504cdc9 release version 0.6.2 2025-01-25 09:40:15 +01:00
dswbx d98d3351c0 Merge pull request #57 from bknd-io/feat/elements-docs-and-custom
restructured elements for better customization
2025-01-25 09:17:39 +01:00
dswbx 3e606cb159 Merge pull request #54 from bknd-io/fix/nextjs-schema-update
fixed schema updates to fail in nextjs envs due to lodash's merge
2025-01-25 09:17:23 +01:00
dswbx cc76964d03 restructured elements slightly for better customization, updated readme and docs 2025-01-25 09:14:02 +01:00
dswbx b0c5f6307a updated adapters to automatically verify auth 2025-01-25 09:09:09 +01:00
dswbx b3019d7333 Merge pull request #55 from bknd-io/feat/nextjs-plasmic-catchall
bknd/plasmic: added helper functions for integration
2025-01-25 09:03:30 +01:00
dswbx 2265451d4b added handy nextjs-plasmic-bknd functions 2025-01-23 10:59:33 +01:00
dswbx ba936604a3 fix overwrite of entity config (e.g. empty label) 2025-01-23 09:02:58 +01:00
dswbx 7dd42fbff3 fixed schema updates to fail in nextjs envs due to lodash's merge 2025-01-23 08:34:11 +01:00
dswbx f64e5dac03 updated README and documentation (first part) to match 0.6 2025-01-21 18:30:01 +01:00
dswbx 24542f30cb Merge pull request #53 from bknd-io/fix/release-0.6.1
fix auth screens' missing config permission, and media URL resolution
2025-01-21 17:13:57 +01:00
dswbx 51715158c2 fix auth screens' missing config permission, and media URL resolution 2025-01-21 17:10:45 +01:00
dswbx 42f935acfd Merge pull request #47 from bknd-io/release/0.6
Release 0.6
2025-01-21 08:04:39 +01:00
dswbx a4ce93bc7e finalize package.json for release 0.6 2025-01-21 08:03:03 +01:00
dswbx fc08108ee0 admin ui: improved relation description 2025-01-18 16:22:26 +01:00
dswbx 7cc437e816 Merge pull request #43 from bknd-io/refactor/optimize-ui-bundle-size
reduce ui bundle size
2025-01-18 15:56:20 +01:00
dswbx a20b6b64a9 updated ui/client components, refactored deps 2025-01-18 15:25:12 +01:00
dswbx 664aed35f9 Merge remote-tracking branch 'origin/release/0.6' into refactor/optimize-ui-bundle-size
# Conflicts:
#	app/build.ts
#	app/package.json
2025-01-18 14:13:34 +01:00
dswbx fb2dff956b added better error messages if config secret permission is missing 2025-01-18 13:31:33 +01:00
dswbx db10188945 strengthened schema ensuring for system entities 2025-01-18 12:39:34 +01:00
dswbx 145b47e942 minimal popper implementation for context menu placement 2025-01-18 09:59:10 +01:00
dswbx ebd4565166 fix __isDev regression because of new vite static build 2025-01-18 09:20:57 +01:00
dswbx ad61770ef4 fix entity form fields regression 2025-01-18 09:15:32 +01:00
dswbx f4dc26198b Merge pull request #52 from bknd-io/feat/admin-entity-context-menu
Admin UI: Adding context menu to entities list
2025-01-18 09:08:50 +01:00
dswbx 89b29256cf adding context menu to entities list 2025-01-18 09:05:35 +01:00
dswbx 7ddcfc89b4 fixed auth strategy toggle, updated astro/remix 2025-01-17 18:08:23 +01:00
dswbx baab70f9da fix stackblitz issue uploading media to entity 2025-01-17 17:02:22 +01:00
dswbx a6fd9f0d96 fix system entity registration by re-applying configs to modules 2025-01-17 15:46:05 +01:00
dswbx 9422cc5bb8 fix media initial enable to force reload window 2025-01-17 13:57:37 +01:00
dswbx c3d0f01390 Merge pull request #51 from bknd-io/feat/admin-data-improve
Admin UI improvements
2025-01-17 13:46:38 +01:00
dswbx c19d3b2d75 fix entity list unnecessary parameters 2025-01-17 13:43:45 +01:00
dswbx a723d6f618 added easier access points to modify schema + added relation flip in dialog 2025-01-17 13:17:09 +01:00
dswbx 1625a0c7c0 made the creation of an entity more accessible and obvious 2025-01-17 11:24:24 +01:00
dswbx ac3c7316ff Merge pull request #50 from bknd-io/feat/admin-user-create
Create users in Admin UI
2025-01-17 10:28:39 +01:00
dswbx b61634e261 introduced auth strategy actions to allow user creation in UI 2025-01-17 10:19:26 +01:00
dswbx d4f647c0db add readme to npm publish, updated package.json and README.md 2025-01-17 05:29:13 +01:00
dswbx ad3fceaf60 Merge pull request #49 from bknd-io/feat/data-api-post-long-url
DataApi: automatically switch to POST if the URL is too long
2025-01-17 05:05:01 +01:00
dswbx 2a015ba0a1 extended dataapi tests 2025-01-17 05:02:26 +01:00
dswbx 8a6d8329f3 refactor for better precision 2025-01-16 17:25:19 +01:00
dswbx 69ea5a00ee DataApi: automatically switch to POST if the URL is too long 2025-01-16 17:18:30 +01:00
dswbx c6cbd36231 fix RepoQuery typings 2025-01-16 16:21:32 +01:00
dswbx 371184d232 Merge pull request #48 from bknd-io/feat/recursive-with
recursive `with`
2025-01-16 15:47:18 +01:00
dswbx 8226b644ae fix double registration of auth middleware on data routes 2025-01-16 15:45:29 +01:00
dswbx 26a5fd8b34 reworked WithBuilder to allow recursive with operations 2025-01-16 15:25:30 +01:00
dswbx 37a65bcaf6 updated repo query schema, repo and withbuilder validation, and reworked relation with building 2025-01-16 13:22:15 +01:00
dswbx 5343d0bd9d allow bypassing entity data validation for unknown keys in useEntityForm 2025-01-16 10:30:28 +01:00
dswbx 92c3457099 Merge remote-tracking branch 'origin/main' into release/0.6
# Conflicts:
#	bun.lockb
2025-01-16 10:22:44 +01:00
dswbx 6c491653db Merge pull request #42 from bknd-io/feat/elements-optimize
optimize elements by reducing the bundle size required
2025-01-16 10:21:16 +01:00
dswbx ec1be3c107 Merge pull request #44 from bknd-io/fix/flow-schema
fix advanced flow creation due to schema mismatches
2025-01-16 10:20:31 +01:00
dswbx 85c9020055 Merge pull request #46 from bknd-io/feat/emgr-return
refactor event system to support returnable events
2025-01-16 10:19:14 +01:00
dswbx b490c01226 Merge pull request #45 from bknd-io/feat/plasmic-pkg-update-esm
update plasmic package to omit cjs
2025-01-16 10:15:00 +01:00
dswbx f47218708a added event related tests to mutator, fixed tests 2025-01-16 10:13:54 +01:00
dswbx aa4aca1a90 added event related tests to mutator, fixed tests 2025-01-16 10:11:10 +01:00
dswbx 6c9707d12c refactored mutator to listen for returned data from event listeners 2025-01-16 10:10:47 +01:00
dswbx 438e36f185 refactor event listener registration
unified listener creation logic under `createEventListener`, adding support for a flexible `RegisterListenerConfig`. Updated associated tests and improved error handling for unregistered events.
2025-01-15 17:46:41 +01:00
dswbx 6625c9bc48 Refactor event system to support returnable events
Added support for validating and managing return values in events. Implemented `validate` and `clone` methods in the event base class for event mutation and return handling. Additionally, enhanced error handling, introduced "once" listeners, and improved async execution management in the `EventManager`.
2025-01-15 17:21:28 +01:00
dswbx f47d0b1761 update plasmic package to omit cjs, migrated example from nextjs to vite/wouter 2025-01-15 14:50:10 +01:00
dswbx a4274423a1 fix advanced flow creation due to schema mismatches 2025-01-14 16:01:24 +01:00
dswbx a81aa875a8 lazy import code editor once, externalized codemirror 2025-01-14 15:07:09 +01:00
dswbx 7b0a41b297 cleanup cache 2025-01-14 14:13:16 +01:00
dswbx 1ea6446fab clean build.ts 2025-01-14 14:10:54 +01:00
dswbx 4ee0f74cdb optimize elements by reducing the bundle size required 2025-01-14 14:10:19 +01:00
dswbx 4e923bff69 Merge pull request #40 from bknd-io/release/0.5
Release 0.5
2025-01-14 12:29:32 +01:00
dswbx c7bd0a636b fix admin redirection on default role matched only 2025-01-14 11:55:24 +01:00
dswbx a9b2c613e1 finalized 0.5.0 2025-01-14 08:51:21 +01:00
dswbx 6855e6f7e9 using vite for static bundling to solve external package's react resolution 2025-01-14 07:51:14 +01:00
dswbx b1a32f3705 added nextjs detection and added exception for auth redirection to also work inside stackblitz 2025-01-11 16:23:20 +01:00
dswbx d8671355a6 exposed bknd middlewares to be used for custom routes 2025-01-11 15:45:32 +01:00
dswbx bd4bc14282 reworked authentication and permission handling 2025-01-11 15:27:58 +01:00
dswbx 5823c2d245 fixed auth tests 2025-01-11 11:35:11 +01:00
dswbx c732566f63 Add integration tests for auth, improve auth middleware and cookies handling 2025-01-11 10:52:31 +01:00
dswbx 0d945ab45b fixed auth middleware 2025-01-11 08:28:46 +01:00
dswbx c06ba061f0 inlined JSON schema form library and updated related references to address bundling and React static asset issues 2025-01-11 08:09:52 +01:00
dswbx 3bf92a8c65 Refactor asset handling and authentication logic (for node)
Updated asset path configuration and server-side logic to standardize asset serving. Introduced `shouldSkipAuth` to bypass authentication for asset requests. Added test coverage for the new asset path handling logic.
2025-01-10 20:58:03 +01:00
dswbx 87e07570d4 Refactor admin theme handling and optimize initialization.
Simplified theme detection logic and improved the loading screen for better performance on slower networks. Adjusted styles, reorganized imports, and removed unnecessary rendering logic in `main.tsx`.
2025-01-10 18:33:12 +01:00
dswbx c7d983942f Add loading indicator for admin asset initialization
Introduced a "loading" div to indicate when admin assets are being fetched. Updated rendering logic and styles in related components to account for this state. Prepared groundwork for potential view transitions.
2025-01-10 18:04:40 +01:00
dswbx bb756548a6 Fix entity referencing issue during post-seeded relational fetch 2025-01-10 17:28:43 +01:00
dswbx e94e8d8bd1 Refactor entity handling to preserve config while overriding type
Reworked `ensureEntity` to replace entities while maintaining their configuration and allowing type adjustments. Updated tests to verify type persistence and synchronization of entity properties.
2025-01-10 15:51:47 +01:00
dswbx 1d5f14fae0 Merge pull request #41 from bknd-io/feat/seed-em-media
refactor module schema handling and add sync mechanism
2025-01-10 14:44:48 +01:00
dswbx a8c20d3675 Refactor module schema handling and add sync mechanism
Redesigned entity and index management with methods to streamline schema updates and added a sync flag to signal required DB syncs post-build. Enhanced test coverage and functionality for schema modifications, including support for additional fields.
2025-01-10 14:43:39 +01:00
dswbx 475563b5e1 fixed version of json-schema-form-react 2025-01-10 11:41:48 +01:00
dswbx f0d502133e Refactor Vite integration and update docs
Introduced a Vite adapter with "fresh" and "cached" modes, centralized dev server configuration, and streamlined the example setup. Updated documentation with detailed steps for Vite integration and revised the internal dev environment to align with the changes.
2025-01-10 09:46:00 +01:00
dswbx 0500d4fc8e Merge remote-tracking branch 'origin/refactor/drop-cjs' into release/0.5 2025-01-10 08:50:13 +01:00
dswbx 5bccc910a3 Merge remote-tracking branch 'origin/feat/auth-components' into release/0.5 2025-01-10 08:24:33 +01:00
dswbx da3f3d98d0 Merge remote-tracking branch 'origin/feat/vite-adapter-optimization' into release/0.5 2025-01-10 08:24:02 +01:00
dswbx c8745b3464 Merge remote-tracking branch 'origin/feat/repo-query-sort' into release/0.5
# Conflicts:
#	app/src/data/api/DataController.ts
2025-01-10 08:23:51 +01:00
dswbx eabd57cb4e Delete media files from filesystem in media controller
Updated the logic to ensure media files are deleted from the filesystem alongside database entries when exceeding the maximum allowed items and enabled overwrite. Replaced IDs with file paths for proper file deletion.
2025-01-09 15:58:35 +01:00
dswbx d182640981 Add Media.Dropzone and Media.Preview as isolated elements
Introduce `Media.*` to modularize and customize file upload handling. Refactor media-related components to improve usability, add max item limits, overwrite support, and event callbacks.
2025-01-09 15:43:43 +01:00
dswbx 138a3579cb Update test expectations and fix test utility usage
Replaced `console.log` with `expect` in crypto tests for proper validation. Wrapped a test case with `withDisabledConsole` to suppress expected log output. Fixed typo in data test field configuration (`max_length` to `maxLength`).
2025-01-09 11:23:23 +01:00
dswbx 99df7f1402 Handle missing app context in permission middleware to prevent errors. 2025-01-09 11:06:40 +01:00
dswbx 47f48be514 Refactor module middleware initialization logic.
Replaced `getMiddleware` with `onServerInit` for streamlined middleware registration. Updated `AppAuth` to automatically register its authentication middleware. Added a test case to verify middleware registration. Removed redundant cookie renewal logic from `AdminController` and made related adjustments across modules.
2025-01-09 10:59:48 +01:00
dswbx 5c7bfeab8f Refactor authentication components for modularity
Replaced `LoginForm` with a more extensible `AuthForm` and `AuthScreen`, enabling support for multiple auth strategies (e.g., OAuth) and improved reusability. Updated imports, routes, and configurations accordingly.
2025-01-09 10:20:28 +01:00
dswbx 7f337e25bb optimized vite adapter with better defaults 2025-01-08 17:10:00 +01:00
dswbx fcab042e88 replaced hook form with json schema from 2025-01-08 17:03:50 +01:00
dswbx aa86d54553 clean up data controller 2025-01-08 10:16:41 +01:00
dswbx 6026613d29 feat: allow data query to have single string as sort 2025-01-07 18:08:50 +01:00
dswbx 7d3d1e811f refactor: extracted auth as middleware to be added manually to endpoints 2025-01-07 13:32:50 +01:00
dswbx 9f2c20ccee refactor: remove cjs from build to reduce package size 2025-01-07 08:55:25 +01:00
dswbx 064bbba8aa doc: nextjs swap edge 2025-01-07 07:42:49 +01:00
dswbx d1aa05a9bb chores: repo update 2025-01-07 07:05:45 +01:00
dswbx ab46611839 app auth: fix missing email when creating user on seeding 2024-12-24 16:16:45 +01:00
dswbx 24226382e8 Merge pull request #33 from bknd-io/release/0.4.0
Release 0.4.0
2024-12-24 16:11:26 +01:00
dswbx d04c4a6191 bump to 0.4.0 2024-12-24 16:10:55 +01:00
dswbx 06125f1afe added fallback route to server, created extensive setup instructions in docs 2024-12-24 16:01:42 +01:00
dswbx 8ef11aa382 Merge pull request #34 from bknd-io/refactor/adapter-options
Refactor: Unified framework and runtime adapters
2024-12-24 09:45:03 +01:00
dswbx 76da14294c unified runtime and framework adapters 2024-12-24 09:43:16 +01:00
dswbx c1e92e503b update & fix typing, updated examples 2024-12-23 19:28:31 +01:00
dswbx 70e42a02d7 updated examples: astro, nextjs, remix, bun, node 2024-12-23 16:50:26 +01:00
dswbx a17fd2df67 Merge pull request #32 from bknd-io/feat/optimize-seeding
Feat: optimize seeding, adding api typing support
2024-12-23 11:45:15 +01:00
dswbx 0648f71a9e Merge pull request #27 from bknd-io/feat/optimize-cf-adapter
improved cloudflare worker adapter
2024-12-23 11:44:52 +01:00
dswbx 131b7dd52e Merge pull request #25 from bknd-io/feat/node-media-local
Feat: Node media local
2024-12-23 11:44:40 +01:00
dswbx 3a79ce2cf8 added a new mutate replacement for useEntityMutate to quickly update cache without revalidating 2024-12-21 15:03:14 +01:00
dswbx deddf00c38 added pausing to event manager, added context aware entity schemas, fixed typings, first boot event, improved useEntityQuery mutation behavior 2024-12-20 20:11:49 +01:00
dswbx a7e3ce878a added chainable functions in em() 2024-12-19 16:12:30 +01:00
dswbx d7ee13011f isolated constructor functions for entities and relations from config 2024-12-19 08:16:47 +01:00
dswbx 1d1ebff64d Merge remote-tracking branch 'origin/main' into feat/optimize-seeding 2024-12-19 07:59:51 +01:00
dswbx ac2ae2657e Merge pull request #31 from bknd-io/feat/optimize-api-and-replace-react-query
optimize client-side api & hooks
2024-12-19 07:59:16 +01:00
dswbx 8e987d58f2 to em() toJSON to match expected type 2024-12-19 07:58:41 +01:00
dswbx 386c0d3ff0 added new em() shorthand for prototyping, added insertMany mutator function for seeding 2024-12-19 07:38:40 +01:00
dswbx 602235b372 optimized module manager seeding, added type support for new api hooks and reduced amount of dist chunks 2024-12-18 18:22:01 +01:00
dswbx c4138ef823 updated plasmic package to work with new hooks + added example 2024-12-18 08:41:11 +01:00
dswbx 1631bbb754 replaced all react-query usages with new hooks + removed react-query 2024-12-13 17:36:09 +01:00
dswbx 8c91dff94d updated admin to use swr hooks instead of react-query 2024-12-13 16:24:55 +01:00
dswbx 50c5adce0c added useEntity and useEntityQuery hooks 2024-12-12 17:00:10 +01:00
dswbx 9d9aa7b7a5 added useApi and useApiQuery to work seamlessly with SWR (tbc) 2024-12-12 10:37:52 +01:00
dswbx 43ec075a32 reworked api to return a custom promise to extract request info 2024-12-12 10:01:05 +01:00
dswbx 29ae6c6f9d hotfix release 0.3.3 2024-12-11 18:49:57 +01:00
dswbx bd86f8ef91 Merge pull request #30 from bknd-io/fix/admin-ui-fixes-3
admin ui: fixing condition where entity has been created with empty label
2024-12-11 18:49:22 +01:00
dswbx 25027429df fixing condition where entity has been created with empty label 2024-12-11 18:48:57 +01:00
dswbx 35959aaf6d hotfix release 3.0.2 2024-12-11 18:38:53 +01:00
dswbx 56fb779f99 Merge pull request #29 from bknd-io/fix/admin-ui-fixes-2
admin ui: fix initial entity state in create modal
2024-12-11 18:37:46 +01:00
dswbx 134fbd6d34 admin ui: fix initial entity state in create modal 2024-12-11 18:36:42 +01:00
dswbx d2c75b1605 Merge pull request #26 from bknd-io/fix/mm-sync
fix: only sync config table once certain it has to happen
2024-12-11 18:29:22 +01:00
dswbx a474e3fe3a Merge pull request #28 from bknd-io/fix/admin-ui-fixes
Admin UI fixes
2024-12-11 18:29:05 +01:00
dswbx d81b3acb94 mm: disable console as it might error trying to fetch config 2024-12-11 18:25:31 +01:00
dswbx 2ca66f4fc9 admin ui: fix "enter" on create new modal (inside fields) 2024-12-11 17:46:15 +01:00
dswbx a8bbb6e760 admin ui: fix data erd layout 2024-12-11 17:30:33 +01:00
dswbx 32459a1562 improved cloudflare worker adapter + added docs about different modes 2024-12-11 17:09:28 +01:00
dswbx 2395d7fe97 fix: only sync config table once certain it has to happen 2024-12-11 15:51:00 +01:00
dswbx b7ec4982dc cli: automatically register local adapter 2024-12-10 15:50:41 +01:00
dswbx 10d606f8e9 docs: added docker instructions 2024-12-10 09:57:05 +01:00
dswbx 5cca911e9d Merge remote-tracking branch 'origin/main' into feat/node-media-local 2024-12-10 08:53:09 +01:00
dswbx 13a88be8d1 Merge pull request #23 from bknd-io/release/0.2.3
Release 0.3
2024-12-10 08:52:20 +01:00
dswbx a5264e22ee bump version 0.3.0 2024-12-10 08:51:54 +01:00
dswbx 290498de6e refactor registries to make registration more convenient 2024-12-10 08:47:23 +01:00
dswbx 847b08b505 fix createApp() fn doesn't require config, fix admin controller main entry 2024-12-10 08:44:50 +01:00
dswbx 86a7bee3d9 fix prototype tests 2024-12-07 19:35:34 +01:00
dswbx 3932396084 fixing imports and ssr support + updated remix/nextjs/astro examples 2024-12-07 19:31:12 +01:00
dswbx 94cc4042d3 small refactorings and cleanups, improved bun/node adapter, updated docs 2024-12-07 18:55:02 +01:00
dswbx 154703f873 added treeshake option to build + finalized package json (for now) 2024-12-07 17:19:27 +01:00
dswbx 71dbfc5469 moved main pkg back to deps to reduce tarball size + removed zod + cleanup old code + fixed tests 2024-12-07 10:11:26 +01:00
dswbx e5924b33e5 exclude metafiles in npm package to reduce tarball size 2024-12-07 09:46:30 +01:00
dswbx c25abd4a48 Merge remote-tracking branch 'origin/refactor/admin-basepath' into release/0.2.3 2024-12-07 09:30:32 +01:00
dswbx 7c58f20cca Merge remote-tracking branch 'origin/refactor/ext-deps' into release/0.2.3 2024-12-07 09:30:06 +01:00
dswbx ef18d8d955 Merge remote-tracking branch 'origin/misc/docker' into release/0.2.3 2024-12-07 09:29:48 +01:00
dswbx c7c6aa7df8 Merge remote-tracking branch 'origin/refactor/module-manager' into release/0.2.3 2024-12-07 09:29:31 +01:00
dswbx 060af0a1e3 fix react peer dependency version constraint (to prevent ERESOLVE warns) 2024-12-07 09:03:31 +01:00
dswbx 44a427de67 moved almost all deps to dev to speed up installs, updated nextjs docs 2024-12-07 08:48:48 +01:00
dswbx aa75355a69 use admin routes registering basepath from registration method instead of configuration
Signed-off-by: dswbx <dennis.senn@gmx.ch>
2024-12-07 06:34:40 +01:00
dswbx 110c7681b7 externalize libsql dependency to let installing env install the correct dep
Signed-off-by: dswbx <dennis.senn@gmx.ch>
2024-12-07 06:29:29 +01:00
dswbx ba517feab5 fix typings for initial partial config
Signed-off-by: dswbx <dennis.senn@gmx.ch>
2024-12-05 20:48:49 +01:00
dswbx 7e990feb99 added new diffing method to module manager
Signed-off-by: dswbx <dennis.senn@gmx.ch>
2024-12-05 20:23:51 +01:00
dswbx 77a6b6e7f5 fix repository's findOne to allow offset and sort – fixes module manager's config retrieval 2024-12-05 10:37:05 +01:00
dswbx a3348122e6 module manager: use json schema field for additional validation 2024-12-05 09:50:25 +01:00
dswbx 3757157a06 module manager: switched config table to use entity 2024-12-05 09:23:34 +01:00
dswbx 6c2f7b32e5 module manager: allow initial config without config as fallback 2024-12-05 08:12:46 +01:00
dswbx 067213ff60 docs: updated astro to add external example + added hint for astro db issues on v5 2024-12-04 15:54:33 +01:00
dswbx a4079804c2 release 0.2.2 2024-12-04 14:49:55 +01:00
dswbx 2bd4b720bb Merge pull request #18 from bknd-io/17-cannot-find-module-node_moduleslodash-esget
fix: lodash-es/* imports causing crash on astro
2024-12-04 14:49:19 +01:00
dswbx f3619bee26 fix lodash-es/* imports causing crash on astro 2024-12-04 14:47:52 +01:00
dswbx c54455870c fix: docs logo 2024-12-03 19:34:14 +01:00
dswbx 20477a655e update docs to navigate to web on logo click 2024-12-03 19:30:16 +01:00
dswbx 1497470d33 bump version for patch release 0.2.1 2024-12-03 17:21:06 +01:00
dswbx abe38fe69d Merge pull request #12 from bknd-io/chores/remove-deps
Remove unusued deps and fixing auth tests
2024-12-03 17:18:18 +01:00
dswbx 665c41f051 Merge pull request #16 from bknd-io/chores/export-data-prototype
export data prototype
2024-12-03 17:17:53 +01:00
dswbx 0184f47a41 added prototype to the list of exports from "bknd/data" 2024-12-03 17:15:53 +01:00
dswbx 5374afc9c8 remove the quick theme switcher if admin settings are overridden 2024-12-03 17:09:27 +01:00
dswbx d6978f9873 fix auth tests 2024-12-03 09:16:00 +01:00
dswbx 3c5bd95988 removed unused deps 2024-12-03 09:15:39 +01:00
dswbx b3e02394ac added an initial docker image 2024-12-03 08:51:30 +01:00
dswbx 91470d530f bump version for release 0.2.0 2024-12-01 09:02:32 +01:00
dswbx 0c8f3cd22a Merge pull request #9 from bknd-io/feat/astro-adapter-2
improved astro adapter (serving api) + added documentation
2024-12-01 09:00:40 +01:00
dswbx feeb13c053 improved astro adapter (serving api) + added documentation 2024-12-01 08:58:08 +01:00
dswbx b55fdd7516 fix invalid discord invite + added missing updates from astro adapter 2024-11-30 07:20:17 +01:00
dswbx 894a90fca9 Merge pull request #5 from bknd-io/feat/astro-adapter
Feat: Astro Adapter
2024-11-29 21:23:26 +01:00
dswbx 582dbd4272 added minimal astro adapter + improved the example 2024-11-29 21:21:59 +01:00
dswbx 6eb0d2242f Merge remote-tracking branch 'origin/main' into feat/astro-adapter 2024-11-29 20:14:31 +01:00
dswbx 4693be5615 added db for astro 2024-11-28 11:22:02 +01:00
dswbx bdc6eb55bf added bknd admin config override to make it easier to include <Admin /> 2024-11-28 11:15:07 +01:00
dswbx 54b38401d8 added fully working astro example 2024-11-28 10:22:18 +01:00
dswbx 49c2a7b4db Merge remote-tracking branch 'origin/main' into feat/astro-adapter 2024-11-28 10:12:37 +01:00
dswbx 6718419d41 prepare admin for astro: inject clientprovider props directly from admin component 2024-11-28 10:04:14 +01:00
700 changed files with 35984 additions and 11622 deletions
+26
View File
@@ -0,0 +1,26 @@
name: Run Tests
on:
pull_request:
push:
branches: [ main ]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Bun
uses: oven-sh/setup-bun@v1
with:
bun-version: latest
- name: Install dependencies
working-directory: ./app
run: bun install
- name: Run tests
working-directory: ./app
run: bun run test
+7 -1
View File
@@ -14,13 +14,19 @@ packages/media/.env
**/*/.env
**/*/.dev.vars
**/*/.wrangler
**/*/*.tgz
**/*/vite.config.ts.timestamp*
.history
**/*/.db/*
**/*/.configs/*
**/*/.template/*
**/*/*.db
**/*/*.db-shm
**/*/*.db-wal
**/*/.tmp
.npmrc
/.verdaccio
.idea
.vscode
.git_old
.git_old
docker/tmp
+1 -1
View File
@@ -6,7 +6,7 @@ FSL-1.1-MIT
## Notice
Copyright 2024 Webintex GmbH
Copyright 2025 Dennis Senn
## Terms and Conditions
+120 -29
View File
@@ -1,9 +1,15 @@
![bknd](https://raw.githubusercontent.com/bknd-io/bknd/refs/heads/main/app/src/ui/assets/poster.png)
[![npm version](https://img.shields.io/npm/v/bknd.svg)](https://npmjs.org/package/bknd)
[![npm downloads](https://img.shields.io/npm/dm/bknd)](https://www.npmjs.com/package/bknd)
bknd simplifies app development by providing fully functional backend for data management,
authentication, workflows and media. Since it's lightweight and built on Web Standards, it can
be deployed nearly anywhere, including running inside your framework of choice. No more
deploying multiple separate services!
![bknd](https://raw.githubusercontent.com/bknd-io/bknd/refs/heads/main/docs/_assets/poster.png)
<p align="center" width="100%">
<a href="https://stackblitz.com/github/bknd-io/bknd-examples?hideExplorer=1&embed=1&view=preview&startScript=example-admin-rich&initialPath=%2Fdata%2Fschema" target="_blank">
<strong>⭐ Live Demo</strong>
</a>
</p>
bknd simplifies app development by providing a fully functional backend for database management, authentication, media and workflows. Being lightweight and built on Web Standards, it can be deployed nearly anywhere, including running inside your framework of choice. No more deploying multiple separate services!
**For documentation and examples, please visit https://docs.bknd.io.**
@@ -11,33 +17,118 @@ deploying multiple separate services!
> Please keep in mind that **bknd** is still under active development
> and therefore full backward compatibility is not guaranteed before reaching v1.0.0.
## ✨ Features
**📊 Data**: Define, query, and control your data with ease.
- Define entities with fields and relationships, synced directly to your database.
- Supported field types: `primary`, `text`, `number`, `date`, `boolean`, `enum`, `json`, `jsonschema`.
- Relationship types: `one-to-one`, `many-to-one`, `many-to-many`, and `polymorphic`.
- Advanced querying with the **Repository**: filtering, sorting, pagination, and relational data handling.
- Seamlessly manage data with mutators and a robust event system.
- Extend database functionality with batching, introspection, and support for multiple SQL dialects.
## Size
![gzipped size of bknd](https://img.badgesize.io/https://unpkg.com/bknd@latest/dist/index.js?compression=gzip&label=bknd)
![gzipped size of bknd/client](https://img.badgesize.io/https://unpkg.com/bknd@latest/dist/ui/client/index.js?compression=gzip&label=bknd/client)
![gzipped size of bknd/elements](https://img.badgesize.io/https://unpkg.com/bknd@latest/dist/ui/elements/index.js?compression=gzip&label=bknd/elements)
![gzipped size of bknd/ui](https://img.badgesize.io/https://unpkg.com/bknd@latest/dist/ui/index.js?compression=gzip&label=bknd/ui)
**🔐 Auth**: Easily implement reliable authentication strategies.
- Built-in `user` entity with customizable fields.
- Supports multiple authentication strategies:
- Email/password (with hashed storage).
- OAuth/OIDC (Google, GitHub, and more).
- Secure JWT generation and session management.
The size on npm is misleading, as the `bknd` package includes the backend, the ui components as well as the whole backend bundled into the cli including static assets.
**🖼️ Media**: Effortlessly manage and serve all your media files.
- Upload files with ease.
- Adapter-based support for S3, S3-compatible storage (e.g., R2, Tigris), and Cloudinary.
Depending on what you use, the size can be higher as additional dependencies are getting pulled in. The minimal size of a full `bknd` app as an API is around 212 kB gzipped (e.g. deployed as Cloudflare Worker).
**🔄 Flows** (UI integration coming soon!): Design and run workflows with seamless automation.
- Create and run workflows with trigger-based automation:
- Manual triggers or events from data, auth, media, or server actions.
- HTTP triggers for external integrations.
- Define tasks in sequence, parallel, or loops, with conditional execution.
- Use reusable sub-workflows to organize complex processes.
- Leverage OpenAPI specifications for API-based tasks.
## Motivation
Creating digital products always requires developing both the backend (the logic) and the frontend (the appearance). Building a backend from scratch demands deep knowledge in areas such as authentication and database management. Using a backend framework can speed up initial development, but it still requires ongoing effort to work within its constraints (e.g., *"how to do X with Y?"*), which can quickly slow you down. Choosing a backend system is a tough decision, as you might not be aware of its limitations until you encounter them.
**The solution:** A backend system that only assumes and implements primitive details, integrates into multiple environments, and adheres to industry standards.
## Features
* ⚡ Instant backend with full REST API:
* **Data**: Define, query, and control your data with ease.
* **Auth**: Easily implement reliable authentication strategies.
* **Media**: Effortlessly manage and serve all your media files.
* **Flows**: Design and run workflows with seamless automation. (UI integration coming soon!)
* 🌐 Built on Web Standards for maximum compatibility
* 🏃‍♂️ Multiple run modes
* standalone using the CLI
* using a JavaScript runtime (Node, Bun, workerd)
* using a React framework (Next.js, React Router, Astro)
* 📦 Official API and React SDK with type-safety
* ⚛️ React elements for auto-configured authentication and media components
## Structure
The package is mainly split into 4 parts, each serving a specific purpose:
| Import | Purpose |
|-----------------------------|------------------------------------------------------|
| `bknd`<br/>`bknd/adapter/*` | Backend including APIs and adapters |
| `bknd/ui` | Admin UI components for react frameworks |
| `bknd/client` | TypeScript SDK and React hooks for the API endpoints |
| `bknd/elements` | React components for authentication and media |
### The backend (`bknd`)
Serve the backend as an API for any JS runtime or framework. The latter is especially handy, as it allows you to deploy your frontend and backend bundled together. Furthermore it allows adding additional logic in a way you're already familar with. Just add another route and you're good to go.
Here is an example of serving the API using node:
```js index.js
import { serve } from "bknd/adapter/node"
serve();
```
### Integrated admin UI (`bknd/ui`)
The admin UI allows to manage your data including full configuration of your backend using a graphical user interface. Using `vite`, your admin route looks like this:
```tsx
import { Admin } from "bknd/ui"
import "bknd/dist/styles.css";
export default function AdminPage() {
return <Admin />
}
```
### Using the REST API or TypeScript SDK (`bknd/client`)
If you're not using a JavaScript environment, you can still access any endpoint using the REST API:
```bash
curl -XGET <your-endpoint>/api/data/entity/<entity>
{
"data": [
{ "id": 1, ... },
{ "id": 2, ... }
],
"meta": { /* ... */ }
}
```
In a JavaScript environment, you can use the TypeScript SDK with type-safety. The above example would look like this:
```ts
import { Api } from "bknd/client";
const api = new Api({ host: "<endpoint>" });
const { data } = await api.data.readMany("<entity>");
```
If you're using React, there are 2 hooks exposed (`useApi`, `useEntity`), as well as an `swr` wrapper around each (`useApiQuery`, `useEntityQuery`). The `swr` wrapped hooks automatically handled query invalidation:
```tsx
import { useState } from "react";
import { useEntityQuery } from "bknd/client";
export default function App() {
const { data } = useEntityQuery("todos");
return <ul>
{data?.map(todo => (
<li key={todo.id}>{todo.name}</li>
))}
</ul>
}
```
### React elements (`bknd/elements`)
You don't have to figure out API details to include media uploads to your app. For an user avatar upload, this is all you need:
```tsx
import { Media } from "bknd/elements"
import "bknd/dist/main.css"
export function UserAvatar() {
return <Media.Dropzone
entity={{ name: "users", id: 1, field: "avatar" }}
maxItems={1}
overwrite
/>
}
```
The import path also exports components for login and registration forms which are automatically pointed to the `bknd` defaults.
## 🚀 Quick start
+14 -2
View File
@@ -1,9 +1,9 @@
import { afterAll, describe, expect, test } from "bun:test";
import { afterAll, afterEach, describe, expect, test } from "bun:test";
import { App } from "../src";
import { getDummyConnection } from "./helper";
const { dummyConnection, afterAllCleanup } = getDummyConnection();
afterAll(afterAllCleanup);
afterEach(afterAllCleanup);
describe("App tests", async () => {
test("boots and pongs", async () => {
@@ -12,4 +12,16 @@ describe("App tests", async () => {
//expect(await app.data?.em.ping()).toBeTrue();
});
/*test.only("what", async () => {
const app = new App(dummyConnection, {
auth: {
enabled: true,
},
});
await app.module.auth.build();
await app.module.data.build();
console.log(app.em.entities.map((e) => e.name));
console.log(await app.em.schema().getDiff());
});*/
});
-38
View File
@@ -1,38 +0,0 @@
import { describe, expect, test } from "bun:test";
import { type TSchema, Type, stripMark } from "../src/core/utils";
import { Module } from "../src/modules/Module";
function createModule<Schema extends TSchema>(schema: Schema) {
class TestModule extends Module<typeof schema> {
getSchema() {
return schema;
}
toJSON() {
return this.config;
}
useForceParse() {
return true;
}
}
return TestModule;
}
describe("Module", async () => {
test("basic", async () => {});
test("listener", async () => {
let result: any;
const module = createModule(Type.Object({ a: Type.String() }));
const m = new module({ a: "test" });
await m.schema().set({ a: "test2" });
m.setListener(async (c) => {
await new Promise((r) => setTimeout(r, 10));
result = stripMark(c);
});
await m.schema().set({ a: "test3" });
expect(result).toEqual({ a: "test3" });
});
});
-197
View File
@@ -1,197 +0,0 @@
import { describe, expect, test } from "bun:test";
import { mark, stripMark } from "../src/core/utils";
import { ModuleManager } from "../src/modules/ModuleManager";
import { CURRENT_VERSION, TABLE_NAME, migrateSchema } from "../src/modules/migrations";
import { getDummyConnection } from "./helper";
describe("ModuleManager", async () => {
test("s1: no config, no build", async () => {
const { dummyConnection } = getDummyConnection();
const mm = new ModuleManager(dummyConnection);
// that is because no module is built
expect(mm.toJSON()).toEqual({ version: 0 } as any);
});
test("s2: no config, build", async () => {
const { dummyConnection } = getDummyConnection();
const mm = new ModuleManager(dummyConnection);
await mm.build();
expect(mm.version()).toBe(CURRENT_VERSION);
expect(mm.built()).toBe(true);
});
test("s3: config given, table exists, version matches", async () => {
const c = getDummyConnection();
const mm = new ModuleManager(c.dummyConnection);
await mm.build();
const version = mm.version();
const json = mm.configs();
//const { version, ...json } = mm.toJSON() as any;
const c2 = getDummyConnection();
const db = c2.dummyConnection.kysely;
await migrateSchema(CURRENT_VERSION, { db });
await db
.updateTable(TABLE_NAME)
.set({ json: JSON.stringify(json), version: CURRENT_VERSION })
.execute();
const mm2 = new ModuleManager(c2.dummyConnection, { initial: { version, ...json } });
await mm2.build();
expect(json).toEqual(mm2.configs());
});
test("s4: config given, table exists, version outdated, migrate", async () => {
const c = getDummyConnection();
const mm = new ModuleManager(c.dummyConnection);
await mm.build();
const version = mm.version();
const json = mm.configs();
//const { version, ...json } = mm.toJSON() as any;
const c2 = getDummyConnection();
const db = c2.dummyConnection.kysely;
console.log("here2");
await migrateSchema(CURRENT_VERSION, { db });
await db
.updateTable(TABLE_NAME)
.set({ json: JSON.stringify(json), version: CURRENT_VERSION - 1 })
.execute();
const mm2 = new ModuleManager(c2.dummyConnection, {
initial: { version: version - 1, ...json }
});
console.log("here3");
await mm2.build();
});
test("s5: config given, table exists, version mismatch", async () => {
const c = getDummyConnection();
const mm = new ModuleManager(c.dummyConnection);
await mm.build();
const version = mm.version();
const json = mm.configs();
//const { version, ...json } = mm.toJSON() as any;
const c2 = getDummyConnection();
const db = c2.dummyConnection.kysely;
await migrateSchema(CURRENT_VERSION, { db });
await db
.updateTable(TABLE_NAME)
.set({ json: JSON.stringify(json), version: CURRENT_VERSION })
.execute();
const mm2 = new ModuleManager(c2.dummyConnection, {
initial: { version: version - 1, ...json }
});
expect(mm2.build()).rejects.toThrow(/version.*do not match/);
});
test("s6: no config given, table exists, fetch", async () => {
const c = getDummyConnection();
const mm = new ModuleManager(c.dummyConnection);
await mm.build();
const json = mm.configs();
//const { version, ...json } = mm.toJSON() as any;
const c2 = getDummyConnection();
const db = c2.dummyConnection.kysely;
await migrateSchema(CURRENT_VERSION, { db });
const config = {
...json,
data: {
...json.data,
basepath: "/api/data2"
}
};
await db
.updateTable(TABLE_NAME)
.set({ json: JSON.stringify(config), version: CURRENT_VERSION })
.execute();
// run without config given
const mm2 = new ModuleManager(c2.dummyConnection);
await mm2.build();
expect(mm2.configs().data.basepath).toBe("/api/data2");
});
test("blank app, modify config", async () => {
const { dummyConnection } = getDummyConnection();
const mm = new ModuleManager(dummyConnection);
await mm.build();
const configs = stripMark(mm.configs());
expect(mm.configs().server.admin.color_scheme).toBe("light");
expect(() => mm.get("server").schema().patch("admin", { color_scheme: "violet" })).toThrow();
await mm.get("server").schema().patch("admin", { color_scheme: "dark" });
await mm.save();
expect(mm.configs().server.admin.color_scheme).toBe("dark");
expect(stripMark(mm.configs())).toEqual({
...configs,
server: {
...configs.server,
admin: {
...configs.server.admin,
color_scheme: "dark"
}
}
});
});
// @todo: check what happens here
/*test("blank app, modify deep config", async () => {
const { dummyConnection } = getDummyConnection();
const mm = new ModuleManager(dummyConnection);
await mm.build();
/!* await mm
.get("data")
.schema()
.patch("entities.test", {
fields: {
content: {
type: "text"
}
}
});
await mm.build();
expect(mm.configs().data.entities?.users?.fields?.email.type).toBe("text");
expect(
mm.get("data").schema().patch("desc", "entities.users.config.sort_dir")
).rejects.toThrow();
await mm.build();*!/
expect(mm.configs().data.entities?.users?.fields?.email.type).toBe("text");
console.log("here", mm.configs());
await mm
.get("data")
.schema()
.patch("entities.users", { config: { sort_dir: "desc" } });
await mm.build();
expect(mm.toJSON());
//console.log(_jsonp(mm.toJSON().data));
/!*expect(mm.configs().data.entities!.test!.fields!.content.type).toBe("text");
expect(mm.configs().data.entities!.users!.config!.sort_dir).toBe("desc");*!/
});*/
/*test("accessing modules", async () => {
const { dummyConnection } = getDummyConnection();
const mm = new ModuleManager(dummyConnection);
//mm.get("auth").mutate().set({});
});*/
});
+1
View File
@@ -0,0 +1 @@
tmp/*
Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

+71
View File
@@ -0,0 +1,71 @@
import { describe, expect, it } from "bun:test";
import { sign } from "hono/jwt";
import { Api } from "../../src/Api";
describe("Api", async () => {
it("should construct without options", () => {
const api = new Api();
expect(api.baseUrl).toBe("http://localhost");
expect(api.isAuthVerified()).toBe(false);
});
it("should ignore force verify if no claims given", () => {
const api = new Api({ verified: true });
expect(api.baseUrl).toBe("http://localhost");
expect(api.isAuthVerified()).toBe(false);
});
it("should construct from request (token)", async () => {
const token = await sign({ foo: "bar" }, "1234");
const request = new Request("http://example.com/test", {
headers: {
Authorization: `Bearer ${token}`,
},
});
const api = new Api({ request });
expect(api.isAuthVerified()).toBe(false);
const params = api.getParams();
expect(params.token).toBe(token);
expect(params.token_transport).toBe("header");
expect(params.host).toBe("http://example.com");
});
it("should construct from request (cookie)", async () => {
const token = await sign({ foo: "bar" }, "1234");
const request = new Request("http://example.com/test", {
headers: {
Cookie: `auth=${token}`,
},
});
const api = new Api({ request });
expect(api.isAuthVerified()).toBe(false);
const params = api.getParams();
console.log(params);
expect(params.token).toBe(token);
expect(params.token_transport).toBe("cookie");
expect(params.host).toBe("http://example.com");
});
it("should construct from token", async () => {
const token = await sign({ foo: "bar" }, "1234");
const api = new Api({ token });
expect(api.isAuthVerified()).toBe(false);
const params = api.getParams();
expect(params.token).toBe(token);
expect(params.token_transport).toBe("header");
expect(params.host).toBe("http://localhost");
});
it("should prefer host when request is given", async () => {
const request = new Request("http://example.com/test");
const api = new Api({ request, host: "http://another.com" });
const params = api.getParams();
expect(params.token).toBeUndefined();
expect(params.token_transport).toBe("header");
expect(params.host).toBe("http://another.com");
});
});
+212
View File
@@ -0,0 +1,212 @@
import { afterAll, beforeAll, describe, expect, it } from "bun:test";
import { Guard } from "../../src/auth";
import { parse } from "../../src/core/utils";
import { DataApi } from "../../src/data/api/DataApi";
import { DataController } from "../../src/data/api/DataController";
import { dataConfigSchema } from "../../src/data/data-schema";
import * as proto from "../../src/data/prototype";
import { disableConsoleLog, enableConsoleLog, schemaToEm } from "../helper";
beforeAll(disableConsoleLog);
afterAll(enableConsoleLog);
const dataConfig = parse(dataConfigSchema, {});
describe("DataApi", () => {
it("should switch to post for long url reads", async () => {
const api = new DataApi();
const get = api.readMany("a".repeat(300), { select: ["id", "name"] });
expect(get.request.method).toBe("GET");
expect(new URL(get.request.url).pathname).toBe(`/api/data/entity/${"a".repeat(300)}`);
const post = api.readMany("a".repeat(1000), { select: ["id", "name"] });
expect(post.request.method).toBe("POST");
expect(new URL(post.request.url).pathname).toBe(`/api/data/entity/${"a".repeat(1000)}/query`);
});
it("returns result", async () => {
const schema = proto.em({
posts: proto.entity("posts", { title: proto.text() }),
});
const em = schemaToEm(schema);
await em.schema().sync({ force: true });
const payload = [{ title: "foo" }, { title: "bar" }, { title: "baz" }];
await em.mutator("posts").insertMany(payload);
const ctx: any = { em, guard: new Guard() };
const controller = new DataController(ctx, dataConfig);
const app = controller.getController();
{
const res = (await app.request("/entity/posts")) as Response;
const { data } = (await res.json()) as any;
expect(data.length).toEqual(3);
}
// @ts-ignore tests
const api = new DataApi({ basepath: "/", queryLengthLimit: 50 }, app.request as typeof fetch);
{
const req = api.readMany("posts", { select: ["title"] });
expect(req.request.method).toBe("GET");
const res = await req;
expect(res.data).toEqual(payload as any);
}
{
const req = api.readMany("posts", {
select: ["title"],
limit: 100000,
offset: 0,
sort: "id",
});
expect(req.request.method).toBe("POST");
const res = await req;
expect(res.data).toEqual(payload as any);
}
});
it("updates many", async () => {
const schema = proto.em({
posts: proto.entity("posts", { title: proto.text(), count: proto.number() }),
});
const em = schemaToEm(schema);
await em.schema().sync({ force: true });
const payload = [
{ title: "foo", count: 0 },
{ title: "bar", count: 0 },
{ title: "baz", count: 0 },
{ title: "bla", count: 2 },
];
await em.mutator("posts").insertMany(payload);
const ctx: any = { em, guard: new Guard() };
const controller = new DataController(ctx, dataConfig);
const app = controller.getController();
// @ts-ignore tests
const api = new DataApi({ basepath: "/" }, app.request as typeof fetch);
{
const req = api.readMany("posts", {
select: ["title", "count"],
});
const res = await req;
expect(res.data).toEqual(payload as any);
}
{
// update with empty where
expect(() => api.updateMany("posts", {}, { count: 1 })).toThrow();
expect(() => api.updateMany("posts", undefined, { count: 1 })).toThrow();
}
{
// update
const req = await api.updateMany("posts", { count: 0 }, { count: 1 });
expect(req.res.status).toBe(200);
}
{
// compare
const res = await api.readMany("posts", {
select: ["title", "count"],
});
expect(res.map((p) => p.count)).toEqual([1, 1, 1, 2]);
}
});
it("refines", async () => {
const schema = proto.em({
posts: proto.entity("posts", { title: proto.text() }),
});
const em = schemaToEm(schema);
await em.schema().sync({ force: true });
const payload = [{ title: "foo" }, { title: "bar" }, { title: "baz" }];
await em.mutator("posts").insertMany(payload);
const ctx: any = { em, guard: new Guard() };
const controller = new DataController(ctx, dataConfig);
const app = controller.getController();
const api = new DataApi({ basepath: "/" }, app.request as typeof fetch);
const normalOne = api.readOne("posts", 1);
const normal = api.readMany("posts", { select: ["title"], where: { title: "baz" } });
expect((await normal).data).toEqual([{ title: "baz" }] as any);
// refine
const refined = normal.refine((data) => data[0]);
expect((await refined).data).toEqual({ title: "baz" } as any);
// one
const oneBy = api.readOneBy("posts", { where: { title: "baz" }, select: ["title"] });
const oneByRes = await oneBy;
expect(oneByRes.data).toEqual({ title: "baz" } as any);
expect(oneByRes.body.meta.count).toEqual(1);
});
it("exists/count", async () => {
const schema = proto.em({
posts: proto.entity("posts", { title: proto.text() }),
});
const em = schemaToEm(schema);
await em.schema().sync({ force: true });
const payload = [{ title: "foo" }, { title: "bar" }, { title: "baz" }];
await em.mutator("posts").insertMany(payload);
const ctx: any = { em, guard: new Guard() };
const controller = new DataController(ctx, dataConfig);
const app = controller.getController();
const api = new DataApi({ basepath: "/" }, app.request as typeof fetch);
const exists = api.exists("posts", { id: 1 });
expect((await exists).exists).toBeTrue();
expect((await api.count("posts")).count).toEqual(3);
});
it("creates many", async () => {
const schema = proto.em({
posts: proto.entity("posts", { title: proto.text(), count: proto.number() }),
});
const em = schemaToEm(schema);
await em.schema().sync({ force: true });
const payload = [
{ title: "foo", count: 0 },
{ title: "bar", count: 0 },
{ title: "baz", count: 0 },
{ title: "bla", count: 2 },
];
const ctx: any = { em, guard: new Guard() };
const controller = new DataController(ctx, dataConfig);
const app = controller.getController();
// @ts-ignore tests
const api = new DataApi({ basepath: "/" }, app.request as typeof fetch);
{
// create many
const res = await api.createMany("posts", payload);
expect(res.data.length).toEqual(4);
expect(res.ok).toBeTrue();
}
{
const req = api.readMany("posts", {
select: ["title", "count"],
});
const res = await req;
expect(res.data).toEqual(payload as any);
}
{
// create with empty
expect(() => api.createMany("posts", [])).toThrow();
}
});
});
+146
View File
@@ -0,0 +1,146 @@
/// <reference types="@types/bun" />
import { describe, expect, it } from "bun:test";
import { Hono } from "hono";
import { getFileFromContext, isFile, isReadableStream } from "../../src/core/utils";
import { MediaApi } from "../../src/media/api/MediaApi";
import { assetsPath, assetsTmpPath } from "../helper";
const mockedBackend = new Hono()
.basePath("/api/media")
.post("/upload/:name", async (c) => {
const { name } = c.req.param();
const body = await getFileFromContext(c);
return c.json({ name, is_file: isFile(body), size: body.size });
})
.get("/file/:name", async (c) => {
const { name } = c.req.param();
const file = Bun.file(`${assetsPath}/${name}`);
return new Response(file, {
headers: {
"Content-Type": file.type,
"Content-Length": file.size.toString(),
},
});
});
describe("MediaApi", () => {
it("should give correct file upload url", () => {
const host = "http://localhost";
const basepath = "/api/media";
// @ts-ignore tests
const api = new MediaApi({
host,
basepath,
});
expect(api.getFileUploadUrl({ path: "path" } as any)).toBe(`${host}${basepath}/upload/path`);
});
it("should have correct upload headers", () => {
// @ts-ignore tests
const api = new MediaApi({
token: "token",
});
expect(api.getUploadHeaders().get("Authorization")).toBe("Bearer token");
});
it("should get file: native", async () => {
const name = "image.png";
const path = `${assetsTmpPath}/${name}`;
const res = await mockedBackend.request("/api/media/file/" + name);
await Bun.write(path, res);
const file = await Bun.file(path);
expect(file.size).toBeGreaterThan(0);
expect(file.type).toBe("image/png");
await file.delete();
});
it("download", async () => {
// @ts-ignore tests
const api = new MediaApi({}, mockedBackend.request);
const name = "image.png";
const file = await api.download(name);
expect(isFile(file)).toBe(true);
expect(file.size).toBeGreaterThan(0);
expect(file.type).toBe("image/png");
expect(file.name).toContain(name);
});
it("getFile", async () => {
// @ts-ignore tests
const api = new MediaApi({}, mockedBackend.request);
const name = "image.png";
const res = await api.getFile(name);
expect(res.ok).toBe(true);
// make sure it's a normal api request as usual
expect(res.res.ok).toBe(true);
expect(isReadableStream(res)).toBe(true);
expect(isReadableStream(res.body)).toBe(true);
expect(isReadableStream(res.res.body)).toBe(true);
const blob = await res.res.blob();
expect(isFile(blob)).toBe(true);
expect(blob.size).toBeGreaterThan(0);
expect(blob.type).toBe("image/png");
expect(blob.name).toContain(name);
});
it("getFileStream", async () => {
// @ts-ignore tests
const api = new MediaApi({}, mockedBackend.request);
const name = "image.png";
const res = await api.getFileStream(name);
expect(isReadableStream(res)).toBe(true);
const blob = await new Response(res).blob();
expect(isFile(blob)).toBe(true);
expect(blob.size).toBeGreaterThan(0);
expect(blob.type).toBe("image/png");
expect(blob.name).toContain(name);
});
it("should upload file in various ways", async () => {
// @ts-ignore tests
const api = new MediaApi({}, mockedBackend.request);
const file = Bun.file(`${assetsPath}/image.png`);
async function matches(req: Promise<any>, filename: string) {
const res: any = await req;
expect(res.name).toBe(filename);
expect(res.is_file).toBe(true);
expect(res.size).toBe(file.size);
}
const url = "http://localhost/api/media/file/image.png";
// upload bun file
await matches(api.upload(file as any, { filename: "bunfile.png" }), "bunfile.png");
// upload via request
await matches(api.upload(new Request(url), { filename: "request.png" }), "request.png");
// upload via url
await matches(api.upload(url, { filename: "url.png" }), "url.png");
// upload via response
{
const response = await mockedBackend.request(url);
await matches(api.upload(response, { filename: "response.png" }), "response.png");
}
// upload via readable from bun
await matches(await api.upload(file.stream(), { filename: "readable.png" }), "readable.png");
// upload via readable from response
{
const response = (await mockedBackend.request(url)) as Response;
await matches(
await api.upload(response.body!, { filename: "readable.png" }),
"readable.png",
);
}
});
});
+102
View File
@@ -0,0 +1,102 @@
import { describe, expect, it } from "bun:test";
import { Hono } from "hono";
import { secureRandomString } from "../../src/core/utils";
import { ModuleApi } from "../../src/modules";
class Api extends ModuleApi {
_getUrl(path: string) {
return this.getUrl(path);
}
}
const host = "http://localhost";
describe("ModuleApi", () => {
it("resolves options correctly", () => {
const api = new Api({ host });
expect(api.options).toEqual({ host });
});
it("returns correct url from path", () => {
const api = new Api({ host });
expect(api._getUrl("/test")).toEqual("http://localhost/test");
expect(api._getUrl("test")).toEqual("http://localhost/test");
expect(api._getUrl("test/")).toEqual("http://localhost/test");
expect(api._getUrl("//test?foo=1")).toEqual("http://localhost/test?foo=1");
});
it("fetches endpoint", async () => {
const app = new Hono().get("/endpoint", (c) => c.json({ foo: "bar" }));
const api = new Api({ host }, app.request as typeof fetch);
const res = await api.get("/endpoint");
expect(res.res.ok).toEqual(true);
expect(res.res.status).toEqual(200);
expect(res.data).toEqual({ foo: "bar" });
expect(res.body).toEqual({ foo: "bar" });
});
it("has accessible request", async () => {
const app = new Hono().get("/endpoint", (c) => c.json({ foo: "bar" }));
const api = new Api({ host }, app.request as typeof fetch);
const promise = api.get("/endpoint");
expect(promise.request).toBeDefined();
expect(promise.request.url).toEqual("http://localhost/endpoint");
expect((await promise).body).toEqual({ foo: "bar" });
});
it("adds token to headers when given in options", () => {
const token = secureRandomString(20);
const api = new Api({ host, token, token_transport: "header" });
expect(api.get("/").request.headers.get("Authorization")).toEqual(`Bearer ${token}`);
});
it("sets header to accept json", () => {
const api = new Api({ host });
expect(api.get("/").request.headers.get("Accept")).toEqual("application/json");
});
it("adds additional headers from options", () => {
const headers = new Headers({
"X-Test": "123",
});
const api = new Api({ host, headers });
expect(api.get("/").request.headers.get("X-Test")).toEqual("123");
});
it("uses basepath & removes trailing slash", () => {
const api = new Api({ host, basepath: "/api" });
expect(api.get("/").request.url).toEqual("http://localhost/api");
});
it("uses search params", () => {
const api = new Api({ host });
const search = new URLSearchParams({
foo: "bar",
});
expect(api.get("/", search).request.url).toEqual("http://localhost/?" + search.toString());
});
it("resolves method shortcut fns correctly", () => {
const api = new Api({ host });
expect(api.get("/").request.method).toEqual("GET");
expect(api.post("/").request.method).toEqual("POST");
expect(api.put("/").request.method).toEqual("PUT");
expect(api.patch("/").request.method).toEqual("PATCH");
expect(api.delete("/").request.method).toEqual("DELETE");
});
it("refines", async () => {
const app = new Hono().get("/endpoint", (c) => c.json({ foo: ["bar"] }));
const api = new Api({ host }, app.request as typeof fetch);
expect((await api.get("/endpoint")).data).toEqual({ foo: ["bar"] });
expect((await api.get("/endpoint").refine((data) => data.foo)).data).toEqual(["bar"]);
});
// @todo: test error response
// @todo: test method shortcut functions
});
-15
View File
@@ -1,15 +0,0 @@
import { describe, test } from "bun:test";
import { DataApi } from "../../src/modules/data/api/DataApi";
describe("Api", async () => {
test("...", async () => {
/*const dataApi = new DataApi({
host: "https://dev-config-soma.bknd.run"
});
const one = await dataApi.readOne("users", 1);
const many = await dataApi.readMany("users", { limit: 2 });
console.log("one", one);
console.log("many", many);*/
});
});
+54
View File
@@ -0,0 +1,54 @@
import { describe, expect, mock, test } from "bun:test";
import type { ModuleBuildContext } from "../../src";
import { type App, createApp } from "../../src/App";
import * as proto from "../../src/data/prototype";
describe("App", () => {
test("seed includes ctx and app", async () => {
const called = mock(() => null);
await createApp({
options: {
seed: async ({ app, ...ctx }) => {
called();
expect(app).toBeDefined();
expect(ctx).toBeDefined();
expect(Object.keys(ctx)).toEqual([
"connection",
"server",
"em",
"emgr",
"guard",
"flags",
"logger",
]);
},
},
}).build();
expect(called).toHaveBeenCalled();
const app = createApp({
initialConfig: {
data: proto
.em({
todos: proto.entity("todos", {
title: proto.text(),
}),
})
.toJSON(),
},
options: {
//manager: { verbosity: 2 },
seed: async ({ app, ...ctx }: ModuleBuildContext & { app: App }) => {
await ctx.em.mutator("todos").insertOne({ title: "ctx" });
await app.getApi().data.createOne("todos", { title: "api" });
},
},
});
await app.build();
const todos = await app.getApi().data.readMany("todos");
expect(todos.length).toBe(2);
expect(todos[0]?.title).toBe("ctx");
expect(todos[1]?.title).toBe("api");
});
});
+103
View File
@@ -0,0 +1,103 @@
import { describe, expect, test } from "bun:test";
import { createApp, registries } from "../../src";
import * as proto from "../../src/data/prototype";
import { StorageLocalAdapter } from "../../src/media/storage/adapters/StorageLocalAdapter";
describe("repros", async () => {
/**
* steps:
* 1. enable media
* 2. create 'test' entity
* 3. add media to 'test'
*
* There was an issue that AppData had old configs because of system entity "media"
*/
test("registers media entity correctly to relate to it", async () => {
registries.media.register("local", StorageLocalAdapter);
const app = createApp();
await app.build();
{
// 1. enable media
const [, config] = await app.module.media.schema().patch("", {
enabled: true,
adapter: {
type: "local",
config: {
path: "./",
},
},
});
expect(config.enabled).toBe(true);
}
{
// 2. create 'test' entity
await app.module.data.schema().patch(
"entities.test",
proto
.entity("test", {
content: proto.text(),
})
.toJSON(),
);
expect(app.em.entities.map((e) => e.name)).toContain("test");
}
{
await app.module.data.schema().patch("entities.test.fields.files", {
type: "media",
config: {
required: false,
fillable: ["update"],
hidden: false,
mime_types: [],
virtual: true,
entity: "test",
},
});
expect(
app.module.data.schema().patch("relations.000", {
type: "poly",
source: "test",
target: "media",
config: { mappedBy: "files" },
}),
).resolves.toBeDefined();
}
expect(app.em.entities.map((e) => e.name)).toEqual(["media", "test"]);
});
test.only("verify inversedBy", async () => {
const schema = proto.em(
{
products: proto.entity("products", {
title: proto.text(),
}),
product_likes: proto.entity("product_likes", {
created_at: proto.date(),
}),
users: proto.entity("users", {}),
},
(fns, schema) => {
fns.relation(schema.product_likes).manyToOne(schema.products, { inversedBy: "likes" });
fns.relation(schema.product_likes).manyToOne(schema.users);
},
);
const app = createApp({ initialConfig: { data: schema.toJSON() } });
await app.build();
const info = (await (await app.server.request("/api/data/info/products")).json()) as any;
expect(info.fields).toEqual(["id", "title"]);
expect(info.relations.listable).toEqual([
{
entity: "product_likes",
ref: "likes",
},
]);
});
});
+22 -26
View File
@@ -7,19 +7,17 @@ describe("authorize", () => {
["read", "write"],
{
admin: {
permissions: ["read", "write"]
}
permissions: ["read", "write"],
},
},
{ enabled: true }
{ enabled: true },
);
const user = {
role: "admin"
role: "admin",
};
guard.setUserContext(user);
expect(guard.granted("read")).toBe(true);
expect(guard.granted("write")).toBe(true);
expect(guard.granted("read", user)).toBe(true);
expect(guard.granted("write", user)).toBe(true);
expect(() => guard.granted("something")).toThrow();
});
@@ -29,27 +27,25 @@ describe("authorize", () => {
["read", "write"],
{
admin: {
permissions: ["read", "write"]
permissions: ["read", "write"],
},
guest: {
permissions: ["read"],
is_default: true
}
is_default: true,
},
},
{ enabled: true }
{ enabled: true },
);
expect(guard.granted("read")).toBe(true);
expect(guard.granted("write")).toBe(false);
const user = {
role: "admin"
role: "admin",
};
guard.setUserContext(user);
expect(guard.granted("read")).toBe(true);
expect(guard.granted("write")).toBe(true);
expect(guard.granted("read", user)).toBe(true);
expect(guard.granted("write", user)).toBe(true);
});
test("guard implicit allow", async () => {
@@ -62,24 +58,24 @@ describe("authorize", () => {
test("role implicit allow", async () => {
const guard = Guard.create(["read", "write"], {
admin: {
implicit_allow: true
}
implicit_allow: true,
},
});
guard.setUserContext({
role: "admin"
});
const user = {
role: "admin",
};
expect(guard.granted("read")).toBe(true);
expect(guard.granted("write")).toBe(true);
expect(guard.granted("read", user)).toBe(true);
expect(guard.granted("write", user)).toBe(true);
});
test("guard with guest role implicit allow", async () => {
const guard = Guard.create(["read", "write"], {
guest: {
implicit_allow: true,
is_default: true
}
is_default: true,
},
});
expect(guard.getUserRole()?.name).toBe("guest");
+1
View File
@@ -0,0 +1 @@
import { describe, expect, it } from "bun:test";
@@ -7,10 +7,10 @@ describe("OAuthStrategy", async () => {
const strategy = new OAuthStrategy({
type: "oidc",
client: {
client_id: process.env.OAUTH_CLIENT_ID,
client_secret: process.env.OAUTH_CLIENT_SECRET
client_id: process.env.OAUTH_CLIENT_ID!,
client_secret: process.env.OAUTH_CLIENT_SECRET!,
},
name: "google"
name: "google",
});
const state = "---";
const redirect_uri = "http://localhost:3000/auth/google/callback";
@@ -19,11 +19,6 @@ describe("OAuthStrategy", async () => {
const config = await strategy.getConfig();
console.log("config", JSON.stringify(config, null, 2));
const request = await strategy.request({
redirect_uri,
state
});
const server = Bun.serve({
fetch: async (req) => {
const url = new URL(req.url);
@@ -31,13 +26,18 @@ describe("OAuthStrategy", async () => {
console.log("req", req);
const user = await strategy.callback(url, {
redirect_uri,
state
state,
});
console.log("---user", user);
}
return new Response("Bun!");
}
},
});
const request = await strategy.request({
redirect_uri,
state,
});
console.log("request", request);
-54
View File
@@ -1,54 +0,0 @@
import { describe, expect, it, test } from "bun:test";
import { Endpoint } from "../../src/core";
import { mockFetch2, unmockFetch } from "./helper";
const testC: any = {
json: (res: any) => Response.json(res)
};
const testNext = async () => {};
describe("Endpoint", async () => {
it("behaves as expected", async () => {
const endpoint = new Endpoint("GET", "/test", async () => {
return { hello: "test" };
});
expect(endpoint.method).toBe("GET");
expect(endpoint.path).toBe("/test");
const handler = endpoint.toHandler();
const response = await handler(testC, testNext);
expect(response.ok).toBe(true);
expect(await response.json()).toEqual({ hello: "test" });
});
it("can be $request(ed)", async () => {
const obj = { hello: "test" };
const baseUrl = "https://local.com:123";
const endpoint = Endpoint.get("/test", async () => obj);
mockFetch2(async (input: RequestInfo, init: RequestInit) => {
expect(input).toBe(`${baseUrl}/test`);
return new Response(JSON.stringify(obj), { status: 200 });
});
const response = await endpoint.$request({}, baseUrl);
expect(response).toEqual({
status: 200,
ok: true,
response: obj
});
unmockFetch();
});
it("resolves helper functions", async () => {
const params = ["/test", () => ({ hello: "test" })];
["get", "post", "patch", "put", "delete"].forEach((method) => {
const endpoint = Endpoint[method](...params);
expect(endpoint.method).toBe(method.toUpperCase());
expect(endpoint.path).toBe(params[0]);
});
});
});
+121 -14
View File
@@ -1,8 +1,12 @@
import { describe, expect, test } from "bun:test";
import { Event, EventManager, NoParamEvent } from "../../src/core/events";
import { afterAll, beforeAll, describe, expect, mock, test } from "bun:test";
import { Event, EventManager, InvalidEventReturn, NoParamEvent } from "../../src/core/events";
import { disableConsoleLog, enableConsoleLog } from "../helper";
beforeAll(disableConsoleLog);
afterAll(enableConsoleLog);
class SpecialEvent extends Event<{ foo: string }> {
static slug = "special-event";
static override slug = "special-event";
isBar() {
return this.params.foo === "bar";
@@ -10,37 +14,140 @@ class SpecialEvent extends Event<{ foo: string }> {
}
class InformationalEvent extends NoParamEvent {
static slug = "informational-event";
static override slug = "informational-event";
}
class ReturnEvent extends Event<{ foo: string }, string> {
static override slug = "return-event";
override validate(value: string) {
if (typeof value !== "string") {
throw new InvalidEventReturn("string", typeof value);
}
return this.clone({
foo: [this.params.foo, value].join("-"),
});
}
}
describe("EventManager", async () => {
test("test", async () => {
test("executes", async () => {
const call = mock(() => null);
const delayed = mock(() => null);
const emgr = new EventManager();
emgr.registerEvents([SpecialEvent, InformationalEvent]);
expect(emgr.eventExists("special-event")).toBe(true);
expect(emgr.eventExists("informational-event")).toBe(true);
expect(emgr.eventExists("unknown-event")).toBe(false);
emgr.onEvent(
SpecialEvent,
async (event, name) => {
console.log("Event: ", name, event.params.foo, event.isBar());
console.log("wait...");
await new Promise((resolve) => setTimeout(resolve, 100));
console.log("done waiting");
expect(name).toBe("special-event");
expect(event.isBar()).toBe(true);
call();
await new Promise((resolve) => setTimeout(resolve, 50));
delayed();
},
"sync"
"sync",
);
// don't allow unknown
expect(() => emgr.on("unknown", () => void 0)).toThrow();
emgr.onEvent(InformationalEvent, async (event, name) => {
console.log("Event: ", name, event.params);
call();
expect(name).toBe("informational-event");
});
await emgr.emit(new SpecialEvent({ foo: "bar" }));
console.log("done");
await emgr.emit(new InformationalEvent());
// expect construct signatures to not cause ts errors
new SpecialEvent({ foo: "bar" });
new InformationalEvent();
expect(true).toBe(true);
expect(call).toHaveBeenCalledTimes(2);
expect(delayed).toHaveBeenCalled();
});
test("custom async executor", async () => {
const call = mock(() => null);
const asyncExecutor = (p: Promise<any>[]) => {
call();
return Promise.all(p);
};
const emgr = new EventManager(
{ InformationalEvent },
{
asyncExecutor,
},
);
emgr.onEvent(InformationalEvent, async () => {});
await emgr.emit(new InformationalEvent());
expect(call).toHaveBeenCalled();
});
test("piping", async () => {
const onInvalidReturn = mock(() => null);
const asyncEventCallback = mock(() => null);
const emgr = new EventManager(
{ ReturnEvent, InformationalEvent },
{
onInvalidReturn,
},
);
// @ts-expect-error InformationalEvent has no return value
emgr.onEvent(InformationalEvent, async () => {
asyncEventCallback();
return 1;
});
emgr.onEvent(ReturnEvent, async () => "1", "sync");
emgr.onEvent(ReturnEvent, async () => "0", "sync");
// @todo: fix this
// @ts-expect-error must be string
emgr.onEvent(ReturnEvent, async () => 0, "sync");
// return is not required
emgr.onEvent(ReturnEvent, async () => {}, "sync");
// was "async", will not return
const e1 = await emgr.emit(new InformationalEvent());
expect(e1.returned).toBe(false);
const e2 = await emgr.emit(new ReturnEvent({ foo: "bar" }));
expect(e2.returned).toBe(true);
expect(e2.params.foo).toBe("bar-1-0");
expect(onInvalidReturn).toHaveBeenCalled();
expect(asyncEventCallback).toHaveBeenCalled();
});
test("once", async () => {
const call = mock(() => null);
const emgr = new EventManager({ InformationalEvent });
emgr.onEvent(
InformationalEvent,
async (event, slug) => {
expect(event).toBeInstanceOf(InformationalEvent);
expect(slug).toBe("informational-event");
call();
},
{ mode: "sync", once: true },
);
expect(emgr.getListeners().length).toBe(1);
await emgr.emit(new InformationalEvent());
expect(emgr.getListeners().length).toBe(0);
await emgr.emit(new InformationalEvent());
expect(emgr.getListeners().length).toBe(0);
expect(call).toHaveBeenCalledTimes(1);
});
});
+41 -10
View File
@@ -1,4 +1,4 @@
import { describe, test } from "bun:test";
import { describe, expect, test } from "bun:test";
import type { TObject, TString } from "@sinclair/typebox";
import { Registry } from "../../src/core/registry/Registry";
import { type TSchema, Type } from "../../src/core/utils";
@@ -11,6 +11,9 @@ class What {
method() {
return null;
}
getType() {
return Type.Object({ type: Type.String() });
}
}
class What2 extends What {}
class NotAllowed {}
@@ -27,30 +30,58 @@ describe("Registry", () => {
first: {
cls: What,
schema: Type.Object({ type: Type.String(), what: Type.String() }),
enabled: true
}
enabled: true,
},
} satisfies Record<string, Test1>);
const item = registry.get("first");
expect(item).toBeDefined();
expect(item?.cls).toBe(What);
const second = Type.Object({ type: Type.String(), what: Type.String() });
registry.add("second", {
cls: What2,
schema: Type.Object({ type: Type.String(), what: Type.String() }),
enabled: true
schema: second,
enabled: true,
});
// @ts-ignore
expect(registry.get("second").schema).toEqual(second);
const third = Type.Object({ type: Type.String({ default: "1" }), what22: Type.String() });
registry.add("third", {
// @ts-expect-error
cls: NotAllowed,
schema: Type.Object({ type: Type.String({ default: "1" }), what22: Type.String() }),
enabled: true
schema: third,
enabled: true,
});
// @ts-ignore
expect(registry.get("third").schema).toEqual(third);
const fourth = Type.Object({ type: Type.Number(), what22: Type.String() });
registry.add("fourth", {
cls: What,
// @ts-expect-error
schema: Type.Object({ type: Type.Number(), what22: Type.String() }),
enabled: true
schema: fourth,
enabled: true,
});
// @ts-ignore
expect(registry.get("fourth").schema).toEqual(fourth);
expect(Object.keys(registry.all()).length).toBe(4);
});
test("uses registration fn", async () => {
const registry = new Registry<Test1>((a: ClassRef<What>) => {
return {
cls: a,
schema: a.prototype.getType(),
enabled: true,
};
});
console.log("list", registry.all());
registry.register("what2", What2);
expect(registry.get("what2")).toBeDefined();
expect(registry.get("what2").cls).toBe(What2);
expect(registry.get("what2").schema).toEqual(What2.prototype.getType());
});
});
+5 -5
View File
@@ -4,7 +4,7 @@ import { after, beforeEach, describe, test } from "node:test";
import { Miniflare } from "miniflare";
import {
CloudflareKVCacheItem,
CloudflareKVCachePool
CloudflareKVCachePool,
} from "../../../src/core/cache/adapters/CloudflareKvCache";
import { runTests } from "./cache-test-suite";
@@ -26,7 +26,7 @@ describe("CloudflareKv", async () => {
mf = new Miniflare({
modules: true,
script: "export default { async fetch() { return new Response(null); } }",
kvNamespaces: ["TEST"]
kvNamespaces: ["TEST"],
});
const kv = await mf.getKVNamespace("TEST");
return new CloudflareKVCachePool(kv as any);
@@ -45,10 +45,10 @@ describe("CloudflareKv", async () => {
},
toBeUndefined() {
assert.equal(actual, undefined);
}
},
};
}
}
},
},
});
after(async () => {
+2 -2
View File
@@ -9,7 +9,7 @@ describe("MemoryCache", () => {
tester: {
test,
beforeEach,
expect
}
expect,
},
});
});
+6 -4
View File
@@ -1,14 +1,16 @@
import { describe, test } from "bun:test";
import { describe, expect, test } from "bun:test";
import { checksum, hash } from "../../src/core/utils";
describe("crypto", async () => {
test("sha256", async () => {
console.log(await hash.sha256("test"));
expect(await hash.sha256("test")).toBe(
"9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08",
);
});
test("sha1", async () => {
console.log(await hash.sha1("test"));
expect(await hash.sha1("test")).toBe("a94a8fe5ccb19ba61c4c0873d391e987982fbbd3");
});
test("checksum", async () => {
console.log(checksum("hello world"));
expect(await checksum("hello world")).toBe("2aae6c35c94fcfb415dbe95f408b9ce91ee846ed");
});
});
+36
View File
@@ -0,0 +1,36 @@
import { describe, expect, test } from "bun:test";
import { env, is_toggled } from "core/env";
describe("env", () => {
test("is_toggled", () => {
expect(is_toggled("true")).toBe(true);
expect(is_toggled("1")).toBe(true);
expect(is_toggled("false")).toBe(false);
expect(is_toggled("0")).toBe(false);
expect(is_toggled(true)).toBe(true);
expect(is_toggled(false)).toBe(false);
expect(is_toggled(undefined)).toBe(false);
expect(is_toggled(null)).toBe(false);
expect(is_toggled(1)).toBe(true);
expect(is_toggled(0)).toBe(false);
expect(is_toggled("anything else")).toBe(false);
expect(is_toggled(undefined, true)).toBe(true);
});
test("env()", () => {
expect(env("cli_log_level", undefined, { source: {} })).toBeUndefined();
expect(env("cli_log_level", undefined, { source: { BKND_CLI_LOG_LEVEL: "log" } })).toBe(
"log" as any,
);
expect(env("cli_log_level", undefined, { source: { BKND_CLI_LOG_LEVEL: "LOG" } })).toBe(
"log" as any,
);
expect(
env("cli_log_level", undefined, { source: { BKND_CLI_LOG_LEVEL: "asdf" } }),
).toBeUndefined();
expect(env("modules_debug", undefined, { source: {} })).toBeFalse();
expect(env("modules_debug", undefined, { source: { BKND_MODULES_DEBUG: "1" } })).toBeTrue();
expect(env("modules_debug", undefined, { source: { BKND_MODULES_DEBUG: "0" } })).toBeFalse();
});
});
+83 -83
View File
@@ -8,8 +8,8 @@ describe("SchemaObject", async () => {
Type.Object({ a: Type.String({ default: "b" }) }),
{ a: "test" },
{
forceParse: true
}
forceParse: true,
},
);
expect(m.get()).toEqual({ a: "test" });
@@ -30,14 +30,14 @@ describe("SchemaObject", async () => {
b: Type.Object(
{
c: Type.String({ default: "d" }),
e: Type.String({ default: "f" })
e: Type.String({ default: "f" }),
},
{ default: {} }
)
{ default: {} },
),
},
{ default: {}, additionalProperties: false }
)
})
{ default: {}, additionalProperties: false },
),
}),
);
expect(m.get()).toEqual({ s: { a: "b", b: { c: "d", e: "f" } } });
@@ -59,8 +59,8 @@ describe("SchemaObject", async () => {
test("patch array", async () => {
const m = new SchemaObject(
Type.Object({
methods: Type.Array(Type.String(), { default: ["GET", "PATCH"] })
})
methods: Type.Array(Type.String(), { default: ["GET", "PATCH"] }),
}),
);
expect(m.get()).toEqual({ methods: ["GET", "PATCH"] });
@@ -81,14 +81,14 @@ describe("SchemaObject", async () => {
a: Type.String({ default: "b" }),
b: Type.Object(
{
c: Type.String({ default: "d" })
c: Type.String({ default: "d" }),
},
{ default: {} }
)
{ default: {} },
),
},
{ default: {} }
)
})
{ default: {} },
),
}),
);
expect(m.get()).toEqual({ s: { a: "b", b: { c: "d" } } });
@@ -108,8 +108,8 @@ describe("SchemaObject", async () => {
test("set", async () => {
const m = new SchemaObject(
Type.Object({
methods: Type.Array(Type.String(), { default: ["GET", "PATCH"] })
})
methods: Type.Array(Type.String(), { default: ["GET", "PATCH"] }),
}),
);
expect(m.get()).toEqual({ methods: ["GET", "PATCH"] });
@@ -125,7 +125,7 @@ describe("SchemaObject", async () => {
let result: any;
const m = new SchemaObject(
Type.Object({
methods: Type.Array(Type.String(), { default: ["GET", "PATCH"] })
methods: Type.Array(Type.String(), { default: ["GET", "PATCH"] }),
}),
undefined,
{
@@ -133,8 +133,8 @@ describe("SchemaObject", async () => {
await new Promise((r) => setTimeout(r, 10));
called = true;
result = config;
}
}
},
},
);
await m.set({ methods: ["GET", "POST"] });
@@ -146,7 +146,7 @@ describe("SchemaObject", async () => {
let called = false;
const m = new SchemaObject(
Type.Object({
methods: Type.Array(Type.String(), { default: ["GET", "PATCH"] })
methods: Type.Array(Type.String(), { default: ["GET", "PATCH"] }),
}),
undefined,
{
@@ -155,8 +155,8 @@ describe("SchemaObject", async () => {
called = true;
to.methods.push("OPTIONS");
return to;
}
}
},
},
);
const result = await m.set({ methods: ["GET", "POST"] });
@@ -168,7 +168,7 @@ describe("SchemaObject", async () => {
test("throwIfRestricted", async () => {
const m = new SchemaObject(Type.Object({}), undefined, {
restrictPaths: ["a.b"]
restrictPaths: ["a.b"],
});
expect(() => m.throwIfRestricted("a.b")).toThrow();
@@ -185,18 +185,18 @@ describe("SchemaObject", async () => {
a: Type.String({ default: "b" }),
b: Type.Object(
{
c: Type.String({ default: "d" })
c: Type.String({ default: "d" }),
},
{ default: {} }
)
{ default: {} },
),
},
{ default: {} }
)
{ default: {} },
),
}),
undefined,
{
restrictPaths: ["s.b"]
}
restrictPaths: ["s.b"],
},
);
expect(m.patch("s.b.c", "e")).rejects.toThrow();
@@ -217,33 +217,33 @@ describe("SchemaObject", async () => {
additionalProperties: Type.Object({
type: Type.String(),
config: Type.Optional(
Type.Object({}, { additionalProperties: Type.String() })
)
})
}
Type.Object({}, { additionalProperties: Type.String() }),
),
}),
},
),
config: Type.Optional(Type.Object({}, { additionalProperties: Type.String() }))
})
}
)
config: Type.Optional(Type.Object({}, { additionalProperties: Type.String() })),
}),
},
),
},
{
additionalProperties: false
}
additionalProperties: false,
},
);
test("patch safe object, overwrite", async () => {
const data = {
entities: {
some: {
fields: {
a: { type: "string", config: { some: "thing" } }
}
}
}
a: { type: "string", config: { some: "thing" } },
},
},
},
};
const m = new SchemaObject(dataEntitiesSchema, data, {
forceParse: true,
overwritePaths: [/^entities\..*\.fields\..*\.config/]
overwritePaths: [/^entities\..*\.fields\..*\.config/],
});
await m.patch("entities.some.fields.a", { type: "string", config: { another: "one" } });
@@ -252,10 +252,10 @@ describe("SchemaObject", async () => {
entities: {
some: {
fields: {
a: { type: "string", config: { another: "one" } }
}
}
}
a: { type: "string", config: { another: "one" } },
},
},
},
});
});
@@ -265,22 +265,22 @@ describe("SchemaObject", async () => {
users: {
fields: {
email: { type: "string" },
password: { type: "string" }
}
}
}
password: { type: "string" },
},
},
},
};
const m = new SchemaObject(dataEntitiesSchema, data, {
forceParse: true,
overwritePaths: [/^entities\..*\.fields\..*\.config\.html_config$/]
overwritePaths: [/^entities\..*\.fields\..*\.config\.html_config$/],
});
await m.patch("entities.test", {
fields: {
content: {
type: "text"
}
}
type: "text",
},
},
});
expect(m.get()).toEqual({
@@ -288,17 +288,17 @@ describe("SchemaObject", async () => {
users: {
fields: {
email: { type: "string" },
password: { type: "string" }
}
password: { type: "string" },
},
},
test: {
fields: {
content: {
type: "text"
}
}
}
}
type: "text",
},
},
},
},
});
});
@@ -308,14 +308,14 @@ describe("SchemaObject", async () => {
users: {
fields: {
email: { type: "string" },
password: { type: "string" }
}
}
}
password: { type: "string" },
},
},
},
};
const m = new SchemaObject(dataEntitiesSchema, data, {
forceParse: true,
overwritePaths: [/^entities\..*\.fields\..*\.config\.html_config$/]
overwritePaths: [/^entities\..*\.fields\..*\.config\.html_config$/],
});
expect(m.patch("desc", "entities.users.config.sort_dir")).rejects.toThrow();
@@ -323,13 +323,13 @@ describe("SchemaObject", async () => {
await m.patch("entities.test", {
fields: {
content: {
type: "text"
}
}
type: "text",
},
},
});
await m.patch("entities.users.config", {
sort_dir: "desc"
sort_dir: "desc",
});
expect(m.get()).toEqual({
@@ -337,20 +337,20 @@ describe("SchemaObject", async () => {
users: {
fields: {
email: { type: "string" },
password: { type: "string" }
password: { type: "string" },
},
config: {
sort_dir: "desc"
}
sort_dir: "desc",
},
},
test: {
fields: {
content: {
type: "text"
}
}
}
}
type: "text",
},
},
},
},
});
});
});
+443
View File
@@ -0,0 +1,443 @@
import { describe, expect, it, test } from "bun:test";
import { apply, diff, revert } from "../../../src/core/object/diff";
describe("diff", () => {
it("should detect added properties", () => {
const oldObj = { a: 1 };
const newObj = { a: 1, b: 2 };
const diffs = diff(oldObj, newObj);
expect(diffs).toEqual([
{
t: "a",
p: ["b"],
o: undefined,
n: 2,
},
]);
const appliedObj = apply(oldObj, diffs);
expect(appliedObj).toEqual(newObj);
const revertedObj = revert(newObj, diffs);
expect(revertedObj).toEqual(oldObj);
});
it("should detect removed properties", () => {
const oldObj = { a: 1, b: 2 };
const newObj = { a: 1 };
const diffs = diff(oldObj, newObj);
expect(diffs).toEqual([
{
t: "r",
p: ["b"],
o: 2,
n: undefined,
},
]);
const appliedObj = apply(oldObj, diffs);
expect(appliedObj).toEqual(newObj);
const revertedObj = revert(newObj, diffs);
expect(revertedObj).toEqual(oldObj);
});
it("should detect edited properties", () => {
const oldObj = { a: 1 };
const newObj = { a: 2 };
const diffs = diff(oldObj, newObj);
expect(diffs).toEqual([
{
t: "e",
p: ["a"],
o: 1,
n: 2,
},
]);
const appliedObj = apply(oldObj, diffs);
expect(appliedObj).toEqual(newObj);
const revertedObj = revert(newObj, diffs);
expect(revertedObj).toEqual(oldObj);
});
it("should detect changes in nested objects", () => {
const oldObj = { a: { b: 1 } };
const newObj = { a: { b: 2 } };
const diffs = diff(oldObj, newObj);
expect(diffs).toEqual([
{
t: "e",
p: ["a", "b"],
o: 1,
n: 2,
},
]);
const appliedObj = apply(oldObj, diffs);
expect(appliedObj).toEqual(newObj);
const revertedObj = revert(newObj, diffs);
expect(revertedObj).toEqual(oldObj);
});
it("should detect changes in arrays", () => {
const oldObj = { a: [1, 2, 3] };
const newObj = { a: [1, 4, 3, 5] };
const diffs = diff(oldObj, newObj);
expect(diffs).toEqual([
{
t: "e",
p: ["a", 1],
o: 2,
n: 4,
},
{
t: "a",
p: ["a", 3],
o: undefined,
n: 5,
},
]);
const appliedObj = apply(oldObj, diffs);
expect(appliedObj).toEqual(newObj);
const revertedObj = revert(newObj, diffs);
expect(revertedObj).toEqual(oldObj);
});
it("should handle adding elements to an empty array", () => {
const oldObj = { a: [] };
const newObj = { a: [1, 2, 3] };
const diffs = diff(oldObj, newObj);
expect(diffs).toEqual([
{
t: "a",
p: ["a", 0],
o: undefined,
n: 1,
},
{
t: "a",
p: ["a", 1],
o: undefined,
n: 2,
},
{
t: "a",
p: ["a", 2],
o: undefined,
n: 3,
},
]);
const appliedObj = apply(oldObj, diffs);
expect(appliedObj).toEqual(newObj);
const revertedObj = revert(newObj, diffs);
expect(revertedObj).toEqual(oldObj);
});
it("should handle removing elements from an array", () => {
const oldObj = { a: [1, 2, 3] };
const newObj = { a: [1, 3] };
const diffs = diff(oldObj, newObj);
expect(diffs).toEqual([
{
t: "e",
p: ["a", 1],
o: 2,
n: 3,
},
{
t: "r",
p: ["a", 2],
o: 3,
n: undefined,
},
]);
const appliedObj = apply(oldObj, diffs);
expect(appliedObj).toEqual(newObj);
const revertedObj = revert(newObj, diffs);
expect(revertedObj).toEqual(oldObj);
});
it("should handle complex nested changes", () => {
const oldObj = {
a: {
b: [1, 2, { c: 3 }],
},
};
const newObj = {
a: {
b: [1, 2, { c: 4 }, 5],
},
};
const diffs = diff(oldObj, newObj);
expect(diffs).toEqual([
{
t: "e",
p: ["a", "b", 2, "c"],
o: 3,
n: 4,
},
{
t: "a",
p: ["a", "b", 3],
o: undefined,
n: 5,
},
]);
const appliedObj = apply(oldObj, diffs);
expect(appliedObj).toEqual(newObj);
const revertedObj = revert(newObj, diffs);
expect(revertedObj).toEqual(oldObj);
});
it("should handle undefined and null values", () => {
const oldObj = { a: undefined, b: null };
const newObj = { a: null, b: undefined };
const diffs = diff(oldObj, newObj);
expect(diffs).toEqual([
{
t: "e",
p: ["a"],
o: undefined,
n: null,
},
{
t: "e",
p: ["b"],
o: null,
n: undefined,
},
]);
const appliedObj = apply(oldObj, diffs);
expect(appliedObj).toEqual(newObj);
const revertedObj = revert(newObj, diffs);
expect(revertedObj).toEqual(oldObj);
});
it("should handle type changes", () => {
const oldObj = { a: 1 };
const newObj = { a: "1" };
const diffs = diff(oldObj, newObj);
expect(diffs).toEqual([
{
t: "e",
p: ["a"],
o: 1,
n: "1",
},
]);
const appliedObj = apply(oldObj, diffs);
expect(appliedObj).toEqual(newObj);
const revertedObj = revert(newObj, diffs);
expect(revertedObj).toEqual(oldObj);
});
it("should handle properties added and removed simultaneously", () => {
const oldObj = { a: 1, b: 2 };
const newObj = { a: 1, c: 3 };
const diffs = diff(oldObj, newObj);
expect(diffs).toEqual([
{
t: "r",
p: ["b"],
o: 2,
n: undefined,
},
{
t: "a",
p: ["c"],
o: undefined,
n: 3,
},
]);
const appliedObj = apply(oldObj, diffs);
expect(appliedObj).toEqual(newObj);
const revertedObj = revert(newObj, diffs);
expect(revertedObj).toEqual(oldObj);
});
it("should handle arrays replaced with objects", () => {
const oldObj = { a: [1, 2, 3] };
const newObj = { a: { b: 4 } };
const diffs = diff(oldObj, newObj);
expect(diffs).toEqual([
{
t: "e",
p: ["a"],
o: [1, 2, 3],
n: { b: 4 },
},
]);
const appliedObj = apply(oldObj, diffs);
expect(appliedObj).toEqual(newObj);
const revertedObj = revert(newObj, diffs);
expect(revertedObj).toEqual(oldObj);
});
it("should handle objects replaced with primitives", () => {
const oldObj = { a: { b: 1 } };
const newObj = { a: 2 };
const diffs = diff(oldObj, newObj);
expect(diffs).toEqual([
{
t: "e",
p: ["a"],
o: { b: 1 },
n: 2,
},
]);
const appliedObj = apply(oldObj, diffs);
expect(appliedObj).toEqual(newObj);
const revertedObj = revert(newObj, diffs);
expect(revertedObj).toEqual(oldObj);
});
it("should handle root object changes", () => {
const oldObj = { a: 1 };
const newObj = { b: 2 };
const diffs = diff(oldObj, newObj);
expect(diffs).toEqual([
{
t: "r",
p: ["a"],
o: 1,
n: undefined,
},
{
t: "a",
p: ["b"],
o: undefined,
n: 2,
},
]);
const appliedObj = apply(oldObj, diffs);
expect(appliedObj).toEqual(newObj);
const revertedObj = revert(newObj, diffs);
expect(revertedObj).toEqual(oldObj);
});
it("should handle identical objects", () => {
const oldObj = { a: 1, b: { c: 2 } };
const newObj = { a: 1, b: { c: 2 } };
const diffs = diff(oldObj, newObj);
expect(diffs).toEqual([]);
const appliedObj = apply(oldObj, diffs);
expect(appliedObj).toEqual(newObj);
const revertedObj = revert(newObj, diffs);
expect(revertedObj).toEqual(oldObj);
});
it("should handle empty objects", () => {
const oldObj = {};
const newObj = {};
const diffs = diff(oldObj, newObj);
expect(diffs).toEqual([]);
const appliedObj = apply(oldObj, diffs);
expect(appliedObj).toEqual(newObj);
const revertedObj = revert(newObj, diffs);
expect(revertedObj).toEqual(oldObj);
});
it("should handle changes from empty object to non-empty object", () => {
const oldObj = {};
const newObj = { a: 1 };
const diffs = diff(oldObj, newObj);
expect(diffs).toEqual([
{
t: "a",
p: ["a"],
o: undefined,
n: 1,
},
]);
const appliedObj = apply(oldObj, diffs);
expect(appliedObj).toEqual(newObj);
const revertedObj = revert(newObj, diffs);
expect(revertedObj).toEqual(oldObj);
});
it("should handle changes from non-empty object to empty object", () => {
const oldObj = { a: 1 };
const newObj = {};
const diffs = diff(oldObj, newObj);
expect(diffs).toEqual([
{
t: "r",
p: ["a"],
o: 1,
n: undefined,
},
]);
const appliedObj = apply(oldObj, diffs);
expect(appliedObj).toEqual(newObj);
const revertedObj = revert(newObj, diffs);
expect(revertedObj).toEqual(oldObj);
});
});
+4 -18
View File
@@ -1,6 +1,5 @@
import { describe, expect, test } from "bun:test";
import { type ObjectQuery, convert, validate } from "../../../src/core/object/query/object-query";
import { deprecated__whereRepoSchema } from "../../../src/data";
describe("object-query", () => {
const q: ObjectQuery = { name: "Michael" };
@@ -8,22 +7,9 @@ describe("object-query", () => {
const q3: ObjectQuery = { name: "Michael", age: { $gt: 18 } };
const bag = { q, q2, q3 };
test("translates into legacy", async () => {
for (const [key, value] of Object.entries(bag)) {
const obj = convert(value);
try {
const parsed = deprecated__whereRepoSchema.parse(obj);
expect(parsed).toBeDefined();
} catch (e) {
console.log("errored", { obj, value });
console.error(key, e);
}
}
});
test("validates", async () => {
const converted = convert({
name: { $eq: "ch" }
name: { $eq: "ch" },
});
validate(converted, { name: "Michael" });
});
@@ -45,7 +31,7 @@ describe("object-query", () => {
[{ val: { $notnull: 1 } }, { val: null }, false],
[{ val: { $regex: ".*" } }, { val: "test" }, true],
[{ val: { $regex: /^t.*/ } }, { val: "test" }, true],
[{ val: { $regex: /^b.*/ } }, { val: "test" }, false]
[{ val: { $regex: /^b.*/ } }, { val: "test" }, false],
];
for (const [query, object, expected] of tests) {
@@ -69,10 +55,10 @@ describe("object-query", () => {
[
{ $or: { val1: { $eq: "foo" }, val2: { $eq: "bar" } } },
{ val1: "foo", val2: "bar" },
true
true,
],
[{ val1: { $eq: 1 }, $or: { val1: { $eq: 2 } } }, { val1: 1 }, true],
[{ val1: { $eq: 1 }, $or: { val1: { $eq: 2 } } }, { val1: 3 }, false]
[{ val1: { $eq: 1 }, $or: { val1: { $eq: 2 } } }, { val1: 3 }, false],
];
for (const [query, object, expected] of tests) {
+162 -15
View File
@@ -1,7 +1,6 @@
import { describe, expect, test } from "bun:test";
import { Perf } from "../../src/core/utils";
import * as reqres from "../../src/core/utils/reqres";
import * as strings from "../../src/core/utils/strings";
import { Perf, datetimeStringUTC, isBlob, ucFirst } from "../../src/core/utils";
import * as utils from "../../src/core/utils";
async function wait(ms: number) {
return new Promise((resolve) => {
@@ -13,33 +12,33 @@ describe("Core Utils", async () => {
describe("[core] strings", async () => {
test("objectToKeyValueArray", async () => {
const obj = { a: 1, b: 2, c: 3 };
const result = strings.objectToKeyValueArray(obj);
const result = utils.objectToKeyValueArray(obj);
expect(result).toEqual([
{ key: "a", value: 1 },
{ key: "b", value: 2 },
{ key: "c", value: 3 }
{ key: "c", value: 3 },
]);
});
test("snakeToPascalWithSpaces", async () => {
const result = strings.snakeToPascalWithSpaces("snake_to_pascal");
const result = utils.snakeToPascalWithSpaces("snake_to_pascal");
expect(result).toBe("Snake To Pascal");
});
test("randomString", async () => {
const result = strings.randomString(10);
const result = utils.randomString(10);
expect(result).toHaveLength(10);
});
test("pascalToKebab", async () => {
const result = strings.pascalToKebab("PascalCase");
const result = utils.pascalToKebab("PascalCase");
expect(result).toBe("pascal-case");
});
test("replaceSimplePlaceholders", async () => {
const str = "Hello, {$name}!";
const vars = { name: "John" };
const result = strings.replaceSimplePlaceholders(str, vars);
const result = utils.replaceSimplePlaceholders(str, vars);
expect(result).toBe("Hello, John!");
});
});
@@ -49,33 +48,84 @@ describe("Core Utils", async () => {
const headers = new Headers();
headers.append("Content-Type", "application/json");
headers.append("Authorization", "Bearer 123");
const obj = reqres.headersToObject(headers);
const obj = utils.headersToObject(headers);
expect(obj).toEqual({
"content-type": "application/json",
authorization: "Bearer 123"
authorization: "Bearer 123",
});
});
test("replaceUrlParam", () => {
const url = "/api/:id/:name";
const params = { id: "123", name: "test" };
const result = reqres.replaceUrlParam(url, params);
const result = utils.replaceUrlParam(url, params);
expect(result).toBe("/api/123/test");
});
test("encode", () => {
const obj = { id: "123", name: "test" };
const result = reqres.encodeSearch(obj);
const result = utils.encodeSearch(obj);
expect(result).toBe("id=123&name=test");
const obj2 = { id: "123", name: ["test1", "test2"] };
const result2 = reqres.encodeSearch(obj2);
const result2 = utils.encodeSearch(obj2);
expect(result2).toBe("id=123&name=test1&name=test2");
const obj3 = { id: "123", name: { test: "test" } };
const result3 = reqres.encodeSearch(obj3, { encode: true });
const result3 = utils.encodeSearch(obj3, { encode: true });
expect(result3).toBe("id=123&name=%7B%22test%22%3A%22test%22%7D");
});
describe("guards", () => {
const types = {
blob: new Blob(),
file: new File([""], "file.txt"),
stream: new ReadableStream(),
arrayBuffer: new ArrayBuffer(10),
arrayBufferView: new Uint8Array(new ArrayBuffer(10)),
};
const fns = [
[utils.isReadableStream, "stream"],
[utils.isBlob, "blob", ["stream", "arrayBuffer", "arrayBufferView"]],
[utils.isFile, "file", ["stream", "arrayBuffer", "arrayBufferView"]],
[utils.isArrayBuffer, "arrayBuffer"],
[utils.isArrayBufferView, "arrayBufferView"],
] as const;
const additional = [0, 0.0, "", null, undefined, {}, []];
for (const [fn, type, _to_test] of fns) {
test(`is${ucFirst(type)}`, () => {
const to_test = _to_test ?? (Object.keys(types) as string[]);
for (const key of to_test) {
const value = types[key as keyof typeof types];
const result = fn(value);
expect(result).toBe(key === type);
}
for (const value of additional) {
const result = fn(value);
expect(result).toBe(false);
}
});
}
});
test("getContentName", () => {
const name = "test.json";
const text = "attachment; filename=" + name;
const headers = new Headers({
"Content-Disposition": text,
});
const request = new Request("http://example.com", {
headers,
});
expect(utils.getContentName(text)).toBe(name);
expect(utils.getContentName(headers)).toBe(name);
expect(utils.getContentName(request)).toBe(name);
});
});
describe("perf", async () => {
@@ -108,4 +158,101 @@ describe("Core Utils", async () => {
expect(count).toBe(2);
});
});
describe("objects", () => {
test("omitKeys", () => {
const objects = [
[{ a: 1, b: 2, c: 3 }, ["a"], { b: 2, c: 3 }],
[{ a: 1, b: 2, c: 3 }, ["b"], { a: 1, c: 3 }],
[{ a: 1, b: 2, c: 3 }, ["c"], { a: 1, b: 2 }],
[{ a: 1, b: 2, c: 3 }, ["a", "b"], { c: 3 }],
[{ a: 1, b: 2, c: 3 }, ["a", "b", "c"], {}],
] as [object, string[], object][];
for (const [obj, keys, expected] of objects) {
const result = utils.omitKeys(obj, keys as any);
expect(result).toEqual(expected);
}
});
test("isEqual", () => {
const objects = [
[1, 1, true],
[1, "1", false],
[1, 2, false],
["1", "1", true],
["1", "2", false],
[true, true, true],
[true, false, false],
[false, false, true],
[1, Number.NaN, false],
[Number.NaN, Number.NaN, true],
[null, null, true],
[null, undefined, false],
[undefined, undefined, true],
[new Map([["a", 1]]), new Map([["a", 1]]), true],
[new Map([["a", 1]]), new Map([["a", 2]]), false],
[new Map([["a", 1]]), new Map([["b", 1]]), false],
[
new Map([["a", 1]]),
new Map([
["a", 1],
["b", 2],
]),
false,
],
[{ a: 1 }, { a: 1 }, true],
[{ a: 1 }, { a: 2 }, false],
[{ a: 1 }, { b: 1 }, false],
[{ a: "1" }, { a: "1" }, true],
[{ a: "1" }, { a: "2" }, false],
[{ a: "1" }, { b: "1" }, false],
[{ a: 1 }, { a: 1, b: 2 }, false],
[{ a: [1, 2, 3] }, { a: [1, 2, 3] }, true],
[{ a: [1, 2, 3] }, { a: [1, 2, 4] }, false],
[{ a: [1, 2, 3] }, { a: [1, 2, 3, 4] }, false],
[{ a: { b: 1 } }, { a: { b: 1 } }, true],
[{ a: { b: 1 } }, { a: { b: 2 } }, false],
[{ a: { b: 1 } }, { a: { c: 1 } }, false],
[{ a: { b: 1 } }, { a: { b: 1, c: 2 } }, false],
[[1, 2, 3], [1, 2, 3], true],
[[1, 2, 3], [1, 2, 4], false],
[[1, 2, 3], [1, 2, 3, 4], false],
[[{ a: 1 }], [{ a: 1 }], true],
[[{ a: 1 }], [{ a: 2 }], false],
[[{ a: 1 }], [{ b: 1 }], false],
] as [any, any, boolean][];
for (const [a, b, expected] of objects) {
const result = utils.isEqual(a, b);
expect(result).toEqual(expected);
}
});
test("getPath", () => {
const tests = [
[{ a: 1, b: 2, c: 3 }, "a", 1],
[{ a: 1, b: 2, c: 3 }, "b", 2],
[{ a: { b: 1 } }, "a.b", 1],
[{ a: { b: 1 } }, "a.b.c", null, null],
[{ a: { b: 1 } }, "a.b.c", 1, 1],
[[[1]], "0.0", 1],
] as [object, string, any, any][];
for (const [obj, path, expected, defaultValue] of tests) {
const result = utils.getPath(obj, path, defaultValue);
expect(result).toEqual(expected);
}
});
});
describe("dates", () => {
test.only("formats local time", () => {
expect(utils.datetimeStringUTC("2025-02-21T16:48:25.841Z")).toBe("2025-02-21 16:48:25");
console.log(utils.datetimeStringUTC(new Date()));
console.log(utils.datetimeStringUTC());
console.log(new Date());
console.log("timezone", Intl.DateTimeFormat().resolvedOptions().timeZone);
});
});
});
+29 -29
View File
@@ -9,7 +9,7 @@ import {
ManyToOneRelation,
type MutatorResponse,
type RepositoryResponse,
TextField
TextField,
} from "../../src/data";
import { DataController } from "../../src/data/api/DataController";
import { dataConfigSchema } from "../../src/data/data-schema";
@@ -35,17 +35,17 @@ describe("[data] DataController", async () => {
meta: {
total: 0,
count: 0,
items: 0
}
items: 0,
},
});
expect(res).toEqual({
meta: {
total: 0,
count: 0,
items: 0
items: 0,
},
data: []
data: [],
});
});
@@ -59,22 +59,22 @@ describe("[data] DataController", async () => {
data: [] as any,
sql: "",
parameters: [] as any,
result: [] as any
result: [] as any,
});
expect(res).toEqual({
data: []
data: [],
});
});
describe("getController", async () => {
const users = new Entity("users", [
new TextField("name", { required: true }),
new TextField("bio")
new TextField("bio"),
]);
const posts = new Entity("posts", [new TextField("content")]);
const em = new EntityManager([users, posts], dummyConnection, [
new ManyToOneRelation(posts, users)
new ManyToOneRelation(posts, users),
]);
await em.schema().sync({ force: true });
@@ -83,12 +83,12 @@ describe("[data] DataController", async () => {
users: [
{ name: "foo", bio: "bar" },
{ name: "bar", bio: null },
{ name: "baz", bio: "!!!" }
{ name: "baz", bio: "!!!" },
],
posts: [
{ content: "post 1", users_id: 1 },
{ content: "post 2", users_id: 2 }
]
{ content: "post 2", users_id: 2 },
],
};
const ctx: any = { em, guard: new Guard() };
@@ -116,9 +116,9 @@ describe("[data] DataController", async () => {
//console.log("app.routes", app.routes);
// create users
for await (const _user of fixtures.users) {
const res = await app.request("/users", {
const res = await app.request("/entity/users", {
method: "POST",
body: JSON.stringify(_user)
body: JSON.stringify(_user),
});
//console.log("res", { _user }, res);
const result = (await res.json()) as MutatorResponse;
@@ -131,9 +131,9 @@ describe("[data] DataController", async () => {
// create posts
for await (const _post of fixtures.posts) {
const res = await app.request("/posts", {
const res = await app.request("/entity/posts", {
method: "POST",
body: JSON.stringify(_post)
body: JSON.stringify(_post),
});
const result = (await res.json()) as MutatorResponse;
const { id, ...data } = result.data as any;
@@ -145,7 +145,7 @@ describe("[data] DataController", async () => {
});
test("/:entity (read many)", async () => {
const res = await app.request("/users");
const res = await app.request("/entity/users");
const data = (await res.json()) as RepositoryResponse;
expect(data.meta.total).toBe(3);
@@ -156,14 +156,14 @@ describe("[data] DataController", async () => {
});
test("/:entity/query (func query)", async () => {
const res = await app.request("/users/query", {
const res = await app.request("/entity/users/query", {
method: "POST",
headers: {
"Content-Type": "application/json"
"Content-Type": "application/json",
},
body: JSON.stringify({
where: { bio: { $isnull: 1 } }
})
where: { bio: { $isnull: 1 } },
}),
});
const data = (await res.json()) as RepositoryResponse;
@@ -175,7 +175,7 @@ describe("[data] DataController", async () => {
});
test("/:entity (read many, paginated)", async () => {
const res = await app.request("/users?limit=1&offset=2");
const res = await app.request("/entity/users?limit=1&offset=2");
const data = (await res.json()) as RepositoryResponse;
expect(data.meta.total).toBe(3);
@@ -186,7 +186,7 @@ describe("[data] DataController", async () => {
});
test("/:entity/:id (read one)", async () => {
const res = await app.request("/users/3");
const res = await app.request("/entity/users/3");
const data = (await res.json()) as RepositoryResponse<EntityData>;
console.log("data", data);
@@ -197,9 +197,9 @@ describe("[data] DataController", async () => {
});
test("/:entity (update one)", async () => {
const res = await app.request("/users/3", {
const res = await app.request("/entity/users/3", {
method: "PATCH",
body: JSON.stringify({ name: "new name" })
body: JSON.stringify({ name: "new name" }),
});
const { data } = (await res.json()) as MutatorResponse;
@@ -208,7 +208,7 @@ describe("[data] DataController", async () => {
});
test("/:entity/:id/:reference (read references)", async () => {
const res = await app.request("/users/1/posts");
const res = await app.request("/entity/users/1/posts");
const data = (await res.json()) as RepositoryResponse;
console.log("data", data);
@@ -220,14 +220,14 @@ describe("[data] DataController", async () => {
});
test("/:entity/:id (delete one)", async () => {
const res = await app.request("/posts/2", {
method: "DELETE"
const res = await app.request("/entity/posts/2", {
method: "DELETE",
});
const { data } = (await res.json()) as RepositoryResponse<EntityData>;
expect(data).toEqual({ id: 2, ...fixtures.posts[1] });
// verify
const res2 = await app.request("/posts");
const res2 = await app.request("/entity/posts");
const data2 = (await res2.json()) as RepositoryResponse;
expect(data2.meta.total).toBe(1);
});
+76 -11
View File
@@ -1,16 +1,21 @@
import { describe, expect, test } from "bun:test";
import type { QueryObject } from "ufo";
import { WhereBuilder, type WhereQuery } from "../../src/data/entities/query/WhereBuilder";
import { Value, _jsonp } from "../../src/core/utils";
import { type RepoQuery, WhereBuilder, type WhereQuery, querySchema } from "../../src/data";
import type { RepoQueryIn } from "../../src/data/server/data-query-impl";
import { getDummyConnection } from "./helper";
const t = "t";
const decode = (input: RepoQueryIn, expected: RepoQuery) => {
const result = Value.Decode(querySchema, input);
expect(result).toEqual(expected);
};
describe("data-query-impl", () => {
function qb() {
const c = getDummyConnection();
const kysely = c.dummyConnection.kysely;
return kysely.selectFrom(t).selectAll();
return kysely.selectFrom("t").selectAll();
}
function compile(q: QueryObject) {
function compile(q: WhereQuery) {
const { sql, parameters } = WhereBuilder.addClause(qb(), q).compile();
return { sql, parameters };
}
@@ -25,7 +30,7 @@ describe("data-query-impl", () => {
[{ val: { $isnull: 0 } }, '"val" is not null', []],
[{ val: { $isnull: false } }, '"val" is not null', []],
[{ val: { $like: "what" } }, '"val" like ?', ["what"]],
[{ val: { $like: "w*t" } }, '"val" like ?', ["w%t"]]
[{ val: { $like: "w*t" } }, '"val" like ?', ["w%t"]],
];
for (const [query, expectedSql, expectedParams] of tests) {
@@ -46,22 +51,22 @@ describe("data-query-impl", () => {
[
{ val1: { $eq: "foo" }, val2: { $eq: "bar" } },
'("val1" = ? and "val2" = ?)',
["foo", "bar"]
["foo", "bar"],
],
[
{ val1: { $eq: "foo" }, val2: { $eq: "bar" } },
'("val1" = ? and "val2" = ?)',
["foo", "bar"]
["foo", "bar"],
],
// or constructs
[
{ $or: { val1: { $eq: "foo" }, val2: { $eq: "bar" } } },
'("val1" = ? or "val2" = ?)',
["foo", "bar"]
["foo", "bar"],
],
[{ val1: { $eq: 1 }, $or: { val1: { $eq: 2 } } }, '("val1" = ? or "val1" = ?)', [1, 2]],
[{ val1: { $eq: 1 }, $or: { val1: { $eq: 2 } } }, '("val1" = ? or "val1" = ?)', [1, 2]]
[{ val1: { $eq: 1 }, $or: { val1: { $eq: 2 } } }, '("val1" = ? or "val1" = ?)', [1, 2]],
];
for (const [query, expectedSql, expectedParams] of tests) {
@@ -81,7 +86,7 @@ describe("data-query-impl", () => {
// or constructs
[{ $or: { val1: { $eq: "foo" }, val2: { $eq: "bar" } } }, ["val1", "val2"]],
[{ val1: { $eq: 1 }, $or: { val1: { $eq: 2 } } }, ["val1"]]
[{ val1: { $eq: 1 }, $or: { val1: { $eq: 2 } } }, ["val1"]],
];
for (const [query, expectedKeys] of tests) {
@@ -89,4 +94,64 @@ describe("data-query-impl", () => {
expect(keys).toEqual(expectedKeys);
}
});
test("with", () => {
decode({ with: ["posts"] }, { with: { posts: {} } });
decode({ with: { posts: {} } }, { with: { posts: {} } });
decode({ with: { posts: { limit: 1 } } }, { with: { posts: { limit: 1 } } });
decode(
{
with: {
posts: {
with: {
images: {
select: ["id"],
},
},
},
},
},
{
with: {
posts: {
with: {
images: {
select: ["id"],
},
},
},
},
},
);
// over http
{
const output = { with: { images: {} } };
decode({ with: "images" }, output);
decode({ with: '["images"]' }, output);
decode({ with: ["images"] }, output);
decode({ with: { images: {} } }, output);
}
{
const output = { with: { images: {}, comments: {} } };
decode({ with: "images,comments" }, output);
decode({ with: ["images", "comments"] }, output);
decode({ with: '["images", "comments"]' }, output);
decode({ with: { images: {}, comments: {} } }, output);
}
});
});
describe("data-query-impl: Typebox", () => {
test("sort", async () => {
const _dflt = { sort: { by: "id", dir: "asc" } };
decode({ sort: "" }, _dflt);
decode({ sort: "name" }, { sort: { by: "name", dir: "asc" } });
decode({ sort: "-name" }, { sort: { by: "name", dir: "desc" } });
decode({ sort: "-posts.name" }, { sort: { by: "posts.name", dir: "desc" } });
decode({ sort: "-1name" }, _dflt);
decode({ sort: { by: "name", dir: "desc" } }, { sort: { by: "name", dir: "desc" } });
});
});
+9 -9
View File
@@ -5,7 +5,7 @@ import {
NumberField,
PrimaryField,
Repository,
TextField
TextField,
} from "../../src/data";
import { getDummyConnection } from "./helper";
@@ -18,14 +18,14 @@ describe("some tests", async () => {
const users = new Entity("users", [
new TextField("username", { required: true, default_value: "nobody" }),
new TextField("email", { max_length: 3 })
new TextField("email", { maxLength: 3 }),
]);
const posts = new Entity("posts", [
new TextField("title"),
new TextField("content"),
new TextField("created_at"),
new NumberField("likes", { default_value: 0 })
new NumberField("likes", { default_value: 0 }),
]);
const em = new EntityManager([users, posts], connection);
@@ -43,7 +43,7 @@ describe("some tests", async () => {
});*/
expect(query.sql).toBe(
'select "users"."id" as "id", "users"."username" as "username", "users"."email" as "email" from "users" where "id" = ? limit ?'
'select "users"."id" as "id", "users"."username" as "username", "users"."email" as "email" from "users" where "id" = ? limit ?',
);
expect(query.parameters).toEqual([1, 1]);
expect(query.result).toEqual([]);
@@ -53,7 +53,7 @@ describe("some tests", async () => {
const query = await em.repository(users).findMany();
expect(query.sql).toBe(
'select "users"."id" as "id", "users"."username" as "username", "users"."email" as "email" from "users" order by "users"."id" asc limit ? offset ?'
'select "users"."id" as "id", "users"."username" as "username", "users"."email" as "email" from "users" order by "users"."id" asc limit ? offset ?',
);
expect(query.parameters).toEqual([10, 0]);
expect(query.result).toEqual([]);
@@ -63,7 +63,7 @@ describe("some tests", async () => {
const query = await em.repository(posts).findMany();
expect(query.sql).toBe(
'select "posts"."id" as "id", "posts"."title" as "title", "posts"."content" as "content", "posts"."created_at" as "created_at", "posts"."likes" as "likes" from "posts" order by "posts"."id" asc limit ? offset ?'
'select "posts"."id" as "id", "posts"."title" as "title", "posts"."content" as "content", "posts"."created_at" as "created_at", "posts"."likes" as "likes" from "posts" order by "posts"."id" asc limit ? offset ?',
);
expect(query.parameters).toEqual([10, 0]);
expect(query.result).toEqual([]);
@@ -74,7 +74,7 @@ describe("some tests", async () => {
new Entity("users", [
new TextField("username"),
new TextField("email"),
new TextField("email") // not throwing, it's just being ignored
new TextField("email"), // not throwing, it's just being ignored
]);
}).toBeDefined();
@@ -83,7 +83,7 @@ describe("some tests", async () => {
new TextField("username"),
new TextField("email"),
// field config differs, will throw
new TextField("email", { required: true })
new TextField("email", { required: true }),
]);
}).toThrow();
@@ -91,7 +91,7 @@ describe("some tests", async () => {
new Entity("users", [
new PrimaryField(),
new TextField("username"),
new TextField("email")
new TextField("email"),
]);
}).toBeDefined();
});
+2 -2
View File
@@ -16,7 +16,7 @@ export function getDummyDatabase(memory: boolean = true): {
afterAllCleanup: async () => {
if (!memory) await unlink(DB_NAME);
return true;
}
},
};
}
@@ -26,7 +26,7 @@ export function getDummyConnection(memory: boolean = true) {
return {
dummyConnection,
afterAllCleanup
afterAllCleanup,
};
}
+3 -3
View File
@@ -6,7 +6,7 @@ import {
ManyToOneRelation,
NumberField,
SchemaManager,
TextField
TextField,
} from "../../src/data";
import { getDummyConnection } from "./helper";
@@ -21,7 +21,7 @@ describe("Mutator relation", async () => {
const posts = new Entity("posts", [
new TextField("title"),
new TextField("content", { default_value: "..." }),
new NumberField("count", { default_value: 0 })
new NumberField("count", { default_value: 0 }),
]);
const users = new Entity("users", [new TextField("username")]);
@@ -44,7 +44,7 @@ describe("Mutator relation", async () => {
expect(em.mutator(posts).insertOne({ title: "post2", users_id: 10 })).rejects.toThrow();
expect(
em.mutator(posts).insertOne({ title: "post2", users_id: data.id })
em.mutator(posts).insertOne({ title: "post2", users_id: data.id }),
).resolves.toBeDefined();
});
});
+65 -18
View File
@@ -14,9 +14,9 @@ describe("Mutator simple", async () => {
const items = new Entity("items", [
new TextField("label", { required: true, minLength: 1 }),
new NumberField("count", { default_value: 0 })
new NumberField("count", { default_value: 0 }),
]);
const em = new EntityManager([items], connection);
const em = new EntityManager<any>([items], connection);
await em.connection.kysely.schema
.createTable("items")
@@ -29,11 +29,11 @@ describe("Mutator simple", async () => {
test("insert single row", async () => {
const mutation = await em.mutator(items).insertOne({
label: "test",
count: 1
count: 1,
});
expect(mutation.sql).toBe(
'insert into "items" ("count", "label") values (?, ?) returning "id", "label", "count"'
'insert into "items" ("count", "label") values (?, ?) returning "id", "label", "count"',
);
expect(mutation.data).toEqual({ id: 1, label: "test", count: 1 });
@@ -41,8 +41,8 @@ describe("Mutator simple", async () => {
limit: 1,
sort: {
by: "id",
dir: "desc"
}
dir: "desc",
},
});
expect(query.result).toEqual([{ id: 1, label: "test", count: 1 }]);
@@ -53,18 +53,18 @@ describe("Mutator simple", async () => {
limit: 1,
sort: {
by: "id",
dir: "desc"
}
dir: "desc",
},
});
const id = query.data![0].id as number;
const mutation = await em.mutator(items).updateOne(id, {
label: "new label",
count: 100
count: 100,
});
expect(mutation.sql).toBe(
'update "items" set "label" = ?, "count" = ? where "id" = ? returning "id", "label", "count"'
'update "items" set "label" = ?, "count" = ? where "id" = ? returning "id", "label", "count"',
);
expect(mutation.data).toEqual({ id, label: "new label", count: 100 });
});
@@ -74,15 +74,15 @@ describe("Mutator simple", async () => {
limit: 1,
sort: {
by: "id",
dir: "desc"
}
dir: "desc",
},
});
const id = query.data![0].id as number;
const mutation = await em.mutator(items).deleteOne(id);
expect(mutation.sql).toBe(
'delete from "items" where "id" = ? returning "id", "label", "count"'
'delete from "items" where "id" = ? returning "id", "label", "count"',
);
expect(mutation.data).toEqual({ id, label: "new label", count: 100 });
@@ -94,7 +94,7 @@ describe("Mutator simple", async () => {
const incompleteCreate = async () =>
await em.mutator(items).insertOne({
//label: "test",
count: 1
count: 1,
});
expect(incompleteCreate()).rejects.toThrow();
@@ -104,7 +104,7 @@ describe("Mutator simple", async () => {
const invalidCreate1 = async () =>
await em.mutator(items).insertOne({
label: 111, // this should work
count: "1" // this should fail
count: "1", // this should fail
});
expect(invalidCreate1()).rejects.toThrow(TransformPersistFailedException);
@@ -112,7 +112,7 @@ describe("Mutator simple", async () => {
const invalidCreate2 = async () =>
await em.mutator(items).insertOne({
label: "", // this should fail
count: 1
count: 1,
});
expect(invalidCreate2()).rejects.toThrow(TransformPersistFailedException);
@@ -132,14 +132,61 @@ describe("Mutator simple", async () => {
const data = (await em.repository(items).findMany()).data;
//console.log(data);
await em.mutator(items).deleteMany({ label: "delete" });
await em.mutator(items).deleteWhere({ label: "delete" });
expect((await em.repository(items).findMany()).data.length).toBe(data.length - 2);
//console.log((await em.repository(items).findMany()).data);
await em.mutator(items).deleteMany();
await em.mutator(items).deleteWhere({ id: { $isnull: 0 } });
expect((await em.repository(items).findMany()).data.length).toBe(0);
//expect(res.data.count).toBe(0);
});
test("updateMany", async () => {
await em.mutator(items).insertOne({ label: "update", count: 1 });
await em.mutator(items).insertOne({ label: "update too", count: 1 });
await em.mutator(items).insertOne({ label: "keep" });
// expect no update
await em.mutator(items).updateWhere(
{ count: 2 },
{
count: 10,
},
);
expect((await em.repository(items).findMany()).data).toEqual([
{ id: 6, label: "update", count: 1 },
{ id: 7, label: "update too", count: 1 },
{ id: 8, label: "keep", count: 0 },
]);
// expect 2 to be updated
await em.mutator(items).updateWhere(
{ count: 2 },
{
count: 1,
},
);
expect((await em.repository(items).findMany()).data).toEqual([
{ id: 6, label: "update", count: 2 },
{ id: 7, label: "update too", count: 2 },
{ id: 8, label: "keep", count: 0 },
]);
});
test("insertMany", async () => {
const oldCount = (await em.repo(items).count()).count;
const inserts = [{ label: "insert 1" }, { label: "insert 2" }];
const { data } = await em.mutator(items).insertMany(inserts);
expect(data.length).toBe(2);
expect(data.map((d) => ({ label: d.label }))).toEqual(inserts);
const newCount = (await em.repo(items).count()).count;
expect(newCount).toBe(oldCount + inserts.length);
const { data: data2 } = await em.repo(items).findMany({ offset: oldCount });
expect(data2).toEqual(data);
});
});
+11 -11
View File
@@ -23,23 +23,23 @@ describe("Polymorphic", async () => {
source: "categories",
target: "media",
config: {
mappedBy: "image"
}
mappedBy: "image",
},
});
// media should not see categories
expect(em.relationsOf(media.name).map((r) => r.toJSON())).toEqual([]);
// it's important that media cannot access categories
expect(em.relations.targetRelationsOf(categories).map((r) => r.source.entity.name)).toEqual(
[]
[],
);
expect(em.relations.targetRelationsOf(media).map((r) => r.source.entity.name)).toEqual([]);
expect(em.relations.sourceRelationsOf(categories).map((r) => r.target.entity.name)).toEqual([
"media"
"media",
]);
expect(em.relations.sourceRelationsOf(categories).map((r) => r.target.reference)).toEqual([
"image"
"image",
]);
expect(em.relations.sourceRelationsOf(media).map((r) => r.target.entity.name)).toEqual([]);
@@ -48,7 +48,7 @@ describe("Polymorphic", async () => {
"id",
"path",
"reference",
"entity_id"
"entity_id",
]);
expect(media.getSelect()).toEqual(["id", "path"]);
});
@@ -60,7 +60,7 @@ describe("Polymorphic", async () => {
const entities = [media, categories];
const single = new PolymorphicRelation(categories, media, {
mappedBy: "single",
targetCardinality: 1
targetCardinality: 1,
});
const multiple = new PolymorphicRelation(categories, media, { mappedBy: "multiple" });
@@ -71,17 +71,17 @@ describe("Polymorphic", async () => {
// it's important that media cannot access categories
expect(em.relations.targetRelationsOf(categories).map((r) => r.source.entity.name)).toEqual(
[]
[],
);
expect(em.relations.targetRelationsOf(media).map((r) => r.source.entity.name)).toEqual([]);
expect(em.relations.sourceRelationsOf(categories).map((r) => r.target.entity.name)).toEqual([
"media",
"media"
"media",
]);
expect(em.relations.sourceRelationsOf(categories).map((r) => r.target.reference)).toEqual([
"single",
"multiple"
"multiple",
]);
expect(em.relations.sourceRelationsOf(media).map((r) => r.target.entity.name)).toEqual([]);
@@ -90,7 +90,7 @@ describe("Polymorphic", async () => {
"id",
"path",
"reference",
"entity_id"
"entity_id",
]);
});
});
+79 -34
View File
@@ -1,9 +1,10 @@
import { describe, expect, test } from "bun:test";
import { MediaField } from "../../src";
import {
BooleanField,
DateField,
Entity,
EntityIndex,
EntityManager,
EnumField,
JsonField,
ManyToManyRelation,
@@ -11,8 +12,9 @@ import {
NumberField,
OneToOneRelation,
PolymorphicRelation,
TextField
TextField,
} from "../../src/data";
import { DummyConnection } from "../../src/data/connection/DummyConnection";
import {
FieldPrototype,
type FieldSchema,
@@ -21,6 +23,7 @@ import {
boolean,
date,
datetime,
em,
entity,
enumm,
json,
@@ -28,8 +31,9 @@ import {
medium,
number,
relation,
text
text,
} from "../../src/data/prototype";
import { MediaField } from "../../src/media/MediaField";
describe("prototype", () => {
test("...", () => {
@@ -46,12 +50,17 @@ describe("prototype", () => {
});
test("...2", async () => {
const user = entity("users", {
name: text().required(),
const users = entity("users", {
name: text(),
bio: text(),
age: number(),
some: number().required()
some: number(),
});
type db = {
users: Schema<typeof users>;
};
const obj: Schema<typeof users> = {} as any;
//console.log("user", user.toJSON());
});
@@ -61,7 +70,7 @@ describe("prototype", () => {
name: text({ default_value: "hello" }).required(),
bio: text(),
age: number(),
some: number().required()
some: number().required(),
});
const obj: InsertSchema<typeof user> = { name: "yo", some: 1 };
@@ -74,10 +83,12 @@ describe("prototype", () => {
new TextField("title", { required: true }),
new TextField("content"),
new DateField("created_at", {
type: "datetime"
type: "datetime",
}),
// @ts-ignore
new MediaField("images", { entity: "posts" }),
new MediaField("cover", { entity: "posts", max_items: 1 })
// @ts-ignore
new MediaField("cover", { entity: "posts", max_items: 1 }),
]);
const posts2 = entity("posts", {
@@ -85,7 +96,7 @@ describe("prototype", () => {
content: text(),
created_at: datetime(),
images: media(),
cover: medium()
cover: medium(),
});
type Posts = Schema<typeof posts2>;
@@ -106,11 +117,11 @@ describe("prototype", () => {
type: "objects",
values: [
{ value: "active", label: "Active" },
{ value: "inactive", label: "Not active" }
]
}
{ value: "inactive", label: "Not active" },
],
},
}),
new JsonField("json")
new JsonField("json"),
]);
const test2 = entity("test", {
@@ -123,10 +134,10 @@ describe("prototype", () => {
status: enumm<"active" | "inactive">({
enum: [
{ value: "active", label: "Active" },
{ value: "inactive", label: "Not active" }
]
{ value: "inactive", label: "Not active" },
],
}),
json: json<{ some: number }>()
json: json<{ some: number }>(),
});
expect(test.toJSON()).toEqual(test2.toJSON());
@@ -150,12 +161,12 @@ describe("prototype", () => {
// category has single image
new PolymorphicRelation(categories, _media, {
mappedBy: "image",
targetCardinality: 1
targetCardinality: 1,
}),
// post has multiple images
new PolymorphicRelation(posts, _media, { mappedBy: "images" }),
new PolymorphicRelation(posts, _media, { mappedBy: "cover", targetCardinality: 1 })
new PolymorphicRelation(posts, _media, { mappedBy: "cover", targetCardinality: 1 }),
];
const relations2 = [
@@ -169,7 +180,7 @@ describe("prototype", () => {
relation(categories).polyToOne(_media, { mappedBy: "image" }),
relation(posts).polyToMany(_media, { mappedBy: "images" }),
relation(posts).polyToOne(_media, { mappedBy: "cover" })
relation(posts).polyToOne(_media, { mappedBy: "cover" }),
];
expect(relations.map((r) => r.toJSON())).toEqual(relations2.map((r) => r.toJSON()));
@@ -183,21 +194,21 @@ describe("prototype", () => {
posts,
categories,
{
connectionTableMappedName: "custom"
connectionTableMappedName: "custom",
},
[new TextField("description")]
[new TextField("description")],
);
const fields = {
description: text()
description: text(),
};
let o: FieldSchema<typeof fields>;
const rel2 = relation(posts).manyToMany(
categories,
{
connectionTableMappedName: "custom"
connectionTableMappedName: "custom",
},
fields
fields,
);
expect(rel.toJSON()).toEqual(rel2.toJSON());
@@ -205,11 +216,11 @@ describe("prototype", () => {
test("devexample", async () => {
const users = entity("users", {
username: text()
username: text(),
});
const comments = entity("comments", {
content: text()
content: text(),
});
const posts = entity("posts", {
@@ -217,17 +228,17 @@ describe("prototype", () => {
content: text(),
created_at: datetime(),
images: media(),
cover: medium()
cover: medium(),
});
const categories = entity("categories", {
name: text(),
description: text(),
image: medium()
image: medium(),
});
const settings = entity("settings", {
theme: text()
theme: text(),
});
const test = entity("test", {
@@ -240,10 +251,10 @@ describe("prototype", () => {
status: enumm<"active" | "inactive">({
enum: [
{ value: "active", label: "Active" },
{ value: "inactive", label: "Not active" }
]
{ value: "inactive", label: "Not active" },
],
}),
json: json<{ some: number }>()
json: json<{ some: number }>(),
});
const _media = entity("media", {});
@@ -259,9 +270,43 @@ describe("prototype", () => {
relation(users).oneToOne(settings),
relation(comments).manyToOne(users, { required: true }),
relation(comments).manyToOne(posts, { required: true })
relation(comments).manyToOne(posts, { required: true }),
];
const obj: Schema<typeof test> = {} as any;
});
test("schema", async () => {
const _em = em(
{
posts: entity("posts", { name: text(), slug: text().required() }),
comments: entity("comments", { some: text() }),
users: entity("users", { email: text() }),
},
({ relation, index }, { posts, comments, users }) => {
relation(posts).manyToOne(comments).manyToOne(users);
index(posts).on(["name"]).on(["slug"], true);
},
);
type LocalDb = (typeof _em)["DB"];
const es = [
new Entity("posts", [new TextField("name"), new TextField("slug", { required: true })]),
new Entity("comments", [new TextField("some")]),
new Entity("users", [new TextField("email")]),
];
const _em2 = new EntityManager(
es,
new DummyConnection(),
[new ManyToOneRelation(es[0], es[1]), new ManyToOneRelation(es[0], es[2])],
[
new EntityIndex(es[0], [es[0].field("name")!]),
new EntityIndex(es[0], [es[0].field("slug")!], true),
],
);
// @ts-ignore
expect(_em2.toJSON()).toEqual(_em.toJSON());
});
});
+32 -43
View File
@@ -6,7 +6,7 @@ import {
ManyToOneRelation,
OneToOneRelation,
PolymorphicRelation,
RelationField
RelationField,
} from "../../src/data/relations";
import { getDummyConnection } from "./helper";
@@ -22,32 +22,32 @@ describe("Relations", async () => {
const r1 = new RelationField("users_id", {
reference: "users",
target: "users",
target_field: "id"
target_field: "id",
});
const sql1 = schema
.createTable("posts")
.addColumn(...r1.schema()!)
.addColumn(...em.connection.getFieldSchema(r1.schema())!)
.compile().sql;
expect(sql1).toBe(
'create table "posts" ("users_id" integer references "users" ("id") on delete set null)'
'create table "posts" ("users_id" integer references "users" ("id") on delete set null)',
);
//const r2 = new RelationField(new Entity("users"), "author");
const r2 = new RelationField("author_id", {
reference: "author",
target: "users",
target_field: "id"
target_field: "id",
});
const sql2 = schema
.createTable("posts")
.addColumn(...r2.schema()!)
.addColumn(...em.connection.getFieldSchema(r2.schema())!)
.compile().sql;
expect(sql2).toBe(
'create table "posts" ("author_id" integer references "users" ("id") on delete set null)'
'create table "posts" ("author_id" integer references "users" ("id") on delete set null)',
);
});
@@ -57,7 +57,7 @@ describe("Relations", async () => {
reference: "users",
target: "users",
target_field: "id",
required: true
required: true,
});
expect(r1.isRequired()).toBeTrue();
});
@@ -66,8 +66,8 @@ describe("Relations", async () => {
const users = new Entity("users", [new TextField("username")]);
const posts = new Entity("posts", [
new TextField("title", {
maxLength: 2
})
maxLength: 2,
}),
]);
const entities = [users, posts];
@@ -106,7 +106,6 @@ describe("Relations", async () => {
expect(postAuthorRel?.other(posts).entity).toBe(users);
const kysely = em.connection.kysely;
const jsonFrom = (e) => e;
/**
* Relation Helper
*/
@@ -119,14 +118,11 @@ describe("Relations", async () => {
- select: users.*
- cardinality: 1
*/
const selectPostsFromUsers = postAuthorRel.buildWith(
users,
kysely.selectFrom(users.name),
jsonFrom,
"posts"
);
const selectPostsFromUsers = kysely
.selectFrom(users.name)
.select((eb) => postAuthorRel.buildWith(users, "posts")(eb).as("posts"));
expect(selectPostsFromUsers.compile().sql).toBe(
'select (select "posts"."id" as "id", "posts"."title" as "title", "posts"."author_id" as "author_id" from "posts" as "posts" where "posts"."author_id" = "users"."id" limit ?) as "posts" from "users"'
'select (select from "posts" as "posts" where "posts"."author_id" = "users"."id") as "posts" from "users"',
);
expect(postAuthorRel!.getField()).toBeInstanceOf(RelationField);
const userObj = { id: 1, username: "test" };
@@ -141,15 +137,12 @@ describe("Relations", async () => {
- select: posts.*
- cardinality:
*/
const selectUsersFromPosts = postAuthorRel.buildWith(
posts,
kysely.selectFrom(posts.name),
jsonFrom,
"author"
);
const selectUsersFromPosts = kysely
.selectFrom(posts.name)
.select((eb) => postAuthorRel.buildWith(posts, "author")(eb).as("author"));
expect(selectUsersFromPosts.compile().sql).toBe(
'select (select "author"."id" as "id", "author"."username" as "username" from "users" as "author" where "author"."id" = "posts"."author_id" limit ?) as "author" from "posts"'
'select (select from "users" as "author" where "author"."id" = "posts"."author_id" limit ?) as "author" from "posts"',
);
expect(postAuthorRel.getField()).toBeInstanceOf(RelationField);
const postObj = { id: 1, title: "test" };
@@ -165,7 +158,7 @@ describe("Relations", async () => {
$detach: false,
primary: undefined,
cardinality: undefined,
relation_type: "n:1"
relation_type: "n:1",
});
expect(postAuthorRel!.helper(posts.name)!.getMutationInfo()).toEqual({
@@ -177,7 +170,7 @@ describe("Relations", async () => {
$detach: false,
primary: "id",
cardinality: 1,
relation_type: "n:1"
relation_type: "n:1",
});
/*console.log("ManyToOne (source=posts, target=users)");
@@ -232,7 +225,7 @@ describe("Relations", async () => {
$detach: false,
primary: "id",
cardinality: 1,
relation_type: "1:1"
relation_type: "1:1",
});
expect(userSettingRel!.helper(settings.name)!.getMutationInfo()).toEqual({
reference: "users",
@@ -243,7 +236,7 @@ describe("Relations", async () => {
$detach: false,
primary: undefined,
cardinality: 1,
relation_type: "1:1"
relation_type: "1:1",
});
/*console.log("");
@@ -315,22 +308,18 @@ describe("Relations", async () => {
- select: users.*
- cardinality: 1
*/
const selectCategoriesFromPosts = postCategoriesRel.buildWith(
posts,
kysely.selectFrom(posts.name),
jsonFrom
);
const selectCategoriesFromPosts = kysely
.selectFrom(posts.name)
.select((eb) => postCategoriesRel.buildWith(posts)(eb).select("id").as("categories"));
expect(selectCategoriesFromPosts.compile().sql).toBe(
'select (select "categories"."id" as "id", "categories"."label" as "label" from "categories" inner join "posts_categories" on "categories"."id" = "posts_categories"."categories_id" where "posts"."id" = "posts_categories"."posts_id" limit ?) as "categories" from "posts"'
'select (select "id" from "categories" inner join "posts_categories" on "categories"."id" = "posts_categories"."categories_id" where "posts"."id" = "posts_categories"."posts_id" limit ?) as "categories" from "posts"',
);
const selectPostsFromCategories = postCategoriesRel.buildWith(
categories,
kysely.selectFrom(categories.name),
jsonFrom
);
const selectPostsFromCategories = kysely
.selectFrom(categories.name)
.select((eb) => postCategoriesRel.buildWith(categories)(eb).select("id").as("posts"));
expect(selectPostsFromCategories.compile().sql).toBe(
'select (select "posts"."id" as "id", "posts"."title" as "title" from "posts" inner join "posts_categories" on "posts"."id" = "posts_categories"."posts_id" where "categories"."id" = "posts_categories"."categories_id" limit ?) as "posts" from "categories"'
'select (select "id" from "posts" inner join "posts_categories" on "posts"."id" = "posts_categories"."posts_id" where "categories"."id" = "posts_categories"."categories_id" limit ?) as "posts" from "categories"',
);
// mutation info
@@ -343,7 +332,7 @@ describe("Relations", async () => {
$detach: true,
primary: "id",
cardinality: undefined,
relation_type: "m:n"
relation_type: "m:n",
});
expect(relations[0].helper(categories.name)!.getMutationInfo()).toEqual({
reference: "posts",
@@ -354,7 +343,7 @@ describe("Relations", async () => {
$detach: false,
primary: undefined,
cardinality: undefined,
relation_type: "m:n"
relation_type: "m:n",
});
/*console.log("");
+3 -3
View File
@@ -6,7 +6,7 @@ describe("[data] Entity", async () => {
new TextField("name", { required: true }),
new TextField("description"),
new NumberField("age", { fillable: false, default_value: 18 }),
new TextField("hidden", { hidden: true, default_value: "secret" })
new TextField("hidden", { hidden: true, default_value: "secret" }),
]);
test("getSelect", async () => {
@@ -17,7 +17,7 @@ describe("[data] Entity", async () => {
expect(entity.getFillableFields().map((f) => f.name)).toEqual([
"name",
"description",
"hidden"
"hidden",
]);
});
@@ -28,7 +28,7 @@ describe("[data] Entity", async () => {
test("getDefaultObject", async () => {
expect(entity.getDefaultObject()).toEqual({
age: 18,
hidden: "secret"
hidden: "secret",
});
});
@@ -4,7 +4,7 @@ import {
EntityManager,
ManyToManyRelation,
ManyToOneRelation,
SchemaManager
SchemaManager,
} from "../../../src/data";
import { UnableToConnectException } from "../../../src/data/errors";
import { getDummyConnection } from "../helper";
@@ -25,7 +25,7 @@ describe("[data] EntityManager", async () => {
expect(await em.ping()).toBe(true);
expect(() => em.entity("...")).toThrow();
expect(() =>
em.addRelation(new ManyToOneRelation(new Entity("1"), new Entity("2")))
em.addRelation(new ManyToOneRelation(new Entity("1"), new Entity("2"))),
).toThrow();
expect(em.schema()).toBeInstanceOf(SchemaManager);
@@ -98,7 +98,7 @@ describe("[data] EntityManager", async () => {
expect(userTargetRel.map((r) => r.other(users).entity.name)).toEqual(["posts", "comments"]);
expect(postTargetRel.map((r) => r.other(posts).entity.name)).toEqual([
"comments",
"categories"
"categories",
]);
expect(commentTargetRel.map((r) => r.other(comments).entity.name)).toEqual([]);
expect(categoriesTargetRel.map((r) => r.other(categories).entity.name)).toEqual(["posts"]);
+3 -3
View File
@@ -12,7 +12,7 @@ describe("[data] JoinBuilder", async () => {
const em = new EntityManager([users], dummyConnection);
expect(() =>
JoinBuilder.addClause(em, em.connection.kysely.selectFrom("users"), users, ["posts"])
JoinBuilder.addClause(em, em.connection.kysely.selectFrom("users"), users, ["posts"]),
).toThrow('Relation "posts" not found');
});
@@ -23,7 +23,7 @@ describe("[data] JoinBuilder", async () => {
const em = new EntityManager([users, posts], dummyConnection, relations);
const qb = JoinBuilder.addClause(em, em.connection.kysely.selectFrom("users"), users, [
"posts"
"posts",
]);
const res = qb.compile();
@@ -34,7 +34,7 @@ describe("[data] JoinBuilder", async () => {
);*/
const qb2 = JoinBuilder.addClause(em, em.connection.kysely.selectFrom("posts"), posts, [
"author"
"author",
]);
const res2 = qb2.compile();
+99 -32
View File
@@ -1,4 +1,5 @@
import { afterAll, describe, expect, test } from "bun:test";
import type { EventManager } from "../../../src/core/events";
import {
Entity,
EntityManager,
@@ -8,8 +9,9 @@ import {
OneToOneRelation,
type RelationField,
RelationMutator,
TextField
TextField,
} from "../../../src/data";
import * as proto from "../../../src/data/prototype";
import { getDummyConnection } from "../helper";
const { dummyConnection, afterAllCleanup } = getDummyConnection();
@@ -20,9 +22,9 @@ describe("[data] Mutator (base)", async () => {
new TextField("label", { required: true }),
new NumberField("count"),
new TextField("hidden", { hidden: true }),
new TextField("not_fillable", { fillable: false })
new TextField("not_fillable", { fillable: false }),
]);
const em = new EntityManager([entity], dummyConnection);
const em = new EntityManager<any>([entity], dummyConnection);
await em.schema().sync({ force: true });
const payload = { label: "item 1", count: 1 };
@@ -42,7 +44,7 @@ describe("[data] Mutator (base)", async () => {
test("updateOne", async () => {
const { data } = await em.mutator(entity).insertOne(payload);
const updated = await em.mutator(entity).updateOne(data.id, {
count: 2
count: 2,
});
expect(updated.parameters).toEqual([2, data.id]);
@@ -61,7 +63,7 @@ describe("[data] Mutator (ManyToOne)", async () => {
const posts = new Entity("posts", [new TextField("title")]);
const users = new Entity("users", [new TextField("username")]);
const relations = [new ManyToOneRelation(posts, users)];
const em = new EntityManager([posts, users], dummyConnection, relations);
const em = new EntityManager<any>([posts, users], dummyConnection, relations);
await em.schema().sync({ force: true });
test("RelationMutator", async () => {
@@ -75,7 +77,7 @@ describe("[data] Mutator (ManyToOne)", async () => {
// persisting relational field should just return key value to be added
expect(
postRelMutator.persistRelationField(postRelField, "users_id", userData.data.id)
postRelMutator.persistRelationField(postRelField, "users_id", userData.data.id),
).resolves.toEqual(["users_id", userData.data.id]);
// persisting invalid value should throw
@@ -83,14 +85,12 @@ describe("[data] Mutator (ManyToOne)", async () => {
// persisting reference should ...
expect(
postRelMutator.persistReference(relations[0], "users", {
$set: { id: userData.data.id }
})
postRelMutator.persistReference(relations[0]!, "users", {
$set: { id: userData.data.id },
}),
).resolves.toEqual(["users_id", userData.data.id]);
// @todo: add what methods are allowed to relation, like $create should not be allowed for post<>users
process.exit(0);
const userRelMutator = new RelationMutator(users, em);
expect(userRelMutator.getRelationalKeys()).toEqual(["posts"]);
});
@@ -99,8 +99,8 @@ describe("[data] Mutator (ManyToOne)", async () => {
expect(
em.mutator(posts).insertOne({
title: "post1",
users_id: 1 // user does not exist yet
})
users_id: 100, // user does not exist yet
}),
).rejects.toThrow();
});
@@ -111,7 +111,7 @@ describe("[data] Mutator (ManyToOne)", async () => {
const em = new EntityManager([items, cats], dummyConnection, relations);
expect(em.mutator(items).insertOne({ label: "test" })).rejects.toThrow(
'Field "cats_id" is required'
'Field "cats_id" is required',
);
});
@@ -119,14 +119,14 @@ describe("[data] Mutator (ManyToOne)", async () => {
const { data } = await em.mutator(users).insertOne({ username: "user1" });
const res = await em.mutator(posts).insertOne({
title: "post1",
users_id: data.id
users_id: data.id,
});
expect(res.data.users_id).toBe(data.id);
// setting "null" should be allowed
const res2 = await em.mutator(posts).insertOne({
title: "post1",
users_id: null
users_id: null,
});
expect(res2.data.users_id).toBe(null);
});
@@ -135,14 +135,14 @@ describe("[data] Mutator (ManyToOne)", async () => {
const { data } = await em.mutator(users).insertOne({ username: "user1" });
const res = await em.mutator(posts).insertOne({
title: "post1",
users: { $set: { id: data.id } }
users: { $set: { id: data.id } },
});
expect(res.data.users_id).toBe(data.id);
// setting "null" should be allowed
const res2 = await em.mutator(posts).insertOne({
title: "post1",
users: { $set: { id: null } }
users: { $set: { id: null } },
});
expect(res2.data.users_id).toBe(null);
});
@@ -151,8 +151,8 @@ describe("[data] Mutator (ManyToOne)", async () => {
expect(
em.mutator(posts).insertOne({
title: "test",
users: { $create: { username: "test" } }
})
users: { $create: { username: "test" } },
}),
).rejects.toThrow();
});
@@ -162,27 +162,27 @@ describe("[data] Mutator (ManyToOne)", async () => {
const res2 = await em.mutator(posts).insertOne({ title: "post1" });
const up1 = await em.mutator(posts).updateOne(res2.data.id, {
users: { $set: { id: res1.data.id } }
users: { $set: { id: res1.data.id } },
});
expect(up1.data.users_id).toBe(res1.data.id);
const up2 = await em.mutator(posts).updateOne(res2.data.id, {
users: { $set: { id: res1_1.data.id } }
users: { $set: { id: res1_1.data.id } },
});
expect(up2.data.users_id).toBe(res1_1.data.id);
const up3_1 = await em.mutator(posts).updateOne(res2.data.id, {
users_id: res1.data.id
users_id: res1.data.id,
});
expect(up3_1.data.users_id).toBe(res1.data.id);
const up3_2 = await em.mutator(posts).updateOne(res2.data.id, {
users_id: res1_1.data.id
users_id: res1_1.data.id,
});
expect(up3_2.data.users_id).toBe(res1_1.data.id);
const up4 = await em.mutator(posts).updateOne(res2.data.id, {
users_id: null
users_id: null,
});
expect(up4.data.users_id).toBe(null);
});
@@ -192,15 +192,15 @@ describe("[data] Mutator (OneToOne)", async () => {
const users = new Entity("users", [new TextField("username")]);
const settings = new Entity("settings", [new TextField("theme")]);
const relations = [new OneToOneRelation(users, settings)];
const em = new EntityManager([users, settings], dummyConnection, relations);
const em = new EntityManager<any>([users, settings], dummyConnection, relations);
await em.schema().sync({ force: true });
test("insertOne: missing ref", async () => {
expect(
em.mutator(users).insertOne({
username: "test",
settings_id: 1 // todo: throws because it doesn't exist, but it shouldn't be allowed
})
settings_id: 1, // todo: throws because it doesn't exist, but it shouldn't be allowed
}),
).rejects.toThrow();
});
@@ -210,15 +210,15 @@ describe("[data] Mutator (OneToOne)", async () => {
expect(
em.mutator(users).insertOne({
username: "test",
settings: { $set: { id: data.id } }
})
settings: { $set: { id: data.id } },
}),
).rejects.toThrow();
});
test("insertOne: using $create", async () => {
const res = await em.mutator(users).insertOne({
username: "test",
settings: { $create: { theme: "dark" } }
settings: { $create: { theme: "dark" } },
});
expect(res.data.settings_id).toBeDefined();
});
@@ -276,7 +276,7 @@ describe("[data] Mutator (ManyToMany)", async () => {
describe("[data] Mutator (Events)", async () => {
const entity = new Entity("test", [new TextField("label")]);
const em = new EntityManager([entity], dummyConnection);
const em = new EntityManager<any>([entity], dummyConnection);
await em.schema().sync({ force: true });
const events = new Map<string, any>();
@@ -299,4 +299,71 @@ describe("[data] Mutator (Events)", async () => {
expect(events.has(MutatorEvents.MutatorDeleteBefore.slug)).toBeTrue();
expect(events.has(MutatorEvents.MutatorDeleteAfter.slug)).toBeTrue();
});
test("insertOne event return is respected", async () => {
const posts = proto.entity("posts", {
title: proto.text(),
views: proto.number(),
});
const conn = getDummyConnection();
const em = new EntityManager([posts], conn.dummyConnection);
await em.schema().sync({ force: true });
const emgr = em.emgr as EventManager<any>;
emgr.onEvent(
// @ts-ignore
EntityManager.Events.MutatorInsertBefore,
async (event) => {
return {
...event.params.data,
views: 2,
};
},
"sync",
);
const mutator = em.mutator("posts");
const result = await mutator.insertOne({ title: "test", views: 1 });
expect(result.data).toEqual({
id: 1,
title: "test",
views: 2,
});
});
test("updateOne event return is respected", async () => {
const posts = proto.entity("posts", {
title: proto.text(),
views: proto.number(),
});
const conn = getDummyConnection();
const em = new EntityManager([posts], conn.dummyConnection);
await em.schema().sync({ force: true });
const emgr = em.emgr as EventManager<any>;
emgr.onEvent(
// @ts-ignore
EntityManager.Events.MutatorUpdateBefore,
async (event) => {
return {
...event.params.data,
views: event.params.data.views + 1,
};
},
"sync",
);
const mutator = em.mutator("posts");
const created = await mutator.insertOne({ title: "test", views: 1 });
const result = await mutator.updateOne(created.data.id, { views: 2 });
expect(result.data).toEqual({
id: 1,
title: "test",
views: 3,
});
});
});
+8 -9
View File
@@ -1,14 +1,13 @@
import { afterAll, describe, expect, test } from "bun:test";
// @ts-ignore
import { Perf } from "@bknd/core/utils";
import type { Kysely, Transaction } from "kysely";
import { Perf } from "../../../src/core/utils";
import {
Entity,
EntityManager,
LibsqlConnection,
ManyToOneRelation,
RepositoryEvents,
TextField
TextField,
} from "../../../src/data";
import { getDummyConnection } from "../helper";
@@ -24,7 +23,7 @@ async function sleep(ms: number) {
}
describe("[Repository]", async () => {
test("bulk", async () => {
test.skip("bulk", async () => {
//const connection = dummyConnection;
//const connection = getLocalLibsqlConnection();
const credentials = null as any; // @todo: determine what to do here
@@ -71,13 +70,13 @@ describe("[Repository]", async () => {
const q1 = selectQ(conn).compile();
const res = await client.execute({
sql: q1.sql,
args: q1.parameters as any
args: q1.parameters as any,
});
const q2 = countQ(conn).compile();
const count = await client.execute({
sql: q2.sql,
args: q2.parameters as any
args: q2.parameters as any,
});
return [res, count];
}
@@ -94,7 +93,7 @@ describe("[Repository]", async () => {
const exec = async (
name: string,
fn: (em: EntityManager<any>) => Promise<any>,
em: EntityManager<any>
em: EntityManager<any>,
) => {
const res = await Perf.execute(() => fn(em), times);
await sleep(1000);
@@ -103,7 +102,7 @@ describe("[Repository]", async () => {
total: res.total.toFixed(2),
avg: (res.total / times).toFixed(2),
first: res.marks[0].time.toFixed(2),
last: res.marks[res.marks.length - 1].time.toFixed(2)
last: res.marks[res.marks.length - 1].time.toFixed(2),
};
console.log(info.name, info, res.marks);
return info;
@@ -184,7 +183,7 @@ describe("[data] Repository (Events)", async () => {
const items = new Entity("items", [new TextField("label")]);
const categories = new Entity("categories", [new TextField("label")]);
const em = new EntityManager([items, categories], dummyConnection, [
new ManyToOneRelation(categories, items)
new ManyToOneRelation(categories, items),
]);
await em.schema().sync({ force: true });
const events = new Map<string, any>();
+33 -33
View File
@@ -15,7 +15,7 @@ describe("SchemaManager tests", async () => {
const em = new EntityManager([entity], dummyConnection, [], [index]);
const schema = new SchemaManager(em);
const introspection = schema.getIntrospectionFromEntity(em.entities[0]);
const introspection = schema.getIntrospectionFromEntity(em.entities[0]!);
expect(introspection).toEqual({
name: "test",
isView: false,
@@ -26,7 +26,7 @@ describe("SchemaManager tests", async () => {
isNullable: true,
isAutoIncrementing: true,
hasDefaultValue: false,
comment: undefined
comment: undefined,
},
{
name: "username",
@@ -34,7 +34,7 @@ describe("SchemaManager tests", async () => {
isNullable: true,
isAutoIncrementing: false,
hasDefaultValue: false,
comment: undefined
comment: undefined,
},
{
name: "email",
@@ -42,7 +42,7 @@ describe("SchemaManager tests", async () => {
isNullable: true,
isAutoIncrementing: false,
hasDefaultValue: false,
comment: undefined
comment: undefined,
},
{
name: "bio",
@@ -50,8 +50,8 @@ describe("SchemaManager tests", async () => {
isNullable: true,
isAutoIncrementing: false,
hasDefaultValue: false,
comment: undefined
}
comment: undefined,
},
],
indices: [
{
@@ -61,11 +61,11 @@ describe("SchemaManager tests", async () => {
columns: [
{
name: "email",
order: 0
}
]
}
]
order: 0,
},
],
},
],
});
});
@@ -77,10 +77,10 @@ describe("SchemaManager tests", async () => {
new Entity(table, [
new TextField("username"),
new TextField("email"),
new TextField("bio")
])
new TextField("bio"),
]),
],
dummyConnection
dummyConnection,
);
const kysely = em.connection.kysely;
@@ -101,15 +101,15 @@ describe("SchemaManager tests", async () => {
name: table,
isNew: false,
columns: { add: ["bio"], drop: [], change: [] },
indices: { add: [], drop: [index] }
}
indices: { add: [], drop: [index] },
},
]);
// now sync
await schema.sync({ force: true, drop: true });
const diffAfter = await schema.getDiff();
console.log("diffAfter", diffAfter);
//console.log("diffAfter", diffAfter);
expect(diffAfter.length).toBe(0);
await kysely.schema.dropTable(table).execute();
@@ -119,7 +119,7 @@ describe("SchemaManager tests", async () => {
const table = "drop_column";
const em = new EntityManager(
[new Entity(table, [new TextField("username")])],
dummyConnection
dummyConnection,
);
const kysely = em.connection.kysely;
@@ -141,10 +141,10 @@ describe("SchemaManager tests", async () => {
columns: {
add: [],
drop: ["email"],
change: []
change: [],
},
indices: { add: [], drop: [] }
}
indices: { add: [], drop: [] },
},
]);
// now sync
@@ -165,15 +165,15 @@ describe("SchemaManager tests", async () => {
new Entity(usersTable, [
new TextField("username"),
new TextField("email"),
new TextField("bio")
new TextField("bio"),
]),
new Entity(postsTable, [
new TextField("title"),
new TextField("content"),
new TextField("created_at")
])
new TextField("created_at"),
]),
],
dummyConnection
dummyConnection,
);
const kysely = em.connection.kysely;
@@ -192,7 +192,7 @@ describe("SchemaManager tests", async () => {
name: usersTable,
isNew: false,
columns: { add: ["bio"], drop: [], change: [] },
indices: { add: [], drop: [] }
indices: { add: [], drop: [] },
},
{
name: postsTable,
@@ -200,10 +200,10 @@ describe("SchemaManager tests", async () => {
columns: {
add: ["id", "title", "content", "created_at"],
drop: [],
change: []
change: [],
},
indices: { add: [], drop: [] }
}
indices: { add: [], drop: [] },
},
]);
// now sync
@@ -228,8 +228,8 @@ describe("SchemaManager tests", async () => {
name: entity.name,
isNew: true,
columns: { add: ["id", "email"], drop: [], change: [] },
indices: { add: [index.name!], drop: [] }
}
indices: { add: [index.name!], drop: [] },
},
]);
// sync and then check again
@@ -256,8 +256,8 @@ describe("SchemaManager tests", async () => {
name: entity.name,
isNew: false,
columns: { add: [], drop: [], change: [] },
indices: { add: [index.name!], drop: [] }
}
indices: { add: [index.name!], drop: [] },
},
]);
// sync and then check again
+275 -34
View File
@@ -1,4 +1,4 @@
import { afterAll, describe, expect, test } from "bun:test";
import { describe, expect, test } from "bun:test";
import {
Entity,
EntityManager,
@@ -6,21 +6,58 @@ import {
ManyToOneRelation,
PolymorphicRelation,
TextField,
WithBuilder
WithBuilder,
} from "../../../src/data";
import * as proto from "../../../src/data/prototype";
import { schemaToEm } from "../../helper";
import { getDummyConnection } from "../helper";
const { dummyConnection, afterAllCleanup } = getDummyConnection();
afterAll(afterAllCleanup);
const { dummyConnection } = getDummyConnection();
describe("[data] WithBuilder", async () => {
test("validate withs", async () => {
const schema = proto.em(
{
posts: proto.entity("posts", {}),
users: proto.entity("users", {}),
media: proto.entity("media", {}),
},
({ relation }, { posts, users, media }) => {
relation(posts).manyToOne(users);
relation(users).polyToOne(media, { mappedBy: "avatar" });
},
);
const em = schemaToEm(schema);
expect(WithBuilder.validateWiths(em, "posts", undefined as any)).toBe(0);
expect(WithBuilder.validateWiths(em, "posts", {})).toBe(0);
expect(WithBuilder.validateWiths(em, "posts", { users: {} })).toBe(1);
expect(
WithBuilder.validateWiths(em, "posts", {
users: {
with: { avatar: {} },
},
}),
).toBe(2);
expect(() => WithBuilder.validateWiths(em, "posts", { author: {} })).toThrow();
expect(() =>
WithBuilder.validateWiths(em, "posts", {
users: {
with: { glibberish: {} },
},
}),
).toThrow();
});
test("missing relation", async () => {
const users = new Entity("users", [new TextField("username")]);
const em = new EntityManager([users], dummyConnection);
expect(() =>
WithBuilder.addClause(em, em.connection.kysely.selectFrom("users"), users, ["posts"])
).toThrow('Relation "posts" not found');
WithBuilder.addClause(em, em.connection.kysely.selectFrom("users"), users, {
posts: {},
}),
).toThrow('Relation "users<>posts" not found');
});
test("addClause: ManyToOne", async () => {
@@ -29,36 +66,39 @@ describe("[data] WithBuilder", async () => {
const relations = [new ManyToOneRelation(posts, users, { mappedBy: "author" })];
const em = new EntityManager([users, posts], dummyConnection, relations);
const qb = WithBuilder.addClause(em, em.connection.kysely.selectFrom("users"), users, [
"posts"
]);
const qb = WithBuilder.addClause(em, em.connection.kysely.selectFrom("users"), users, {
posts: {},
});
const res = qb.compile();
expect(res.sql).toBe(
'select (select coalesce(json_group_array(json_object(\'id\', "agg"."id", \'content\', "agg"."content", \'author_id\', "agg"."author_id")), \'[]\') from (select "posts"."id" as "id", "posts"."content" as "content", "posts"."author_id" as "author_id" from "posts" where "users"."id" = "posts"."author_id" limit ?) as agg) as "posts" from "users"'
'select (select coalesce(json_group_array(json_object(\'id\', "agg"."id", \'content\', "agg"."content", \'author_id\', "agg"."author_id")), \'[]\') from (select "posts"."id" as "id", "posts"."content" as "content", "posts"."author_id" as "author_id" from "posts" as "posts" where "posts"."author_id" = "users"."id" order by "posts"."id" asc limit ? offset ?) as agg) as "posts" from "users"',
);
expect(res.parameters).toEqual([5]);
expect(res.parameters).toEqual([10, 0]);
const qb2 = WithBuilder.addClause(
em,
em.connection.kysely.selectFrom("posts"),
posts, // @todo: try with "users", it gives output!
["author"]
{
author: {},
},
);
const res2 = qb2.compile();
expect(res2.sql).toBe(
'select (select json_object(\'id\', "obj"."id", \'username\', "obj"."username") from (select "users"."id" as "id", "users"."username" as "username" from "users" where "posts"."author_id" = "users"."id" limit ?) as obj) as "author" from "posts"'
'select (select json_object(\'id\', "obj"."id", \'username\', "obj"."username") from (select "users"."id" as "id", "users"."username" as "username" from "users" as "author" where "author"."id" = "posts"."author_id" order by "users"."id" asc limit ? offset ?) as obj) as "author" from "posts"',
);
expect(res2.parameters).toEqual([1]);
expect(res2.parameters).toEqual([1, 0]);
});
test("test with empty join", async () => {
const em = new EntityManager([], dummyConnection);
const qb = { qb: 1 } as any;
expect(WithBuilder.addClause(null as any, qb, null as any, [])).toBe(qb);
expect(WithBuilder.addClause(em, qb, null as any, {})).toBe(qb);
});
test("test manytomany", async () => {
@@ -83,13 +123,13 @@ describe("[data] WithBuilder", async () => {
.values([
{ posts_id: 1, categories_id: 1 },
{ posts_id: 2, categories_id: 2 },
{ posts_id: 1, categories_id: 2 }
{ posts_id: 1, categories_id: 2 },
])
.execute();
//console.log((await em.repository().findMany("posts_categories")).result);
const res = await em.repository(posts).findMany({ with: ["categories"] });
const res = await em.repository(posts).findMany({ with: { categories: {} } });
expect(res.data).toEqual([
{
@@ -97,17 +137,17 @@ describe("[data] WithBuilder", async () => {
title: "fashion post",
categories: [
{ id: 1, label: "fashion" },
{ id: 2, label: "beauty" }
]
{ id: 2, label: "beauty" },
],
},
{
id: 2,
title: "beauty post",
categories: [{ id: 2, label: "beauty" }]
}
categories: [{ id: 2, label: "beauty" }],
},
]);
const res2 = await em.repository(categories).findMany({ with: ["posts"] });
const res2 = await em.repository(categories).findMany({ with: { posts: {} } });
//console.log(res2.sql, res2.data);
@@ -115,21 +155,21 @@ describe("[data] WithBuilder", async () => {
{
id: 1,
label: "fashion",
posts: [{ id: 1, title: "fashion post" }]
posts: [{ id: 1, title: "fashion post" }],
},
{
id: 2,
label: "beauty",
posts: [
{ id: 1, title: "fashion post" },
{ id: 2, title: "beauty post" },
{ id: 1, title: "fashion post" }
]
],
},
{
id: 3,
label: "tech",
posts: []
}
posts: [],
},
]);
});
@@ -140,7 +180,7 @@ describe("[data] WithBuilder", async () => {
const entities = [media, categories];
const single = new PolymorphicRelation(categories, media, {
mappedBy: "single",
targetCardinality: 1
targetCardinality: 1,
});
const multiple = new PolymorphicRelation(categories, media, { mappedBy: "multiple" });
@@ -150,25 +190,25 @@ describe("[data] WithBuilder", async () => {
em,
em.connection.kysely.selectFrom("categories"),
categories,
["single"]
{ single: {} },
);
const res = qb.compile();
expect(res.sql).toBe(
'select (select json_object(\'id\', "obj"."id", \'path\', "obj"."path") from (select "media"."id" as "id", "media"."path" as "path" from "media" where "media"."reference" = ? and "categories"."id" = "media"."entity_id" limit ?) as obj) as "single" from "categories"'
'select (select json_object(\'id\', "obj"."id", \'path\', "obj"."path") from (select "media"."id" as "id", "media"."path" as "path" from "media" where "media"."reference" = ? and "categories"."id" = "media"."entity_id" order by "media"."id" asc limit ? offset ?) as obj) as "single" from "categories"',
);
expect(res.parameters).toEqual(["categories.single", 1]);
expect(res.parameters).toEqual(["categories.single", 1, 0]);
const qb2 = WithBuilder.addClause(
em,
em.connection.kysely.selectFrom("categories"),
categories,
["multiple"]
{ multiple: {} },
);
const res2 = qb2.compile();
expect(res2.sql).toBe(
'select (select coalesce(json_group_array(json_object(\'id\', "agg"."id", \'path\', "agg"."path")), \'[]\') from (select "media"."id" as "id", "media"."path" as "path" from "media" where "media"."reference" = ? and "categories"."id" = "media"."entity_id" limit ?) as agg) as "multiple" from "categories"'
'select (select coalesce(json_group_array(json_object(\'id\', "agg"."id", \'path\', "agg"."path")), \'[]\') from (select "media"."id" as "id", "media"."path" as "path" from "media" where "media"."reference" = ? and "categories"."id" = "media"."entity_id" order by "media"."id" asc limit ? offset ?) as agg) as "multiple" from "categories"',
);
expect(res2.parameters).toEqual(["categories.multiple", 5]);
expect(res2.parameters).toEqual(["categories.multiple", 10, 0]);
});
/*test("test manytoone", async () => {
@@ -192,4 +232,205 @@ describe("[data] WithBuilder", async () => {
const res = await em.repository().findMany("posts", { join: ["author"] });
console.log(res.sql, res.parameters, res.result);
});*/
describe("recursive", () => {
test("compiles with singles", async () => {
const schema = proto.em(
{
posts: proto.entity("posts", {}),
users: proto.entity("users", {
username: proto.text(),
}),
media: proto.entity("media", {
path: proto.text(),
}),
},
({ relation }, { posts, users, media }) => {
relation(posts).manyToOne(users);
relation(users).polyToOne(media, { mappedBy: "avatar" });
},
);
const em = schemaToEm(schema);
const qb = WithBuilder.addClause(
em,
em.connection.kysely.selectFrom("posts"),
schema.entities.posts,
{
users: {
limit: 5, // ignored
select: ["id", "username"],
sort: { by: "username", dir: "asc" },
with: {
avatar: {
select: ["id", "path"],
limit: 2, // ignored
},
},
},
},
);
//prettyPrintQb(qb);
expect(qb.compile().sql).toBe(
'select (select json_object(\'id\', "obj"."id", \'username\', "obj"."username", \'avatar\', "obj"."avatar") from (select "users"."id" as "id", "users"."username" as "username", (select json_object(\'id\', "obj"."id", \'path\', "obj"."path") from (select "media"."id" as "id", "media"."path" as "path" from "media" where "media"."reference" = ? and "users"."id" = "media"."entity_id" order by "media"."id" asc limit ? offset ?) as obj) as "avatar" from "users" as "users" where "users"."id" = "posts"."users_id" order by "users"."username" asc limit ? offset ?) as obj) as "users" from "posts"',
);
expect(qb.compile().parameters).toEqual(["users.avatar", 1, 0, 1, 0]);
});
test("compiles with many", async () => {
const schema = proto.em(
{
posts: proto.entity("posts", {}),
comments: proto.entity("comments", {}),
users: proto.entity("users", {
username: proto.text(),
}),
media: proto.entity("media", {
path: proto.text(),
}),
},
({ relation }, { posts, comments, users, media }) => {
relation(posts).manyToOne(users).polyToOne(media, { mappedBy: "images" });
relation(users).polyToOne(media, { mappedBy: "avatar" });
relation(comments).manyToOne(posts).manyToOne(users);
},
);
const em = schemaToEm(schema);
const qb = WithBuilder.addClause(
em,
em.connection.kysely.selectFrom("posts"),
schema.entities.posts,
{
comments: {
limit: 12,
with: {
users: {
select: ["username"],
},
},
},
},
);
expect(qb.compile().sql).toBe(
'select (select coalesce(json_group_array(json_object(\'id\', "agg"."id", \'posts_id\', "agg"."posts_id", \'users_id\', "agg"."users_id", \'users\', "agg"."users")), \'[]\') from (select "comments"."id" as "id", "comments"."posts_id" as "posts_id", "comments"."users_id" as "users_id", (select json_object(\'username\', "obj"."username") from (select "users"."username" as "username" from "users" as "users" where "users"."id" = "comments"."users_id" order by "users"."id" asc limit ? offset ?) as obj) as "users" from "comments" as "comments" where "comments"."posts_id" = "posts"."id" order by "comments"."id" asc limit ? offset ?) as agg) as "comments" from "posts"',
);
expect(qb.compile().parameters).toEqual([1, 0, 12, 0]);
});
test("returns correct result", async () => {
const schema = proto.em(
{
posts: proto.entity("posts", {
title: proto.text(),
}),
comments: proto.entity("comments", {
content: proto.text(),
}),
users: proto.entity("users", {
username: proto.text(),
}),
media: proto.entity("media", {
path: proto.text(),
}),
},
({ relation }, { posts, comments, users, media }) => {
relation(posts).manyToOne(users).polyToOne(media, { mappedBy: "images" });
relation(users).polyToOne(media, { mappedBy: "avatar" });
relation(comments).manyToOne(posts).manyToOne(users);
},
);
const em = schemaToEm(schema);
await em.schema().sync({ force: true });
// add data
await em.mutator("users").insertMany([{ username: "user1" }, { username: "user2" }]);
await em.mutator("posts").insertMany([
{ title: "post1", users_id: 1 },
{ title: "post2", users_id: 1 },
{ title: "post3", users_id: 2 },
]);
await em.mutator("comments").insertMany([
{ content: "comment1", posts_id: 1, users_id: 1 },
{ content: "comment1-1", posts_id: 1, users_id: 1 },
{ content: "comment2", posts_id: 1, users_id: 2 },
{ content: "comment3", posts_id: 2, users_id: 1 },
{ content: "comment4", posts_id: 2, users_id: 2 },
{ content: "comment5", posts_id: 3, users_id: 1 },
{ content: "comment6", posts_id: 3, users_id: 2 },
]);
const result = await em.repo("posts").findMany({
select: ["title"],
with: {
comments: {
limit: 2,
select: ["content"],
with: {
users: {
select: ["username"],
},
},
},
},
});
expect(result.data).toEqual([
{
title: "post1",
comments: [
{
content: "comment1",
users: {
username: "user1",
},
},
{
content: "comment1-1",
users: {
username: "user1",
},
},
],
},
{
title: "post2",
comments: [
{
content: "comment3",
users: {
username: "user1",
},
},
{
content: "comment4",
users: {
username: "user2",
},
},
],
},
{
title: "post3",
comments: [
{
content: "comment5",
users: {
username: "user1",
},
},
{
content: "comment6",
users: {
username: "user2",
},
},
],
},
]);
//console.log(_jsonp(result.data));
});
});
});
@@ -22,10 +22,10 @@ describe("Connection", async () => {
columns: [
{
name: "name",
order: 0
}
]
}
order: 0,
},
],
},
]);
});
@@ -54,14 +54,14 @@ describe("Connection", async () => {
columns: [
{
name: "name",
order: 0
order: 0,
},
{
name: "desc",
order: 1
}
]
}
order: 1,
},
],
},
]);
});
@@ -83,10 +83,10 @@ describe("Connection", async () => {
columns: [
{
name: "name",
order: 0
}
]
}
order: 0,
},
],
},
]);
});
});
@@ -0,0 +1,107 @@
import { describe, expect, test } from "bun:test";
import { SqliteIntrospector } from "data/connection";
import { getDummyDatabase } from "../../helper";
import { Kysely, SqliteDialect } from "kysely";
function create() {
const database = getDummyDatabase().dummyDb;
return new Kysely({
dialect: new SqliteDialect({ database }),
});
}
function createLibsql() {
const database = getDummyDatabase().dummyDb;
return new Kysely({
dialect: new SqliteDialect({ database }),
});
}
describe("SqliteIntrospector", () => {
test("asdf", async () => {
const kysely = create();
await kysely.schema
.createTable("test")
.addColumn("id", "integer", (col) => col.primaryKey().autoIncrement().notNull())
.addColumn("string", "text", (col) => col.notNull())
.addColumn("number", "integer")
.execute();
await kysely.schema
.createIndex("idx_test_string")
.on("test")
.columns(["string"])
.unique()
.execute();
await kysely.schema
.createTable("test2")
.addColumn("id", "integer", (col) => col.primaryKey().autoIncrement().notNull())
.addColumn("number", "integer")
.execute();
await kysely.schema.createIndex("idx_test2_number").on("test2").columns(["number"]).execute();
const introspector = new SqliteIntrospector(kysely, {});
const result = await introspector.getTables();
//console.log(_jsonp(result));
expect(result).toEqual([
{
name: "test",
isView: false,
columns: [
{
name: "id",
dataType: "INTEGER",
isNullable: false,
isAutoIncrementing: true,
hasDefaultValue: false,
comment: undefined,
},
{
name: "string",
dataType: "TEXT",
isNullable: false,
isAutoIncrementing: false,
hasDefaultValue: false,
comment: undefined,
},
{
comment: undefined,
dataType: "INTEGER",
hasDefaultValue: false,
isAutoIncrementing: false,
isNullable: true,
name: "number",
},
],
},
{
name: "test2",
isView: false,
columns: [
{
name: "id",
dataType: "INTEGER",
isNullable: false,
isAutoIncrementing: true,
hasDefaultValue: false,
comment: undefined,
},
{
name: "number",
dataType: "INTEGER",
isNullable: true,
isAutoIncrementing: false,
hasDefaultValue: false,
comment: undefined,
},
],
},
]);
});
});
@@ -10,16 +10,12 @@ describe("[data] EnumField", async () => {
runBaseFieldTests(
EnumField,
{ defaultValue: "a", schemaType: "text" },
{ options: options(["a", "b", "c"]) }
{ options: options(["a", "b", "c"]) },
);
test("yields if no options", async () => {
expect(() => new EnumField("test", { options: options([]) })).toThrow();
});
test("yields if default value is not a valid option", async () => {
expect(
() => new EnumField("test", { options: options(["a", "b"]), default_value: "c" })
() => new EnumField("test", { options: options(["a", "b"]), default_value: "c" }),
).toThrow();
});
@@ -35,7 +31,7 @@ describe("[data] EnumField", async () => {
const field = new EnumField("test", {
options: options(["a", "b", "c"]),
default_value: "a",
required: true
required: true,
});
expect(field.transformRetrieve(null)).toBe("a");
+18 -14
View File
@@ -1,45 +1,49 @@
import { describe, expect, test } from "bun:test";
import { Default, parse, stripMark } from "../../../../src/core/utils";
import { Field, type SchemaResponse, TextField, baseFieldConfigSchema } from "../../../../src/data";
import { runBaseFieldTests, transformPersist } from "./inc";
import { Default, stripMark } from "../../../../src/core/utils";
import { baseFieldConfigSchema, Field } from "../../../../src/data/fields/Field";
import { runBaseFieldTests } from "./inc";
describe("[data] Field", async () => {
class FieldSpec extends Field {
schema(): SchemaResponse {
return this.useSchemaHelper("text");
}
getSchema() {
return baseFieldConfigSchema;
}
}
test("fieldSpec", () => {
expect(new FieldSpec("test").schema()).toEqual({
name: "test",
type: "text",
nullable: true, // always true
dflt: undefined, // never using default value
});
});
runBaseFieldTests(FieldSpec, { defaultValue: "test", schemaType: "text" });
test.only("default config", async () => {
const field = new FieldSpec("test");
test("default config", async () => {
const config = Default(baseFieldConfigSchema, {});
expect(stripMark(new FieldSpec("test").config)).toEqual(config);
console.log("config", new TextField("test", { required: true }).toJSON());
expect(stripMark(new FieldSpec("test").config)).toEqual(config as any);
});
test("transformPersist (specific)", async () => {
const required = new FieldSpec("test", { required: true });
const requiredDefault = new FieldSpec("test", {
required: true,
default_value: "test"
default_value: "test",
});
expect(required.transformPersist(null, undefined as any, undefined as any)).rejects.toThrow();
expect(
required.transformPersist(undefined, undefined as any, undefined as any)
required.transformPersist(undefined, undefined as any, undefined as any),
).rejects.toThrow();
// works because it has a default value
expect(
requiredDefault.transformPersist(null, undefined as any, undefined as any)
requiredDefault.transformPersist(null, undefined as any, undefined as any),
).resolves.toBeDefined();
expect(
requiredDefault.transformPersist(undefined, undefined as any, undefined as any)
requiredDefault.transformPersist(undefined, undefined as any, undefined as any),
).resolves.toBeDefined();
});
});
@@ -5,7 +5,7 @@ import {
EntityIndex,
type EntityManager,
Field,
type SchemaResponse
type SchemaResponse,
} from "../../../../src/data";
class TestField extends Field {
@@ -7,7 +7,7 @@ describe("[data] JsonField", async () => {
runBaseFieldTests(JsonField, {
defaultValue: { a: 1 },
sampleValues: ["string", { test: 1 }, 1],
schemaType: "text"
schemaType: "text",
});
test("transformPersist (no config)", async () => {
@@ -32,7 +32,7 @@ describe("[data] JsonField", async () => {
});
test("getValue", async () => {
expect(field.getValue({ test: 1 }, "form")).toBe('{"test":1}');
expect(field.getValue({ test: 1 }, "form")).toBe('{\n "test": 1\n}');
expect(field.getValue("string", "form")).toBe('"string"');
expect(field.getValue(1, "form")).toBe("1");
@@ -10,7 +10,12 @@ describe("[data] PrimaryField", async () => {
test("schema", () => {
expect(field.name).toBe("primary");
expect(field.schema()).toEqual(["primary", "integer", expect.any(Function)]);
expect(field.schema()).toEqual({
name: "primary",
type: "integer" as const,
nullable: false,
primary: true,
});
});
test("hasDefault", async () => {
+22 -19
View File
@@ -18,7 +18,7 @@ export function transformPersist(field: Field, value: any, context?: TActionCont
export function runBaseFieldTests(
fieldClass: ConstructableField,
config: FieldTestConfig,
_requiredConfig: any = {}
_requiredConfig: any = {},
) {
const noConfigField = new fieldClass("no_config", _requiredConfig);
const fillable = new fieldClass("fillable", { ..._requiredConfig, fillable: true });
@@ -29,16 +29,19 @@ export function runBaseFieldTests(
..._requiredConfig,
fillable: true,
required: true,
default_value: config.defaultValue
default_value: config.defaultValue,
});
test("schema", () => {
expect(noConfigField.name).toBe("no_config");
expect(noConfigField.schema(null as any)).toEqual([
"no_config",
config.schemaType,
expect.any(Function)
]);
const { type, name, nullable, dflt } = noConfigField.schema()!;
expect({ type, name, nullable, dflt }).toEqual({
type: config.schemaType as any,
name: "no_config",
nullable: true, // always true
dflt: undefined, // never using default value
});
});
test("hasDefault", async () => {
@@ -96,7 +99,7 @@ export function runBaseFieldTests(
//order: 1,
fillable: true,
required: false,
hidden: false
hidden: false,
//virtual: false,
//default_value: undefined
};
@@ -105,20 +108,20 @@ export function runBaseFieldTests(
const json = field.toJSON();
return {
...json,
config: omit(json.config, ["html"])
config: omit(json.config, ["html"]),
};
}
expect(fieldJson(noConfigField)).toEqual({
//name: "no_config",
type: noConfigField.type,
config: _config
config: _config,
});
expect(fieldJson(fillable)).toEqual({
//name: "fillable",
type: noConfigField.type,
config: _config
config: _config,
});
expect(fieldJson(required)).toEqual({
@@ -126,8 +129,8 @@ export function runBaseFieldTests(
type: required.type,
config: {
..._config,
required: true
}
required: true,
},
});
expect(fieldJson(hidden)).toEqual({
@@ -135,8 +138,8 @@ export function runBaseFieldTests(
type: required.type,
config: {
..._config,
hidden: true
}
hidden: true,
},
});
expect(fieldJson(dflt)).toEqual({
@@ -144,8 +147,8 @@ export function runBaseFieldTests(
type: dflt.type,
config: {
..._config,
default_value: config.defaultValue
}
default_value: config.defaultValue,
},
});
expect(fieldJson(requiredAndDefault)).toEqual({
@@ -155,8 +158,8 @@ export function runBaseFieldTests(
..._config,
fillable: true,
required: true,
default_value: config.defaultValue
}
default_value: config.defaultValue,
},
});
});
}
@@ -4,7 +4,7 @@ import {
type BaseRelationConfig,
EntityRelation,
EntityRelationAnchor,
RelationTypes
RelationTypes,
} from "../../../../src/data/relations";
class TestEntityRelation extends EntityRelation {
@@ -12,7 +12,7 @@ class TestEntityRelation extends EntityRelation {
super(
new EntityRelationAnchor(new Entity("source"), "source"),
new EntityRelationAnchor(new Entity("target"), "target"),
config
config,
);
}
initialize(em: EntityManager<any>) {}
@@ -70,9 +70,9 @@ describe("[data] EntityRelation", async () => {
it("required", async () => {
const relation1 = new TestEntityRelation();
expect(relation1.config.required).toBe(false);
expect(relation1.required).toBe(false);
const relation2 = new TestEntityRelation({ required: true });
expect(relation2.config.required).toBe(true);
expect(relation2.required).toBe(true);
});
});
+12 -12
View File
@@ -30,14 +30,14 @@ beforeAll(() =>
method: init?.method ?? "GET",
// @ts-ignore
headers: Object.fromEntries(init?.headers?.entries() ?? []),
body: init?.body
body: init?.body,
};
return new Response(JSON.stringify({ todos: [1, 2], request }), {
status: 200,
headers: { "Content-Type": "application/json" }
headers: { "Content-Type": "application/json" },
});
})
}),
);
afterAll(unmockFetch);
@@ -46,7 +46,7 @@ describe("FetchTask", async () => {
const task = new FetchTask("Fetch Something", {
url: "https://jsonplaceholder.typicode.com/todos/1",
method: "GET",
headers: [{ key: "Content-Type", value: "application/json" }]
headers: [{ key: "Content-Type", value: "application/json" }],
});
const result = await task.run();
@@ -62,18 +62,18 @@ describe("FetchTask", async () => {
expect(
// // @ts-expect-error
() => new FetchTask("", { url: "https://jsonplaceholder.typicode.com", method: 1 })
() => new FetchTask("", { url: "https://jsonplaceholder.typicode.com", method: 1 }),
).toThrow();
expect(
new FetchTask("", {
url: "https://jsonplaceholder.typicode.com",
method: "invalid"
}).execute()
method: "invalid",
}).execute(),
).rejects.toThrow(/^Invalid method/);
expect(
() => new FetchTask("", { url: "https://jsonplaceholder.typicode.com", method: "GET" })
() => new FetchTask("", { url: "https://jsonplaceholder.typicode.com", method: "GET" }),
).toBeDefined();
expect(() => new FetchTask("", { url: "", method: "Invalid" })).toThrow();
@@ -85,17 +85,17 @@ describe("FetchTask", async () => {
method: "{{ flow.output.method }}",
headers: [
{ key: "Content-{{ flow.output.headerKey }}", value: "application/json" },
{ key: "Authorization", value: "Bearer {{ flow.output.apiKey }}" }
{ key: "Authorization", value: "Bearer {{ flow.output.apiKey }}" },
],
body: JSON.stringify({
email: "{{ flow.output.email }}"
})
email: "{{ flow.output.email }}",
}),
});
const inputs = {
headerKey: "Type",
apiKey: 123,
email: "what@else.com",
method: "PATCH"
method: "PATCH",
};
const flow = new Flow("", [task]);
+9 -9
View File
@@ -4,16 +4,16 @@ import { Flow, LogTask, RenderTask, SubFlowTask } from "../../src/flows";
describe("SubFlowTask", async () => {
test("Simple Subflow", async () => {
const subTask = new RenderTask("render", {
render: "subflow"
render: "subflow",
});
const subflow = new Flow("subflow", [subTask]);
const task = new LogTask("log");
const task2 = new SubFlowTask("sub", {
flow: subflow
flow: subflow,
});
const task3 = new RenderTask("render2", {
render: "Subflow output: {{ sub.output }}"
render: "Subflow output: {{ sub.output }}",
});
const flow = new Flow("test", [task, task2, task3], []);
@@ -30,7 +30,7 @@ describe("SubFlowTask", async () => {
test("Simple loop", async () => {
const subTask = new RenderTask("render", {
render: "run {{ flow.output }}"
render: "run {{ flow.output }}",
});
const subflow = new Flow("subflow", [subTask]);
@@ -38,10 +38,10 @@ describe("SubFlowTask", async () => {
const task2 = new SubFlowTask("sub", {
flow: subflow,
loop: true,
input: [1, 2, 3]
input: [1, 2, 3],
});
const task3 = new RenderTask("render2", {
render: `Subflow output: {{ sub.output | join: ", " }}`
render: `Subflow output: {{ sub.output | join: ", " }}`,
});
const flow = new Flow("test", [task, task2, task3], []);
@@ -61,7 +61,7 @@ describe("SubFlowTask", async () => {
test("Simple loop from flow input", async () => {
const subTask = new RenderTask("render", {
render: "run {{ flow.output }}"
render: "run {{ flow.output }}",
});
const subflow = new Flow("subflow", [subTask]);
@@ -70,10 +70,10 @@ describe("SubFlowTask", async () => {
const task2 = new SubFlowTask("sub", {
flow: subflow,
loop: true,
input: "{{ flow.output | json }}"
input: "{{ flow.output | json }}",
});
const task3 = new RenderTask("render2", {
render: `Subflow output: {{ sub.output | join: ", " }}`
render: `Subflow output: {{ sub.output | join: ", " }}`,
});
const flow = new Flow("test", [task, task2, task3], []);
+26 -26
View File
@@ -8,13 +8,13 @@ describe("Task", async () => {
const result = await Task.resolveParams(
Type.Object({ test: dynamic(Type.Number()) }),
{
test: "{{ some.path }}"
test: "{{ some.path }}",
},
{
some: {
path: 1
}
}
path: 1,
},
},
);
expect(result.test).toBe(1);
@@ -24,13 +24,13 @@ describe("Task", async () => {
const result = await Task.resolveParams(
Type.Object({ test: Type.String() }),
{
test: "{{ some.path }}"
test: "{{ some.path }}",
},
{
some: {
path: "1/1"
}
}
path: "1/1",
},
},
);
expect(result.test).toBe("1/1");
@@ -40,13 +40,13 @@ describe("Task", async () => {
const result = await Task.resolveParams(
Type.Object({ test: dynamic(Type.Object({ key: Type.String(), value: Type.String() })) }),
{
test: { key: "path", value: "{{ some.path }}" }
test: { key: "path", value: "{{ some.path }}" },
},
{
some: {
path: "1/1"
}
}
path: "1/1",
},
},
);
expect(result.test).toEqual({ key: "path", value: "1/1" });
@@ -55,17 +55,17 @@ describe("Task", async () => {
test("resolveParams: with json", async () => {
const result = await Task.resolveParams(
Type.Object({
test: dynamic(Type.Object({ key: Type.String(), value: Type.String() }))
test: dynamic(Type.Object({ key: Type.String(), value: Type.String() })),
}),
{
test: "{{ some | json }}"
test: "{{ some | json }}",
},
{
some: {
key: "path",
value: "1/1"
}
}
value: "1/1",
},
},
);
expect(result.test).toEqual({ key: "path", value: "1/1" });
@@ -74,11 +74,11 @@ describe("Task", async () => {
test("resolveParams: with array", async () => {
const result = await Task.resolveParams(
Type.Object({
test: dynamic(Type.Array(Type.String()))
test: dynamic(Type.Array(Type.String())),
}),
{
test: '{{ "1,2,3" | split: "," | json }}'
}
test: '{{ "1,2,3" | split: "," | json }}',
},
);
expect(result.test).toEqual(["1", "2", "3"]);
@@ -87,11 +87,11 @@ describe("Task", async () => {
test("resolveParams: boolean", async () => {
const result = await Task.resolveParams(
Type.Object({
test: dynamic(Type.Boolean())
test: dynamic(Type.Boolean()),
}),
{
test: "{{ true }}"
}
test: "{{ true }}",
},
);
expect(result.test).toEqual(true);
@@ -100,11 +100,11 @@ describe("Task", async () => {
test("resolveParams: float", async () => {
const result = await Task.resolveParams(
Type.Object({
test: dynamic(Type.Number(), Number.parseFloat)
test: dynamic(Type.Number(), Number.parseFloat),
}),
{
test: "{{ 3.14 }}"
}
test: "{{ 3.14 }}",
},
);
expect(result.test).toEqual(3.14);
+3 -3
View File
@@ -7,11 +7,11 @@ const first = getNamedTask(
//throw new Error("Error");
return {
inner: {
result: 2
}
result: 2,
},
};
},
1000
1000,
);
const second = getNamedTask("second (if match)");
const third = getNamedTask("third (if error)");
+3 -3
View File
@@ -11,7 +11,7 @@ class ExecTask extends Task {
constructor(
name: string,
params: any,
private fn: () => any
private fn: () => any,
) {
super(name, params);
}
@@ -54,8 +54,8 @@ export function getNamedTask(name: string, _func?: () => Promise<any>, delay?: n
return new ExecTask(
name,
{
delay
delay,
},
func
func,
);
}
+1 -1
View File
@@ -4,7 +4,7 @@ const first = new LogTask("First", { delay: 1000 });
const second = new LogTask("Second", { delay: 1000 });
const third = new LogTask("Long Third", { delay: 2500 });
const fourth = new FetchTask("Fetch Something", {
url: "https://jsonplaceholder.typicode.com/todos/1"
url: "https://jsonplaceholder.typicode.com/todos/1",
});
const fifth = new LogTask("Task 4", { delay: 500 }); // without connection
+11 -11
View File
@@ -23,10 +23,10 @@ class OutputParamTask extends Task<typeof OutputParamTask.schema> {
static override schema = Type.Object({
number: dynamic(
Type.Number({
title: "Output number"
title: "Output number",
}),
Number.parseInt
)
Number.parseInt,
),
});
async execute(inputs: InputsMap) {
@@ -75,7 +75,7 @@ describe("Flow task inputs", async () => {
test("output/input", async () => {
const task = new OutputParamTask("task1", { number: 111 });
const task2 = new OutputParamTask("task2", {
number: "{{ task1.output }}"
number: "{{ task1.output }}",
});
const flow = new Flow("test", [task, task2]);
@@ -94,10 +94,10 @@ describe("Flow task inputs", async () => {
test("input from flow", async () => {
const task = new OutputParamTask("task1", {
number: "{{flow.output.someFancyParam}}"
number: "{{flow.output.someFancyParam}}",
});
const task2 = new OutputParamTask("task2", {
number: "{{task1.output}}"
number: "{{task1.output}}",
});
const flow = new Flow("test", [task, task2]);
@@ -126,7 +126,7 @@ describe("Flow task inputs", async () => {
const emgr = new EventManager({ EventTriggerClass });
const task = new OutputParamTask("event", {
number: "{{flow.output.number}}"
number: "{{flow.output.number}}",
});
const flow = new Flow(
"test",
@@ -134,8 +134,8 @@ describe("Flow task inputs", async () => {
[],
new EventTrigger({
event: "test-event",
mode: "sync"
})
mode: "sync",
}),
);
flow.setRespondingTask(task);
flow.trigger.register(flow, emgr);
@@ -155,8 +155,8 @@ describe("Flow task inputs", async () => {
new HttpTrigger({
path: "/test",
method: "GET",
mode: "sync"
})
mode: "sync",
}),
);
flow.setRespondingTask(task);
+4 -4
View File
@@ -10,7 +10,7 @@ const flows = {
back,
fanout,
parallel,
simpleFetch
simpleFetch,
};
const arg = process.argv[2];
@@ -32,7 +32,7 @@ const colors = [
"#F78F1E", // Saffron
"#BD10E0", // Vivid Purple
"#50E3C2", // Turquoise
"#9013FE" // Grape
"#9013FE", // Grape
];
const colorsCache: Record<string, string> = {};
@@ -82,7 +82,7 @@ function TerminalFlow({ flow }: { flow: Flow }) {
}
return t;
})
}),
);
}
});
@@ -92,7 +92,7 @@ function TerminalFlow({ flow }: { flow: Flow }) {
console.log("done", response ? response : "(no response)");
console.log(
"Executed tasks:",
execution.logs.map((l) => l.task.name)
execution.logs.map((l) => l.task.name),
);
console.log("Executed count:", execution.logs.length);
});
+7 -7
View File
@@ -11,7 +11,7 @@ class ExecTask extends Task {
constructor(
name: string,
params: any,
private fn: () => any
private fn: () => any,
) {
super(name, params);
}
@@ -60,7 +60,7 @@ describe("Flow trigger", async () => {
"test",
[task],
[],
new EventTrigger({ event: "test-event", mode: "sync" })
new EventTrigger({ event: "test-event", mode: "sync" }),
);
flow.trigger.register(flow, emgr);
@@ -107,8 +107,8 @@ describe("Flow trigger", async () => {
new HttpTrigger({
path: "/test",
method: "GET",
mode: "sync"
})
mode: "sync",
}),
);
const hono = new Hono();
@@ -123,7 +123,7 @@ describe("Flow trigger", async () => {
test("http trigger with response", async () => {
const task = ExecTask.create("http", () => ({
called: true
called: true,
}));
const flow = new Flow(
"test",
@@ -132,8 +132,8 @@ describe("Flow trigger", async () => {
new HttpTrigger({
path: "/test",
method: "GET",
mode: "sync"
})
mode: "sync",
}),
);
flow.setRespondingTask(task);
+18 -16
View File
@@ -1,7 +1,7 @@
// eslint-disable-next-line import/no-unresolved
import { describe, expect, test } from "bun:test";
import { isEqual } from "lodash-es";
import { type Static, Type, _jsonp } from "../../src/core/utils";
import { type Static, Type, _jsonp, withDisabledConsole } from "../../src/core/utils";
import { Condition, ExecutionEvent, FetchTask, Flow, LogTask, Task } from "../../src/flows";
/*beforeAll(disableConsoleLog);
@@ -11,13 +11,13 @@ class ExecTask extends Task<typeof ExecTask.schema> {
type = "exec";
static override schema = Type.Object({
delay: Type.Number({ default: 10 })
delay: Type.Number({ default: 10 }),
});
constructor(
name: string,
params: Static<typeof ExecTask.schema>,
private func: () => Promise<any>
private func: () => Promise<any>,
) {
super(name, params);
}
@@ -36,12 +36,12 @@ function getTask(num: number = 0, delay: number = 5) {
return new ExecTask(
`Task ${num}`,
{
delay
delay,
},
async () => {
//console.log(`[DONE] Task: ${num}`);
return true;
}
},
);
//return new LogTask(`Log ${num}`, { delay });
}
@@ -56,9 +56,9 @@ function getNamedTask(name: string, _func?: () => Promise<any>, delay?: number)
return new ExecTask(
name,
{
delay: delay ?? 0
delay: delay ?? 0,
},
func
func,
);
}
@@ -228,11 +228,13 @@ describe("Flow tests", async () => {
back
.task(third)
.getOutTasks()
.map((t) => t.name)
.map((t) => t.name),
).toEqual(["second", "fourth"]);
const execution = back.createExecution();
expect(execution.start()).rejects.toThrow();
withDisabledConsole(async () => {
expect(execution.start()).rejects.toThrow();
});
});
test("Flow with back step: enough retries", async () => {
@@ -261,7 +263,7 @@ describe("Flow tests", async () => {
back
.task(third)
.getOutTasks()
.map((t) => t.name)
.map((t) => t.name),
).toEqual(["second", "fourth"]);
const execution = back.createExecution();
@@ -322,8 +324,8 @@ describe("Flow tests", async () => {
const first = getNamedTask("first", async () => {
return {
inner: {
result: 2
}
result: 2,
},
};
});
const second = getNamedTask("second");
@@ -359,7 +361,7 @@ describe("Flow tests", async () => {
"[event]",
event.isStart() ? "start" : "end",
event.task().name,
event.isStart() ? undefined : event.succeeded()
event.isStart() ? undefined : event.succeeded(),
);
}
});
@@ -387,7 +389,7 @@ describe("Flow tests", async () => {
const second = new LogTask("Task 1");
const third = new LogTask("Task 2", { delay: 50 });
const fourth = new FetchTask("Fetch Something", {
url: "https://jsonplaceholder.typicode.com/todos/1"
url: "https://jsonplaceholder.typicode.com/todos/1",
});
const fifth = new LogTask("Task 4"); // without connection
@@ -403,7 +405,7 @@ describe("Flow tests", async () => {
// @todo: fix
const deserialized = Flow.fromObject("", original, {
fetch: { cls: FetchTask },
log: { cls: LogTask }
log: { cls: LogTask },
} as any);
const diffdeep = getObjectDiff(original, deserialized.toJSON());
@@ -412,7 +414,7 @@ describe("Flow tests", async () => {
expect(flow.startTask.name).toEqual(deserialized.startTask.name);
expect(flow.respondingTask?.name).toEqual(
// @ts-ignore
deserialized.respondingTask?.name
deserialized.respondingTask?.name,
);
//console.log("--- creating original sequence");
+72 -6
View File
@@ -1,7 +1,12 @@
import { unlink } from "node:fs/promises";
import type { SqliteDatabase } from "kysely";
import type { SelectQueryBuilder, SqliteDatabase } from "kysely";
import Database from "libsql";
import { SqliteLocalConnection } from "../src/data";
import { format as sqlFormat } from "sql-formatter";
import { type Connection, EntityManager, SqliteLocalConnection } from "../src/data";
import type { em as protoEm } from "../src/data/prototype";
import { writeFile } from "node:fs/promises";
import { join } from "node:path";
import { slugify } from "core/utils/strings";
export function getDummyDatabase(memory: boolean = true): {
dummyDb: SqliteDatabase;
@@ -15,7 +20,7 @@ export function getDummyDatabase(memory: boolean = true): {
afterAllCleanup: async () => {
if (!memory) await unlink(DB_NAME);
return true;
}
},
};
}
@@ -25,7 +30,7 @@ export function getDummyConnection(memory: boolean = true) {
return {
dummyConnection,
afterAllCleanup
afterAllCleanup,
};
}
@@ -37,10 +42,10 @@ type ConsoleSeverity = "log" | "warn" | "error";
const _oldConsoles = {
log: console.log,
warn: console.warn,
error: console.error
error: console.error,
};
export function disableConsoleLog(severities: ConsoleSeverity[] = ["log"]) {
export function disableConsoleLog(severities: ConsoleSeverity[] = ["log", "warn"]) {
severities.forEach((severity) => {
console[severity] = () => null;
});
@@ -51,3 +56,64 @@ export function enableConsoleLog() {
console[severity as ConsoleSeverity] = fn;
});
}
export function compileQb(qb: SelectQueryBuilder<any, any, any>) {
const { sql, parameters } = qb.compile();
return { sql, parameters };
}
export function prettyPrintQb(qb: SelectQueryBuilder<any, any, any>) {
const { sql, parameters } = qb.compile();
console.log("$", sqlFormat(sql), "\n[params]", parameters);
}
export function schemaToEm(s: ReturnType<typeof protoEm>, conn?: Connection): EntityManager<any> {
const connection = conn ? conn : getDummyConnection().dummyConnection;
return new EntityManager(Object.values(s.entities), connection, s.relations, s.indices);
}
export const assetsPath = `${import.meta.dir}/_assets`;
export const assetsTmpPath = `${import.meta.dir}/_assets/tmp`;
export async function enableFetchLogging() {
const originalFetch = global.fetch;
global.fetch = async (input: RequestInfo | URL, init?: RequestInit) => {
const response = await originalFetch(input, init);
const url = input instanceof URL || typeof input === "string" ? input : input.url;
// Only clone if it's a supported content type
const contentType = response.headers.get("content-type") || "";
const isSupported =
contentType.includes("json") ||
contentType.includes("text") ||
contentType.includes("xml");
if (isSupported) {
const clonedResponse = response.clone();
let extension = "txt";
let body: string;
if (contentType.includes("json")) {
body = JSON.stringify(await clonedResponse.json(), null, 2);
extension = "json";
} else if (contentType.includes("xml")) {
body = await clonedResponse.text();
extension = "xml";
} else {
body = await clonedResponse.text();
}
const fileName = `${new Date().getTime()}_${init?.method ?? "GET"}_${slugify(String(url))}.${extension}`;
const filePath = join(assetsTmpPath, fileName);
await writeFile(filePath, body);
}
return response;
};
return () => {
global.fetch = originalFetch;
};
}
@@ -0,0 +1,250 @@
import { afterAll, afterEach, beforeAll, describe, expect, it } from "bun:test";
import { App, createApp } from "../../src";
import type { AuthResponse } from "../../src/auth";
import { auth } from "../../src/auth/middlewares";
import { randomString, secureRandomString, withDisabledConsole } from "../../src/core/utils";
import { disableConsoleLog, enableConsoleLog, getDummyConnection } from "../helper";
const { dummyConnection, afterAllCleanup } = getDummyConnection();
afterEach(afterAllCleanup);
beforeAll(disableConsoleLog);
afterAll(enableConsoleLog);
const roles = {
sloppy: {
guest: {
permissions: [
"system.access.admin",
"system.schema.read",
"system.access.api",
"system.config.read",
"data.entity.read",
],
is_default: true,
},
admin: {
is_default: true,
implicit_allow: true,
},
},
strict: {
guest: {
permissions: ["system.access.api", "system.config.read", "data.entity.read"],
is_default: true,
},
admin: {
is_default: true,
implicit_allow: true,
},
},
};
const configs = {
auth: {
enabled: true,
entity_name: "users",
jwt: {
secret: secureRandomString(20),
issuer: randomString(10),
},
roles: roles.strict,
guard: {
enabled: true,
},
},
users: {
normal: {
email: "normal@bknd.io",
password: "12345678",
},
admin: {
email: "admin@bknd.io",
password: "12345678",
role: "admin",
},
},
};
function createAuthApp() {
const app = createApp({
connection: dummyConnection,
initialConfig: {
auth: configs.auth,
},
});
app.emgr.onEvent(
App.Events.AppFirstBoot,
async () => {
await app.createUser(configs.users.normal);
await app.createUser(configs.users.admin);
},
"sync",
);
return app;
}
function getCookie(r: Response, name: string) {
const cookies = r.headers.get("cookie") ?? r.headers.get("set-cookie");
if (!cookies) return;
const cookie = cookies.split(";").find((c) => c.trim().startsWith(name));
if (!cookie) return;
return cookie.split("=")[1];
}
const fns = <Mode extends "cookie" | "token" = "token">(app: App, mode?: Mode) => {
function headers(token?: string, additional?: Record<string, string>) {
if (mode === "cookie") {
return {
cookie: `auth=${token};`,
...additional,
};
}
return {
Authorization: token ? `Bearer ${token}` : "",
"Content-Type": "application/json",
...additional,
};
}
function body(obj?: Record<string, any>) {
if (mode === "cookie") {
const formData = new FormData();
for (const key in obj) {
formData.append(key, obj[key]);
}
return formData;
}
return JSON.stringify(obj);
}
return {
login: async (
user: any,
): Promise<{ res: Response; data: Mode extends "token" ? AuthResponse : string }> => {
const res = (await app.server.request("/api/auth/password/login", {
method: "POST",
headers: headers(),
body: body(user),
})) as Response;
const data = mode === "cookie" ? getCookie(res, "auth") : await res.json();
return { res, data };
},
me: async (token?: string): Promise<Pick<AuthResponse, "user">> => {
const res = (await app.server.request("/api/auth/me", {
method: "GET",
headers: headers(token),
})) as Response;
return await res.json();
},
};
};
describe("integration auth", () => {
it("should create users on boot", async () => {
const app = createAuthApp();
await app.build();
const { data: users } = await app.em.repository("users").findMany();
expect(users.length).toBe(2);
expect(users[0].email).toBe(configs.users.normal.email);
expect(users[1].email).toBe(configs.users.admin.email);
});
it("should log you in with API", async () => {
const app = createAuthApp();
await app.build();
const $fns = fns(app);
// login api
const { data } = await $fns.login(configs.users.normal);
const me = await $fns.me(data.token);
expect(data.user.email).toBe(me.user.email);
expect(me.user.email).toBe(configs.users.normal.email);
// expect no user with no token
expect(await $fns.me()).toEqual({ user: null as any });
// expect no user with invalid token
expect(await $fns.me("invalid")).toEqual({ user: null as any });
expect(await $fns.me()).toEqual({ user: null as any });
});
it("should log you in with form and cookie", async () => {
const app = createAuthApp();
await app.build();
const $fns = fns(app, "cookie");
const { res, data: token } = await $fns.login(configs.users.normal);
expect(token).toBeDefined();
expect(res.status).toBe(302); // because it redirects
// test cookie jwt interchangability
{
// expect token to not work as-is for api endpoints
expect(await fns(app).me(token)).toEqual({ user: null as any });
// hono adds an additional segment to cookies
const apified_token = token.split(".").slice(0, -1).join(".");
// now it should work
// @todo: maybe add a config to don't allow re-use?
expect((await fns(app).me(apified_token)).user.email).toBe(configs.users.normal.email);
}
// test cookie with me endpoint
{
const me = await $fns.me(token);
expect(me.user.email).toBe(configs.users.normal.email);
// check with invalid & empty
expect(await $fns.me("invalid")).toEqual({ user: null as any });
expect(await $fns.me()).toEqual({ user: null as any });
}
});
it("should check for permissions", async () => {
const app = createAuthApp();
await app.build();
await withDisabledConsole(async () => {
const res = await app.server.request("/api/system/schema");
expect(res.status).toBe(403);
});
});
it("context is exclusive", async () => {
const app = createAuthApp();
await app.build();
const $fns = fns(app);
app.server.get("/get", auth(), async (c) => {
return c.json({
user: c.get("auth").user ?? null,
});
});
app.server.get("/wait", auth(), async (c) => {
await new Promise((r) => setTimeout(r, 20));
return c.json({ ok: true });
});
const { data } = await $fns.login(configs.users.normal);
const me = await $fns.me(data.token);
expect(me.user.email).toBe(configs.users.normal.email);
app.server.request("/wait", {
headers: { Authorization: `Bearer ${data.token}` },
});
{
await new Promise((r) => setTimeout(r, 10));
const res = await app.server.request("/get");
const data = await res.json();
expect(data.user).toBe(null);
expect(await $fns.me()).toEqual({ user: null as any });
}
});
});
@@ -0,0 +1,24 @@
import { describe, expect, it } from "bun:test";
import { createApp } from "../../src";
import { Api } from "../../src/Api";
describe("integration config", () => {
it("should create an entity", async () => {
const app = createApp();
await app.build();
const api = new Api({
host: "http://localhost",
fetcher: app.server.request as typeof fetch,
});
// create entity
await api.system.addConfig("data", "entities.posts", {
name: "posts",
config: { sort_field: "id", sort_dir: "asc" },
fields: { id: { type: "primary", name: "id" }, asdf: { type: "text" } },
type: "regular",
});
expect(app.em.entities.map((e) => e.name)).toContain("posts");
});
});
+86 -45
View File
@@ -1,56 +1,97 @@
import { describe, test } from "bun:test";
import { Hono } from "hono";
import { Guard } from "../../src/auth";
import { EventManager } from "../../src/core/events";
import { EntityManager } from "../../src/data";
import { AppMedia } from "../../src/media/AppMedia";
import { MediaController } from "../../src/media/api/MediaController";
import { getDummyConnection } from "../helper";
/// <reference types="@types/bun" />
const { dummyConnection, afterAllCleanup } = getDummyConnection();
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
import { createApp, registries } from "../../src";
import { mergeObject, randomString } from "../../src/core/utils";
import type { TAppMediaConfig } from "../../src/media/media-schema";
import { StorageLocalAdapter } from "../../src/media/storage/adapters/StorageLocalAdapter";
import { assetsPath, assetsTmpPath, disableConsoleLog, enableConsoleLog } from "../helper";
/**
* R2
* value: ReadableStream | ArrayBuffer | ArrayBufferView | string | Blob | null,
* Node writefile
* data: string | NodeJS.ArrayBufferView | Iterable<string | NodeJS.ArrayBufferView> | AsyncIterable<string | NodeJS.ArrayBufferView> | Stream,
*/
const ALL_TESTS = !!process.env.ALL_TESTS;
describe.skipIf(ALL_TESTS)("MediaController", () => {
test("..", async () => {
const ctx: any = {
em: new EntityManager([], dummyConnection, []),
guard: new Guard(),
emgr: new EventManager(),
server: new Hono()
};
beforeAll(() => {
registries.media.register("local", StorageLocalAdapter);
});
const media = new AppMedia(
// @ts-ignore
{
enabled: true,
adapter: {
type: "s3",
config: {
access_key: process.env.R2_ACCESS_KEY as string,
secret_access_key: process.env.R2_SECRET_ACCESS_KEY as string,
url: process.env.R2_URL as string
}
}
},
ctx
);
await media.build();
const app = new MediaController(media).getController();
const path = `${assetsPath}/image.png`;
const file = Bun.file(`${import.meta.dir}/adapters/icon.png`);
console.log("file", file);
async function makeApp(mediaOverride: Partial<TAppMediaConfig> = {}) {
const app = createApp({
initialConfig: {
media: mergeObject(
{
enabled: true,
adapter: {
type: "local",
config: {
path: assetsTmpPath,
},
},
},
mediaOverride,
),
},
});
await app.build();
return app;
}
function makeName(ext: string) {
return randomString(10) + "." + ext;
}
beforeAll(disableConsoleLog);
afterAll(enableConsoleLog);
describe("MediaController", () => {
test.only("accepts direct", async () => {
const app = await makeApp();
const file = Bun.file(path);
const name = makeName("png");
const res = await app.server.request("/api/media/upload/" + name, {
method: "POST",
body: file,
});
const result = (await res.json()) as any;
console.log(result);
expect(result.name).toBe(name);
const destFile = Bun.file(assetsTmpPath + "/" + name);
expect(destFile.exists()).resolves.toBe(true);
await destFile.delete();
});
test("accepts form data", async () => {
const app = await makeApp();
const file = Bun.file(path);
const name = makeName("png");
const form = new FormData();
form.append("file", file);
await app.request("/upload/test.png", {
const res = await app.server.request("/api/media/upload/" + name, {
method: "POST",
body: file
body: form,
});
const result = (await res.json()) as any;
expect(result.name).toBe(name);
const destFile = Bun.file(assetsTmpPath + "/" + name);
expect(destFile.exists()).resolves.toBe(true);
await destFile.delete();
});
test("limits body", async () => {
const app = await makeApp({ storage: { body_max_size: 1 } });
const file = await Bun.file(path);
const name = makeName("png");
const res = await app.server.request("/api/media/upload/" + name, {
method: "POST",
body: file,
});
expect(res.status).toBe(413);
expect(await Bun.file(assetsTmpPath + "/" + name).exists()).toBe(false);
});
});
+1 -1
View File
@@ -60,7 +60,7 @@ describe("Storage", async () => {
test("uploads a file", async () => {
const {
meta: { type, size }
meta: { type, size },
} = await storage.uploadFile("hello", "world.txt");
expect({ type, size }).toEqual({ type: "text/plain", size: 0 });
});
@@ -14,7 +14,7 @@ test("what", async () => {
const mf = new Miniflare({
modules: true,
script: "export default { async fetch() { return new Response(null); } }",
r2Buckets: ["BUCKET"]
r2Buckets: ["BUCKET"],
});
const bucket = await mf.getR2Bucket("BUCKET");
@@ -3,22 +3,24 @@ import { randomString } from "../../../src/core/utils";
import { StorageCloudinaryAdapter } from "../../../src/media";
import { config } from "dotenv";
const dotenvOutput = config({ path: `${import.meta.dir}/../../.env` });
const dotenvOutput = config({ path: `${import.meta.dir}/../../../.env` });
const {
CLOUDINARY_CLOUD_NAME,
CLOUDINARY_API_KEY,
CLOUDINARY_API_SECRET,
CLOUDINARY_UPLOAD_PRESET
CLOUDINARY_UPLOAD_PRESET,
} = dotenvOutput.parsed!;
const ALL_TESTS = !!process.env.ALL_TESTS;
describe.skipIf(ALL_TESTS)("StorageCloudinaryAdapter", () => {
if (ALL_TESTS) return;
const adapter = new StorageCloudinaryAdapter({
cloud_name: CLOUDINARY_CLOUD_NAME as string,
api_key: CLOUDINARY_API_KEY as string,
api_secret: CLOUDINARY_API_SECRET as string,
upload_preset: CLOUDINARY_UPLOAD_PRESET as string
upload_preset: CLOUDINARY_UPLOAD_PRESET as string,
});
const file = Bun.file(`${import.meta.dir}/icon.png`);
@@ -1,13 +1,14 @@
import { describe, expect, test } from "bun:test";
import { randomString } from "../../../src/core/utils";
import { StorageLocalAdapter } from "../../../src/media/storage/adapters/StorageLocalAdapter";
import { assetsPath, assetsTmpPath } from "../../helper";
describe("StorageLocalAdapter", () => {
const adapter = new StorageLocalAdapter({
path: `${import.meta.dir}/local`
path: assetsTmpPath,
});
const file = Bun.file(`${import.meta.dir}/icon.png`);
const file = Bun.file(`${assetsPath}/image.png`);
const _filename = randomString(10);
const filename = `${_filename}.png`;
@@ -15,7 +16,7 @@ describe("StorageLocalAdapter", () => {
test("puts an object", async () => {
objects = (await adapter.listObjects()).length;
expect(await adapter.putObject(filename, await file.arrayBuffer())).toBeString();
expect(await adapter.putObject(filename, file as unknown as File)).toBeString();
});
test("lists objects", async () => {
@@ -35,7 +36,7 @@ describe("StorageLocalAdapter", () => {
test("gets object meta", async () => {
expect(await adapter.getObjectMeta(filename)).toEqual({
type: file.type, // image/png
size: file.size
size: file.size,
});
});
@@ -1,34 +1,47 @@
import { describe, expect, test } from "bun:test";
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
import { randomString } from "../../../src/core/utils";
import { StorageS3Adapter } from "../../../src/media";
import { config } from "dotenv";
const dotenvOutput = config({ path: `${import.meta.dir}/../../.env` });
//import { enableFetchLogging } from "../../helper";
const dotenvOutput = config({ path: `${import.meta.dir}/../../../.env` });
const { R2_ACCESS_KEY, R2_SECRET_ACCESS_KEY, R2_URL, AWS_ACCESS_KEY, AWS_SECRET_KEY, AWS_S3_URL } =
dotenvOutput.parsed!;
// @todo: mock r2/s3 responses for faster tests
const ALL_TESTS = process.env.ALL_TESTS;
const ALL_TESTS = !!process.env.ALL_TESTS;
console.log("ALL_TESTS?", ALL_TESTS);
/*
// @todo: preparation to mock s3 calls + replace fast-xml-parser
let cleanup: () => void;
beforeAll(async () => {
cleanup = await enableFetchLogging();
});
afterAll(() => {
cleanup();
}); */
describe.skipIf(ALL_TESTS)("StorageS3Adapter", async () => {
if (ALL_TESTS) return;
describe("Storage", async () => {
console.log("ALL_TESTS", process.env.ALL_TESTS);
const versions = [
[
"r2",
new StorageS3Adapter({
access_key: R2_ACCESS_KEY as string,
secret_access_key: R2_SECRET_ACCESS_KEY as string,
url: R2_URL as string
})
url: R2_URL as string,
}),
],
[
"s3",
new StorageS3Adapter({
access_key: AWS_ACCESS_KEY as string,
secret_access_key: AWS_SECRET_KEY as string,
url: AWS_S3_URL as string
})
]
url: AWS_S3_URL as string,
}),
],
] as const;
const _conf = {
@@ -39,8 +52,8 @@ describe("Storage", async () => {
"objectExists",
"getObject",
"deleteObject",
"getObjectMeta"
]
"getObjectMeta",
],
};
const file = Bun.file(`${import.meta.dir}/icon.png`);
@@ -55,7 +68,7 @@ describe("Storage", async () => {
// @todo: add mocked fetch for faster tests
describe.each(versions)("StorageS3Adapter for %s", async (name, adapter) => {
if (!_conf.adapters.includes(name)) {
if (!_conf.adapters.includes(name) || ALL_TESTS) {
console.log("Skipping", name);
return;
}
@@ -64,7 +77,7 @@ describe("Storage", async () => {
test.skipIf(disabled("putObject"))("puts an object", async () => {
objects = (await adapter.listObjects()).length;
expect(await adapter.putObject(filename, file)).toBeString();
expect(await adapter.putObject(filename, file as any)).toBeString();
});
test.skipIf(disabled("listObjects"))("lists objects", async () => {
@@ -84,7 +97,7 @@ describe("Storage", async () => {
test.skipIf(disabled("getObjectMeta"))("gets object meta", async () => {
expect(await adapter.getObjectMeta(filename)).toEqual({
type: file.type, // image/png
size: file.size
size: file.size,
});
});
+98
View File
@@ -0,0 +1,98 @@
import { describe, expect, test } from "bun:test";
import * as large from "../../src/media/storage/mime-types";
import * as tiny from "../../src/media/storage/mime-types-tiny";
import { getRandomizedFilename } from "../../src/media/utils";
describe("media/mime-types", () => {
test("tiny resolves", () => {
const tests = [[".mp4", "video/mp4", ".jpg", "image/jpeg", ".zip", "application/zip"]];
for (const [ext, mime] of tests) {
expect(tiny.guess(ext)).toBe(mime);
}
});
test("all tiny resolves to large", () => {
for (const [ext, mime] of Object.entries(tiny.M)) {
expect(large.guessMimeType("." + ext)).toBe(mime);
}
for (const [type, exts] of Object.entries(tiny.Q)) {
for (const ext of exts) {
const ex = `${type}/${ext}`;
try {
expect(large.guessMimeType("." + ext)).toBe(ex);
} catch (e) {
console.log(`Failed for ${ext}`, {
type,
exts,
ext,
expected: ex,
actual: large.guessMimeType("." + ext),
});
throw new Error(`Failed for ${ext}`);
}
}
}
});
test("isMimeType", () => {
const tests = [
["image/avif", true],
["image/AVIF", true],
["image/jpeg", true],
["image/jpeg", true, ["image/png"]],
["image/png", false, ["image/png"]],
["image/png", true],
["image/heif", true],
["image/heic", true],
["image/gif", true],
["whatever", false],
["text/tab-separated-values", true],
["application/zip", true],
];
for (const [mime, expected, exclude] of tests) {
expect(
tiny.isMimeType(mime, exclude as any),
`isMimeType(): ${mime} should be ${expected}`,
).toBe(expected as any);
}
});
test("extension", () => {
const tests = [
["image/avif", "avif"],
["image/png", "png"],
["image/PNG", "png"],
["image/jpeg", "jpeg"],
["application/zip", "zip"],
["text/tab-separated-values", "tsv"],
["application/zip", "zip"],
];
for (const [mime, ext] of tests) {
expect(tiny.extension(mime), `extension(): ${mime} should be ${ext}`).toBe(ext);
}
});
test("getRandomizedFilename", () => {
const tests = [
["file.txt", "txt"],
["file.TXT", "txt"],
["image.jpg", "jpg"],
["image.avif", "avif"],
["image.heic", "heic"],
["image.jpeg", "jpeg"],
["-473Wx593H-466453554-black-MODEL.jpg", "jpg"],
["-473Wx593H-466453554-black-MODEL.avif", "avif"],
];
for (const [filename, ext] of tests) {
expect(
getRandomizedFilename(filename).split(".").pop(),
`getRandomizedFilename(): ${filename} should end with ${ext}`,
).toBe(ext);
}
});
});
+100 -7
View File
@@ -1,7 +1,10 @@
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
import { afterAll, beforeAll, beforeEach, describe, expect, spyOn, test } from "bun:test";
import { createApp } from "../../src";
import { AuthController } from "../../src/auth/api/AuthController";
import { em, entity, make, text } from "../../src/data";
import { AppAuth, type ModuleBuildContext } from "../../src/modules";
import { disableConsoleLog, enableConsoleLog } from "../helper";
// @ts-ignore
import { makeCtx, moduleTestSuite } from "./module-test-suite";
describe("AppAuth", () => {
@@ -20,7 +23,7 @@ describe("AppAuth", () => {
const config = auth.toJSON();
expect(config.jwt).toBeUndefined();
expect(config.strategies.password.config).toBeUndefined();
expect(config.strategies?.password?.config).toBeUndefined();
});
test("enabling auth: generate secret", async () => {
@@ -39,9 +42,13 @@ describe("AppAuth", () => {
test("creates user on register", async () => {
const auth = new AppAuth(
{
enabled: true
enabled: true,
// @ts-ignore
jwt: {
secret: "123456",
},
},
ctx
ctx,
);
await auth.build();
@@ -57,17 +64,103 @@ describe("AppAuth", () => {
disableConsoleLog();
const res = await app.request("/password/register", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
email: "some@body.com",
password: "123456"
})
password: "123456",
}),
});
enableConsoleLog();
expect(res.status).toBe(200);
const { data: users } = await ctx.em.repository("users").findMany();
expect(users.length).toBe(1);
expect(users[0].email).toBe("some@body.com");
expect(users[0]?.email).toBe("some@body.com");
}
});
test("registers auth middleware for bknd routes only", async () => {
const app = createApp({
initialConfig: {
auth: {
enabled: true,
jwt: {
secret: "123456",
},
},
},
});
await app.build();
const spy = spyOn(app.module.auth.authenticator, "requestCookieRefresh");
// register custom route
app.server.get("/test", async (c) => c.text("test"));
// call a system api and then the custom route
await app.server.request("/api/system/ping");
await app.server.request("/test");
expect(spy.mock.calls.length).toBe(1);
});
test("should allow additional user fields", async () => {
const app = createApp({
initialConfig: {
auth: {
entity_name: "users",
enabled: true,
},
data: em({
users: entity("users", {
additional: text(),
}),
}).toJSON(),
},
});
await app.build();
const e = app.modules.em.entity("users");
const fields = e.fields.map((f) => f.name);
expect(e.type).toBe("system");
expect(fields).toContain("additional");
expect(fields).toEqual(["id", "additional", "email", "strategy", "strategy_value", "role"]);
});
test("ensure user field configs is always correct", async () => {
const app = createApp({
initialConfig: {
auth: {
enabled: true,
},
data: em({
users: entity("users", {
strategy: text({
fillable: true,
hidden: false,
}),
strategy_value: text({
fillable: true,
hidden: false,
}),
}),
}).toJSON(),
},
});
await app.build();
const users = app.em.entity("users");
const props = ["hidden", "fillable", "required"];
for (const [name, _authFieldProto] of Object.entries(AppAuth.usersFields)) {
const authField = make(name, _authFieldProto as any);
const field = users.field(name)!;
for (const prop of props) {
expect(field.config[prop]).toEqual(authField.config[prop]);
}
}
});
});
+49 -1
View File
@@ -1,7 +1,55 @@
import { describe } from "bun:test";
import { describe, expect, test } from "bun:test";
import { createApp, registries } from "../../src";
import { em, entity, text } from "../../src/data";
import { StorageLocalAdapter } from "../../src/media/storage/adapters/StorageLocalAdapter";
import { AppMedia } from "../../src/modules";
import { moduleTestSuite } from "./module-test-suite";
describe("AppMedia", () => {
moduleTestSuite(AppMedia);
test("should allow additional fields", async () => {
registries.media.register("local", StorageLocalAdapter);
const app = createApp({
initialConfig: {
media: {
entity_name: "media",
enabled: true,
adapter: {
type: "local",
config: {
path: "./",
},
},
},
data: em({
media: entity("media", {
additional: text(),
}),
}).toJSON(),
},
});
await app.build();
const e = app.modules.em.entity("media");
const fields = e.fields.map((f) => f.name);
expect(e.type).toBe("system");
expect(fields).toContain("additional");
expect(fields).toEqual([
"id",
"additional",
"path",
"folder",
"mime_type",
"size",
"scope",
"etag",
"modified_at",
"reference",
"entity_id",
"metadata",
]);
});
});
+212
View File
@@ -0,0 +1,212 @@
import { describe, expect, test } from "bun:test";
import { type TSchema, Type, stripMark } from "../../src/core/utils";
import { EntityManager, em, entity, index, text } from "../../src/data";
import { DummyConnection } from "../../src/data/connection/DummyConnection";
import { Module } from "../../src/modules/Module";
function createModule<Schema extends TSchema>(schema: Schema) {
class TestModule extends Module<typeof schema> {
getSchema() {
return schema;
}
toJSON() {
return this.config;
}
useForceParse() {
return true;
}
}
return TestModule;
}
describe("Module", async () => {
describe("basic", () => {
test("listener", async () => {
let result: any;
const module = createModule(Type.Object({ a: Type.String() }));
const m = new module({ a: "test" });
await m.schema().set({ a: "test2" });
m.setListener(async (c) => {
await new Promise((r) => setTimeout(r, 10));
result = stripMark(c);
});
await m.schema().set({ a: "test3" });
expect(result).toEqual({ a: "test3" });
});
});
describe("db schema", () => {
class M extends Module {
override getSchema() {
return Type.Object({});
}
prt = {
ensureEntity: this.ensureEntity.bind(this),
ensureIndex: this.ensureIndex.bind(this),
ensureSchema: this.ensureSchema.bind(this),
};
get em() {
return this.ctx.em;
}
}
function make(_em: ReturnType<typeof em>) {
const em = new EntityManager(
Object.values(_em.entities),
new DummyConnection(),
_em.relations,
_em.indices,
);
return new M({} as any, { em, flags: Module.ctx_flags } as any);
}
function flat(_em: EntityManager) {
return {
entities: _em.entities.map((e) => ({
name: e.name,
fields: e.fields.map((f) => f.name),
type: e.type,
})),
indices: _em.indices.map((i) => ({
name: i.name,
entity: i.entity.name,
fields: i.fields.map((f) => f.name),
unique: i.unique,
})),
};
}
test("no change", () => {
const initial = em({});
const m = make(initial);
expect(m.ctx.flags.sync_required).toBe(false);
expect(flat(make(initial).em)).toEqual({
entities: [],
indices: [],
});
});
test("init", () => {
const initial = em({
users: entity("u", {
name: text(),
}),
});
const m = make(initial);
expect(m.ctx.flags.sync_required).toBe(false);
expect(flat(m.em)).toEqual({
entities: [
{
name: "u",
fields: ["id", "name"],
type: "regular",
},
],
indices: [],
});
});
test("ensure entity", () => {
const initial = em({
users: entity("u", {
name: text(),
}),
});
const m = make(initial);
expect(flat(m.em)).toEqual({
entities: [
{
name: "u",
fields: ["id", "name"],
type: "regular",
},
],
indices: [],
});
// this should add a new entity
m.prt.ensureEntity(
entity("p", {
title: text(),
}),
);
// this should only add the field "important"
m.prt.ensureEntity(
entity(
"u",
{
important: text(),
},
undefined,
"system",
),
);
expect(m.ctx.flags.sync_required).toBe(true);
expect(flat(m.em)).toEqual({
entities: [
{
name: "u",
fields: ["id", "name", "important"],
// ensured type must be present
type: "system",
},
{
name: "p",
fields: ["id", "title"],
type: "regular",
},
],
indices: [],
});
});
test("ensure index", () => {
const users = entity("u", {
name: text(),
title: text(),
});
const initial = em({ users }, ({ index }, { users }) => {
index(users).on(["title"]);
});
const m = make(initial);
m.prt.ensureIndex(index(users).on(["name"]));
expect(m.ctx.flags.sync_required).toBe(true);
expect(flat(m.em)).toEqual({
entities: [
{
name: "u",
fields: ["id", "name", "title"],
type: "regular",
},
],
indices: [
{
name: "idx_u_title",
entity: "u",
fields: ["title"],
unique: false,
},
{
name: "idx_u_name",
entity: "u",
fields: ["name"],
unique: false,
},
],
});
});
});
});
+383
View File
@@ -0,0 +1,383 @@
import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
import { Type, disableConsoleLog, enableConsoleLog, stripMark } from "../../src/core/utils";
import { entity, text } from "../../src/data";
import { Module } from "../../src/modules/Module";
import { ModuleManager, getDefaultConfig } from "../../src/modules/ModuleManager";
import { CURRENT_VERSION, TABLE_NAME } from "../../src/modules/migrations";
import { getDummyConnection } from "../helper";
describe("ModuleManager", async () => {
test("s1: no config, no build", async () => {
const { dummyConnection } = getDummyConnection();
const mm = new ModuleManager(dummyConnection);
// that is because no module is built
expect(mm.toJSON()).toEqual({ version: 0 } as any);
});
test("s2: no config, build", async () => {
const { dummyConnection } = getDummyConnection();
const mm = new ModuleManager(dummyConnection);
await mm.build();
expect(mm.version()).toBe(CURRENT_VERSION);
expect(mm.built()).toBe(true);
});
test("s3: config given, table exists, version matches", async () => {
const c = getDummyConnection();
const mm = new ModuleManager(c.dummyConnection);
await mm.build();
const version = mm.version();
const configs = mm.configs();
const json = stripMark({
...configs,
data: {
...configs.data,
basepath: "/api/data2",
entities: {
test: entity("test", {
content: text(),
}).toJSON(),
},
},
}) as any;
//const { version, ...json } = mm.toJSON() as any;
const c2 = getDummyConnection();
const db = c2.dummyConnection.kysely;
const mm2 = new ModuleManager(c2.dummyConnection, { initial: { version, ...json } });
await mm2.syncConfigTable();
await db
.insertInto(TABLE_NAME)
.values({ type: "config", json: JSON.stringify(json), version: CURRENT_VERSION })
.execute();
await mm2.build();
expect(json).toEqual(stripMark(mm2.configs()));
});
test("s3.1: (fetch) config given, table exists, version matches", async () => {
const configs = getDefaultConfig();
const json = {
...configs,
data: {
...configs.data,
basepath: "/api/data2",
entities: {
test: entity("test", {
content: text(),
}).toJSON(),
},
},
} as any;
//const { version, ...json } = mm.toJSON() as any;
const { dummyConnection } = getDummyConnection();
const db = dummyConnection.kysely;
const mm2 = new ModuleManager(dummyConnection);
await mm2.syncConfigTable();
// assume an initial version
await db.insertInto(TABLE_NAME).values({ type: "config", json: null, version: 1 }).execute();
await db
.insertInto(TABLE_NAME)
.values({ type: "config", json: JSON.stringify(json), version: CURRENT_VERSION })
.execute();
await mm2.build();
expect(stripMark(json)).toEqual(stripMark(mm2.configs()));
expect(mm2.configs().data.entities?.test).toBeDefined();
expect(mm2.configs().data.entities?.test?.fields?.content).toBeDefined();
expect(mm2.get("data").toJSON().entities?.test?.fields?.content).toBeDefined();
});
test("s4: config given, table exists, version outdated, migrate", async () => {
const c = getDummyConnection();
const mm = new ModuleManager(c.dummyConnection);
await mm.build();
const json = mm.configs();
const c2 = getDummyConnection();
const db = c2.dummyConnection.kysely;
const mm2 = new ModuleManager(c2.dummyConnection);
await mm2.syncConfigTable();
await db
.insertInto(TABLE_NAME)
.values({ json: JSON.stringify(json), type: "config", version: CURRENT_VERSION - 1 })
.execute();
await mm2.build();
});
test("s5: config given, table exists, version mismatch", async () => {
const c = getDummyConnection();
const mm = new ModuleManager(c.dummyConnection);
await mm.build();
const version = mm.version();
const json = mm.configs();
//const { version, ...json } = mm.toJSON() as any;
const c2 = getDummyConnection();
const db = c2.dummyConnection.kysely;
const mm2 = new ModuleManager(c2.dummyConnection, {
initial: { version: version - 1, ...json },
});
await mm2.syncConfigTable();
await db
.insertInto(TABLE_NAME)
.values({ type: "config", json: JSON.stringify(json), version: CURRENT_VERSION })
.execute();
expect(mm2.build()).rejects.toThrow(/version.*do not match/);
});
test("s6: no config given, table exists, fetch", async () => {
const c = getDummyConnection();
const mm = new ModuleManager(c.dummyConnection);
await mm.build();
const json = mm.configs();
//const { version, ...json } = mm.toJSON() as any;
const c2 = getDummyConnection();
const db = c2.dummyConnection.kysely;
const mm2 = new ModuleManager(c2.dummyConnection);
await mm2.syncConfigTable();
const config = {
...json,
data: {
...json.data,
basepath: "/api/data2",
},
};
await db
.insertInto(TABLE_NAME)
.values({ type: "config", json: JSON.stringify(config), version: CURRENT_VERSION })
.execute();
// run without config given
await mm2.build();
expect(mm2.configs().data.basepath).toBe("/api/data2");
});
/*test("blank app, modify config", async () => {
const { dummyConnection } = getDummyConnection();
const mm = new ModuleManager(dummyConnection);
await mm.build();
const configs = stripMark(mm.configs());
expect(mm.configs().server.admin.color_scheme).toBeUndefined();
expect(() => mm.get("server").schema().patch("admin", { color_scheme: "violet" })).toThrow();
await mm.get("server").schema().patch("admin", { color_scheme: "dark" });
await mm.save();
expect(mm.configs().server.admin.color_scheme).toBe("dark");
expect(stripMark(mm.configs())).toEqual({
...configs,
server: {
...configs.server,
admin: {
...configs.server.admin,
color_scheme: "dark",
},
},
});
});*/
test("partial config given", async () => {
const { dummyConnection } = getDummyConnection();
const partial = {
auth: {
enabled: true,
},
};
const mm = new ModuleManager(dummyConnection, {
initial: partial,
});
await mm.build();
expect(mm.version()).toBe(CURRENT_VERSION);
expect(mm.built()).toBe(true);
expect(mm.configs().auth.enabled).toBe(true);
expect(mm.configs().data.entities?.users).toBeDefined();
});
test("partial config given, but db version exists", async () => {
const c = getDummyConnection();
const mm = new ModuleManager(c.dummyConnection);
await mm.build();
console.log("==".repeat(30));
console.log("");
const json = mm.configs();
const c2 = getDummyConnection();
const db = c2.dummyConnection.kysely;
const mm2 = new ModuleManager(c2.dummyConnection, {
initial: {
auth: {
basepath: "/shouldnt/take/this",
},
},
});
await mm2.syncConfigTable();
const payload = {
...json,
auth: {
...json.auth,
enabled: true,
basepath: "/api/auth2",
},
};
await db
.insertInto(TABLE_NAME)
.values({
type: "config",
json: JSON.stringify(payload),
version: CURRENT_VERSION,
})
.execute();
await mm2.build();
expect(mm2.configs().auth.basepath).toBe("/api/auth2");
});
// @todo: add tests for migrations (check "backup" and new version)
describe("revert", async () => {
const failingModuleSchema = Type.Object({
value: Type.Optional(Type.Number()),
});
class FailingModule extends Module<typeof failingModuleSchema> {
getSchema() {
return failingModuleSchema;
}
override async build() {
//console.log("building FailingModule", this.config);
if (this.config.value && this.config.value < 0) {
throw new Error("value must be positive, given: " + this.config.value);
}
this.setBuilt();
}
}
class TestModuleManager extends ModuleManager {
constructor(...args: ConstructorParameters<typeof ModuleManager>) {
super(...args);
const [, options] = args;
// @ts-ignore
const initial = options?.initial?.failing ?? {};
this.modules["failing"] = new FailingModule(initial, this.ctx());
this.modules["failing"].setListener(async (c) => {
// @ts-ignore
await this.onModuleConfigUpdated("failing", c);
});
}
}
beforeEach(() => disableConsoleLog(["log", "warn", "error"]));
afterEach(enableConsoleLog);
test("it builds", async () => {
const { dummyConnection } = getDummyConnection();
const mm = new TestModuleManager(dummyConnection);
expect(mm).toBeDefined();
await mm.build();
expect(mm.toJSON()).toBeDefined();
});
test("it accepts config", async () => {
const { dummyConnection } = getDummyConnection();
const mm = new TestModuleManager(dummyConnection, {
initial: {
// @ts-ignore
failing: { value: 2 },
},
});
await mm.build();
expect(mm.configs()["failing"].value).toBe(2);
});
test("it crashes on invalid", async () => {
const { dummyConnection } = getDummyConnection();
const mm = new TestModuleManager(dummyConnection, {
initial: {
// @ts-ignore
failing: { value: -1 },
},
});
expect(mm.build()).rejects.toThrow(/value must be positive/);
expect(mm.configs()["failing"].value).toBe(-1);
});
test("it correctly accepts valid", async () => {
const mockOnUpdated = mock(() => null);
const { dummyConnection } = getDummyConnection();
const mm = new TestModuleManager(dummyConnection, {
onUpdated: async () => {
mockOnUpdated();
},
});
await mm.build();
// @ts-ignore
const f = mm.mutateConfigSafe("failing");
// @ts-ignore
expect(f.set({ value: 2 })).resolves.toBeDefined();
expect(mockOnUpdated).toHaveBeenCalled();
});
test("it reverts on safe mutate", async () => {
const mockOnUpdated = mock(() => null);
const { dummyConnection } = getDummyConnection();
const mm = new TestModuleManager(dummyConnection, {
initial: {
// @ts-ignore
failing: { value: 1 },
},
onUpdated: async () => {
mockOnUpdated();
},
});
await mm.build();
expect(mm.configs()["failing"].value).toBe(1);
// now safe mutate
// @ts-ignore
expect(mm.mutateConfigSafe("failing").set({ value: -2 })).rejects.toThrow(
/value must be positive/,
);
expect(mm.configs()["failing"].value).toBe(1);
expect(mockOnUpdated).toHaveBeenCalled();
});
test("it only accepts schema mutating methods", async () => {
const { dummyConnection } = getDummyConnection();
const mm = new TestModuleManager(dummyConnection);
await mm.build();
// @ts-ignore
const f = mm.mutateConfigSafe("failing");
// @ts-expect-error
expect(() => f.has("value")).toThrow();
// @ts-expect-error
expect(() => f.bypass()).toThrow();
// @ts-expect-error
expect(() => f.clone()).toThrow();
// @ts-expect-error
expect(() => f.get()).toThrow();
// @ts-expect-error
expect(() => f.default()).toThrow();
});
});
});
@@ -0,0 +1,81 @@
import { describe, expect, test } from "bun:test";
import { type InitialModuleConfigs, createApp } from "../../../src";
import { type Kysely, sql } from "kysely";
import { getDummyConnection } from "../../helper";
import v7 from "./samples/v7.json";
import v8 from "./samples/v8.json";
import v8_2 from "./samples/v8-2.json";
// app expects migratable config to be present in database
async function createVersionedApp(config: InitialModuleConfigs | any) {
const { dummyConnection } = getDummyConnection();
if (!("version" in config)) throw new Error("config must have a version");
const { version, ...rest } = config;
const db = dummyConnection.kysely as Kysely<any>;
await sql`CREATE TABLE "__bknd" (
"id" integer not null primary key autoincrement,
"version" integer,
"type" text,
"json" text,
"created_at" datetime,
"updated_at" datetime
)`.execute(db);
await db
.insertInto("__bknd")
.values({
version,
type: "config",
created_at: new Date().toISOString(),
json: JSON.stringify(rest),
})
.execute();
const app = createApp({
connection: dummyConnection,
});
await app.build();
return app;
}
describe("Migrations", () => {
/**
* updated auth strategies to have "enabled" prop
* by default, migration should make all available strategies enabled
*/
test("migration from 7 to 8", async () => {
expect(v7.version).toBe(7);
const app = await createVersionedApp(v7);
expect(app.version()).toBeGreaterThan(7);
expect(app.toJSON(true).auth.strategies?.password?.enabled).toBe(true);
const req = await app.server.request("/api/auth/password/register", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
email: "test@test.com",
password: "12345678",
}),
});
expect(req.ok).toBe(true);
const res = (await req.json()) as any;
expect(res.user.email).toBe("test@test.com");
});
test("migration from 8 to 9", async () => {
expect(v8.version).toBe(8);
const app = await createVersionedApp(v8);
expect(app.version()).toBeGreaterThan(8);
// @ts-expect-error
expect(app.toJSON(true).server.admin).toBeUndefined();
});
});
@@ -0,0 +1,638 @@
{
"version": 7,
"server": {
"admin": {
"basepath": "",
"logo_return_path": "/",
"color_scheme": "light"
},
"cors": {
"origin": "*",
"allow_methods": ["GET", "POST", "PATCH", "PUT", "DELETE"],
"allow_headers": [
"Content-Type",
"Content-Length",
"Authorization",
"Accept"
]
}
},
"data": {
"basepath": "/api/data",
"entities": {
"products": {
"type": "regular",
"fields": {
"id": {
"type": "primary",
"config": {
"fillable": false,
"required": false,
"hidden": false
}
},
"title": {
"type": "text",
"config": {
"required": true,
"fillable": true,
"hidden": false
}
},
"brand": {
"type": "text",
"config": {
"required": true,
"fillable": true,
"hidden": false
}
},
"currency": {
"type": "text",
"config": {
"required": false,
"fillable": true,
"hidden": false
}
},
"price": {
"type": "number",
"config": {
"required": true,
"fillable": true,
"hidden": false
}
},
"price_compare": {
"type": "number",
"config": {
"required": false,
"fillable": true,
"hidden": ["table"]
}
},
"url": {
"type": "text",
"config": {
"html_config": {
"element": "input"
},
"required": true,
"fillable": true,
"hidden": ["table"]
}
},
"created_at": {
"type": "date",
"config": {
"type": "date",
"required": false,
"fillable": true,
"hidden": ["table"]
}
},
"description": {
"type": "text",
"config": {
"html_config": {
"element": "textarea",
"props": {
"rows": 4
}
},
"required": false,
"fillable": true,
"hidden": ["table"]
}
},
"images": {
"type": "media",
"config": {
"required": false,
"fillable": ["update"],
"hidden": false,
"mime_types": [],
"virtual": true,
"entity": "products"
}
},
"identifier": {
"type": "text",
"config": {
"required": false,
"fillable": true,
"hidden": false
}
},
"metadata": {
"type": "jsonschema",
"config": {
"schema": {
"type": "object",
"properties": {
"size": {
"type": "string"
}
}
},
"required": false,
"fillable": true,
"hidden": ["table"]
}
}
},
"config": {
"sort_field": "id",
"sort_dir": "asc"
}
},
"media": {
"type": "system",
"fields": {
"id": {
"type": "primary",
"config": {
"fillable": false,
"required": false,
"hidden": false
}
},
"path": {
"type": "text",
"config": {
"required": true,
"fillable": true,
"hidden": false
}
},
"folder": {
"type": "boolean",
"config": {
"default_value": false,
"hidden": true,
"fillable": ["create"],
"required": false
}
},
"mime_type": {
"type": "text",
"config": {
"required": false,
"fillable": true,
"hidden": false
}
},
"size": {
"type": "number",
"config": {
"required": false,
"fillable": true,
"hidden": false
}
},
"scope": {
"type": "text",
"config": {
"hidden": true,
"fillable": ["create"],
"required": false
}
},
"etag": {
"type": "text",
"config": {
"required": false,
"fillable": true,
"hidden": false
}
},
"modified_at": {
"type": "date",
"config": {
"type": "datetime",
"required": false,
"fillable": true,
"hidden": false
}
},
"reference": {
"type": "text",
"config": {
"required": false,
"fillable": true,
"hidden": false
}
},
"entity_id": {
"type": "number",
"config": {
"required": false,
"fillable": true,
"hidden": false
}
},
"metadata": {
"type": "json",
"config": {
"required": false,
"fillable": true,
"hidden": false
}
}
},
"config": {
"sort_field": "id",
"sort_dir": "asc"
}
},
"users": {
"type": "system",
"fields": {
"id": {
"type": "primary",
"config": {
"fillable": false,
"required": false,
"hidden": false
}
},
"email": {
"type": "text",
"config": {
"required": true,
"fillable": true,
"hidden": false
}
},
"strategy": {
"type": "enum",
"config": {
"options": {
"type": "strings",
"values": ["password"]
},
"required": true,
"fillable": ["create"],
"hidden": ["update", "form"]
}
},
"strategy_value": {
"type": "text",
"config": {
"fillable": ["create"],
"hidden": ["read", "table", "update", "form"],
"required": true
}
},
"role": {
"type": "enum",
"config": {
"options": {
"type": "strings",
"values": ["guest", "admin"]
},
"required": false,
"fillable": true,
"hidden": false
}
},
"username": {
"type": "text",
"config": {
"required": false,
"fillable": true,
"hidden": false
}
},
"name": {
"type": "text",
"config": {
"required": false,
"fillable": true,
"hidden": false
}
}
},
"config": {
"sort_field": "id",
"sort_dir": "asc"
}
},
"product_likes": {
"type": "regular",
"fields": {
"id": {
"type": "primary",
"config": {
"fillable": false,
"required": false,
"hidden": false
}
},
"created_at": {
"type": "date",
"config": {
"type": "date",
"required": false,
"fillable": true,
"hidden": false
}
},
"users_id": {
"type": "relation",
"config": {
"label": "User",
"required": true,
"reference": "users",
"target": "users",
"target_field": "id",
"fillable": true,
"hidden": false,
"on_delete": "set null"
}
},
"products_id": {
"type": "relation",
"config": {
"label": "Product",
"required": true,
"reference": "products",
"target": "products",
"target_field": "id",
"fillable": true,
"hidden": false,
"on_delete": "set null"
}
}
},
"config": {
"name": "Product Likes",
"sort_field": "id",
"sort_dir": "asc"
}
},
"boards": {
"type": "regular",
"fields": {
"id": {
"type": "primary",
"config": {
"fillable": false,
"required": false,
"hidden": false
}
},
"private": {
"type": "boolean",
"config": {
"required": false,
"fillable": true,
"hidden": false
}
},
"title": {
"type": "text",
"config": {
"required": true,
"fillable": true,
"hidden": false
}
},
"users_id": {
"type": "relation",
"config": {
"label": "Users",
"required": true,
"reference": "users",
"target": "users",
"target_field": "id",
"fillable": true,
"hidden": false,
"on_delete": "set null"
}
},
"images": {
"type": "media",
"config": {
"required": false,
"fillable": ["update"],
"hidden": false,
"mime_types": [],
"virtual": true,
"entity": "boards",
"max_items": 5
}
},
"cover": {
"type": "number",
"config": {
"default_value": 0,
"required": false,
"fillable": true,
"hidden": false
}
}
},
"config": {
"sort_field": "id",
"sort_dir": "asc"
}
},
"boards_products": {
"type": "generated",
"fields": {
"id": {
"type": "primary",
"config": {
"fillable": false,
"required": false,
"hidden": false
}
},
"boards_id": {
"type": "relation",
"config": {
"required": false,
"reference": "boards",
"target": "boards",
"target_field": "id",
"fillable": true,
"hidden": false,
"on_delete": "set null"
}
},
"products_id": {
"type": "relation",
"config": {
"required": false,
"reference": "products",
"target": "products",
"target_field": "id",
"fillable": true,
"hidden": false,
"on_delete": "set null"
}
}
},
"config": {
"sort_field": "id",
"sort_dir": "asc"
}
}
},
"relations": {
"poly_products_media_images": {
"type": "poly",
"source": "products",
"target": "media",
"config": {
"mappedBy": "images"
}
},
"n1_product_likes_users": {
"type": "n:1",
"source": "product_likes",
"target": "users",
"config": {
"mappedBy": "",
"inversedBy": "",
"required": true,
"with_limit": 5
}
},
"n1_product_likes_products": {
"type": "n:1",
"source": "product_likes",
"target": "products",
"config": {
"mappedBy": "",
"inversedBy": "",
"required": true,
"with_limit": 5
}
},
"n1_boards_users": {
"type": "n:1",
"source": "boards",
"target": "users",
"config": {
"mappedBy": "",
"inversedBy": "",
"required": true,
"with_limit": 5
}
},
"poly_boards_media_images": {
"type": "poly",
"source": "boards",
"target": "media",
"config": {
"mappedBy": "images",
"targetCardinality": 5
}
},
"mn_boards_products_boards_products,boards_products": {
"type": "m:n",
"source": "boards",
"target": "products",
"config": {}
}
},
"indices": {
"idx_unique_media_path": {
"entity": "media",
"fields": ["path"],
"unique": true
},
"idx_media_reference": {
"entity": "media",
"fields": ["reference"],
"unique": false
},
"idx_unique_users_email": {
"entity": "users",
"fields": ["email"],
"unique": true
},
"idx_users_strategy": {
"entity": "users",
"fields": ["strategy"],
"unique": false
},
"idx_users_strategy_value": {
"entity": "users",
"fields": ["strategy_value"],
"unique": false
},
"idx_product_likes_unique_products_id_users_id": {
"entity": "product_likes",
"fields": ["products_id", "users_id"],
"unique": true
},
"idx_media_entity_id": {
"entity": "media",
"fields": ["entity_id"],
"unique": false
}
}
},
"auth": {
"enabled": true,
"basepath": "/api/auth",
"entity_name": "users",
"allow_register": true,
"jwt": {
"secret": "...",
"alg": "HS256",
"fields": ["id", "email", "role"]
},
"cookie": {
"path": "/",
"sameSite": "lax",
"secure": true,
"httpOnly": true,
"expires": 604800,
"renew": true,
"pathSuccess": "/",
"pathLoggedOut": "/"
},
"strategies": {
"password": {
"type": "password",
"config": {
"hashing": "sha256"
}
}
},
"roles": {
"guest": {
"is_default": true,
"permissions": ["system.access.api", "data.entity.read"]
},
"admin": {
"implicit_allow": true
}
},
"guard": {
"enabled": true
}
},
"media": {
"enabled": true,
"basepath": "/api/media",
"entity_name": "media",
"storage": {},
"adapter": {
"type": "s3",
"config": {
"access_key": "...",
"secret_access_key": "...",
"url": "https://some.r2.cloudflarestorage.com/some"
}
}
},
"flows": {
"basepath": "/api/flows",
"flows": {}
}
}
@@ -0,0 +1,709 @@
{
"version": 8,
"server": {
"admin": {
"basepath": "",
"color_scheme": "light",
"logo_return_path": "/"
},
"cors": {
"origin": "*",
"allow_methods": ["GET", "POST", "PATCH", "PUT", "DELETE"],
"allow_headers": [
"Content-Type",
"Content-Length",
"Authorization",
"Accept"
]
}
},
"data": {
"basepath": "/api/data",
"entities": {
"products": {
"type": "regular",
"fields": {
"id": {
"type": "primary",
"config": {
"fillable": false,
"required": false,
"hidden": false
}
},
"title": {
"type": "text",
"config": {
"required": true,
"fillable": true,
"hidden": false
}
},
"brand": {
"type": "text",
"config": {
"required": true,
"fillable": true,
"hidden": false
}
},
"currency": {
"type": "text",
"config": {
"required": false,
"fillable": true,
"hidden": false
}
},
"price": {
"type": "number",
"config": {
"required": true,
"fillable": true,
"hidden": false
}
},
"price_compare": {
"type": "number",
"config": {
"required": false,
"fillable": true,
"hidden": ["table"]
}
},
"url": {
"type": "text",
"config": {
"html_config": {
"element": "input"
},
"required": true,
"fillable": true,
"hidden": ["table"]
}
},
"created_at": {
"type": "date",
"config": {
"type": "date",
"required": false,
"fillable": true,
"hidden": ["table"]
}
},
"description": {
"type": "text",
"config": {
"html_config": {
"element": "textarea",
"props": {
"rows": 4
}
},
"required": false,
"fillable": true,
"hidden": ["table"]
}
},
"images": {
"type": "media",
"config": {
"required": false,
"fillable": ["update"],
"hidden": false,
"mime_types": [],
"virtual": true,
"entity": "products"
}
},
"identifier": {
"type": "text",
"config": {
"required": false,
"fillable": true,
"hidden": false
}
},
"metadata": {
"type": "jsonschema",
"config": {
"schema": {
"type": "object",
"properties": {
"size": {
"type": "string"
},
"gender": {
"type": "string"
},
"ai_description": {
"type": "string"
}
},
"additionalProperties": {
"type": ["string", "number"]
}
},
"required": false,
"fillable": true,
"hidden": ["table"]
}
},
"_likes": {
"type": "number",
"config": {
"default_value": 0,
"required": false,
"fillable": true,
"hidden": false
}
}
},
"config": {
"sort_field": "id",
"sort_dir": "desc"
}
},
"media": {
"type": "system",
"fields": {
"id": {
"type": "primary",
"config": {
"fillable": false,
"required": false,
"hidden": false
}
},
"path": {
"type": "text",
"config": {
"required": true,
"fillable": true,
"hidden": false
}
},
"folder": {
"type": "boolean",
"config": {
"default_value": false,
"hidden": true,
"fillable": ["create"],
"required": false
}
},
"mime_type": {
"type": "text",
"config": {
"required": false,
"fillable": true,
"hidden": false
}
},
"size": {
"type": "number",
"config": {
"required": false,
"fillable": true,
"hidden": false
}
},
"scope": {
"type": "text",
"config": {
"hidden": true,
"fillable": ["create"],
"required": false
}
},
"etag": {
"type": "text",
"config": {
"required": false,
"fillable": true,
"hidden": false
}
},
"modified_at": {
"type": "date",
"config": {
"type": "datetime",
"required": false,
"fillable": true,
"hidden": false
}
},
"reference": {
"type": "text",
"config": {
"required": false,
"fillable": true,
"hidden": false
}
},
"entity_id": {
"type": "number",
"config": {
"required": false,
"fillable": true,
"hidden": false
}
},
"metadata": {
"type": "json",
"config": {
"required": false,
"fillable": true,
"hidden": false
}
},
"temporary": {
"type": "boolean",
"config": {
"required": false,
"fillable": true,
"hidden": false
}
}
},
"config": {
"sort_field": "id",
"sort_dir": "desc"
}
},
"users": {
"type": "system",
"fields": {
"id": {
"type": "primary",
"config": {
"fillable": false,
"required": false,
"hidden": false
}
},
"email": {
"type": "text",
"config": {
"required": true,
"fillable": true,
"hidden": false
}
},
"strategy": {
"type": "enum",
"config": {
"options": {
"type": "strings",
"values": ["password"]
},
"required": true,
"fillable": ["create"],
"hidden": ["read", "table", "update", "form"]
}
},
"strategy_value": {
"type": "text",
"config": {
"fillable": ["create"],
"hidden": ["read", "table", "update", "form"],
"required": true
}
},
"role": {
"type": "enum",
"config": {
"options": {
"type": "strings",
"values": ["guest", "admin"]
},
"required": false,
"fillable": true,
"hidden": false
}
},
"username": {
"type": "text",
"config": {
"required": false,
"fillable": true,
"hidden": false
}
},
"name": {
"type": "text",
"config": {
"required": false,
"fillable": true,
"hidden": false
}
},
"_boards": {
"type": "number",
"config": {
"default_value": 0,
"required": false,
"fillable": true,
"hidden": false
}
}
},
"config": {
"sort_field": "id",
"sort_dir": "desc"
}
},
"product_likes": {
"type": "regular",
"fields": {
"id": {
"type": "primary",
"config": {
"fillable": false,
"required": false,
"hidden": false
}
},
"created_at": {
"type": "date",
"config": {
"type": "datetime",
"required": false,
"fillable": true,
"hidden": false
}
},
"users_id": {
"type": "relation",
"config": {
"label": "User",
"required": true,
"reference": "users",
"target": "users",
"target_field": "id",
"fillable": true,
"hidden": false,
"on_delete": "set null"
}
},
"products_id": {
"type": "relation",
"config": {
"label": "Product",
"required": true,
"reference": "products",
"target": "products",
"target_field": "id",
"fillable": true,
"hidden": false,
"on_delete": "set null"
}
}
},
"config": {
"sort_field": "id",
"sort_dir": "desc",
"name": "Product Likes"
}
},
"boards": {
"type": "regular",
"fields": {
"id": {
"type": "primary",
"config": {
"fillable": false,
"required": false,
"hidden": false
}
},
"private": {
"type": "boolean",
"config": {
"required": false,
"fillable": true,
"hidden": false
}
},
"title": {
"type": "text",
"config": {
"required": false,
"fillable": true,
"hidden": false
}
},
"description": {
"type": "text",
"config": {
"required": false,
"fillable": true,
"hidden": false
}
},
"users_id": {
"type": "relation",
"config": {
"label": "Users",
"required": true,
"reference": "users",
"target": "users",
"target_field": "id",
"fillable": true,
"hidden": false,
"on_delete": "set null"
}
},
"images": {
"type": "media",
"config": {
"required": false,
"fillable": ["update"],
"hidden": false,
"mime_types": [],
"virtual": true,
"entity": "boards",
"max_items": 5
}
},
"cover": {
"type": "number",
"config": {
"default_value": 0,
"required": false,
"fillable": true,
"hidden": false
}
},
"_products": {
"type": "number",
"config": {
"default_value": 0,
"required": false,
"fillable": true,
"hidden": false
}
}
},
"config": {
"sort_field": "id",
"sort_dir": "desc"
}
},
"boards_products": {
"type": "generated",
"fields": {
"id": {
"type": "primary",
"config": {
"fillable": false,
"required": false,
"hidden": false
}
},
"boards_id": {
"type": "relation",
"config": {
"required": true,
"reference": "boards",
"target": "boards",
"target_field": "id",
"fillable": true,
"hidden": false,
"on_delete": "set null"
}
},
"products_id": {
"type": "relation",
"config": {
"required": true,
"reference": "products",
"target": "products",
"target_field": "id",
"fillable": true,
"hidden": false,
"on_delete": "set null"
}
},
"manual": {
"type": "boolean",
"config": {
"required": false,
"fillable": true,
"hidden": false
}
}
},
"config": {
"sort_field": "id",
"sort_dir": "desc"
}
}
},
"relations": {
"poly_products_media_images": {
"type": "poly",
"source": "products",
"target": "media",
"config": {
"mappedBy": "images"
}
},
"n1_product_likes_users": {
"type": "n:1",
"source": "product_likes",
"target": "users",
"config": {
"mappedBy": "",
"inversedBy": "",
"required": true,
"with_limit": 5
}
},
"n1_product_likes_products": {
"type": "n:1",
"source": "product_likes",
"target": "products",
"config": {
"mappedBy": "",
"inversedBy": "",
"required": true,
"with_limit": 5
}
},
"n1_boards_users": {
"type": "n:1",
"source": "boards",
"target": "users",
"config": {
"mappedBy": "",
"inversedBy": "",
"required": true,
"with_limit": 5
}
},
"poly_boards_media_images": {
"type": "poly",
"source": "boards",
"target": "media",
"config": {
"mappedBy": "images",
"targetCardinality": 5
}
},
"mn_boards_products_boards_products,boards_products": {
"type": "m:n",
"source": "boards",
"target": "products",
"config": {}
}
},
"indices": {
"idx_unique_media_path": {
"entity": "media",
"fields": ["path"],
"unique": true
},
"idx_media_reference": {
"entity": "media",
"fields": ["reference"],
"unique": false
},
"idx_unique_users_email": {
"entity": "users",
"fields": ["email"],
"unique": true
},
"idx_users_strategy": {
"entity": "users",
"fields": ["strategy"],
"unique": false
},
"idx_users_strategy_value": {
"entity": "users",
"fields": ["strategy_value"],
"unique": false
},
"idx_product_likes_unique_products_id_users_id": {
"entity": "product_likes",
"fields": ["products_id", "users_id"],
"unique": true
},
"idx_boards_products_unique_boards_id_products_id": {
"entity": "boards_products",
"fields": ["boards_id", "products_id"],
"unique": true
},
"idx_media_entity_id": {
"entity": "media",
"fields": ["entity_id"],
"unique": false
},
"idx_products_identifier": {
"entity": "products",
"fields": ["identifier"],
"unique": false
}
}
},
"auth": {
"enabled": true,
"basepath": "/api/auth",
"entity_name": "users",
"allow_register": true,
"jwt": {
"secret": "2wY76Z$JQg(3t?Wn8g,^ZqZhlmjNx<@uQjI!7i^XZBF11Xa1>zZK2??Y[D|]cc%k",
"alg": "HS256",
"fields": ["id", "email", "role"]
},
"cookie": {
"path": "/",
"sameSite": "lax",
"secure": true,
"httpOnly": true,
"expires": 604800,
"renew": true,
"pathSuccess": "/",
"pathLoggedOut": "/"
},
"strategies": {
"password": {
"enabled": true,
"type": "password",
"config": {
"hashing": "sha256"
}
}
},
"roles": {
"guest": {
"is_default": true,
"permissions": ["system.access.api", "data.entity.read"]
},
"admin": {
"implicit_allow": true
}
},
"guard": {
"enabled": true
}
},
"media": {
"enabled": true,
"basepath": "/api/media",
"entity_name": "media",
"storage": {},
"adapter": {
"type": "s3",
"config": {
"access_key": "123",
"secret_access_key": "123",
"url": "https://123.r2.cloudflarestorage.com/enly"
}
}
},
"flows": {
"basepath": "/api/flows",
"flows": {}
}
}
@@ -0,0 +1,598 @@
{
"version": 8,
"server": {
"admin": {
"basepath": "",
"logo_return_path": "/",
"color_scheme": "dark"
},
"cors": {
"origin": "*",
"allow_methods": ["GET", "POST", "PATCH", "PUT", "DELETE"],
"allow_headers": [
"Content-Type",
"Content-Length",
"Authorization",
"Accept"
]
}
},
"data": {
"basepath": "/api/data",
"entities": {
"posts": {
"type": "regular",
"fields": {
"id": {
"type": "primary",
"config": {
"fillable": false,
"required": false,
"hidden": false
}
},
"title": {
"type": "text",
"config": {
"required": true,
"fillable": true,
"hidden": false
}
},
"slug": {
"type": "text",
"config": {
"html_config": {
"element": "input"
},
"pattern": "^[a-z\\-\\_0-9]+$",
"required": false,
"fillable": true,
"hidden": false,
"label": "Slug"
}
},
"content": {
"type": "text",
"config": {
"html_config": {
"element": "textarea",
"props": {
"rows": 5
}
},
"required": false,
"fillable": true,
"hidden": ["form"]
}
},
"active": {
"type": "boolean",
"config": {
"required": false,
"fillable": true,
"hidden": false
}
},
"images": {
"type": "media",
"config": {
"required": false,
"fillable": ["update"],
"hidden": false,
"mime_types": [],
"virtual": true,
"entity": "posts"
}
},
"tags": {
"type": "jsonschema",
"config": {
"schema": {
"type": "array",
"items": {
"type": "string"
}
},
"ui_schema": {
"ui:options": {
"orderable": false
}
},
"required": false,
"fillable": true,
"hidden": false
}
},
"users_id": {
"type": "relation",
"config": {
"label": "Users",
"required": false,
"reference": "users",
"target": "users",
"target_field": "id",
"fillable": true,
"hidden": false,
"on_delete": "set null"
}
}
},
"config": {
"sort_field": "title",
"sort_dir": "desc"
}
},
"comments": {
"type": "regular",
"fields": {
"id": {
"type": "primary",
"config": {
"fillable": false,
"required": false,
"hidden": false
}
},
"content": {
"type": "text",
"config": {
"required": false,
"fillable": true,
"hidden": false
}
},
"created_at": {
"type": "date",
"config": {
"type": "date",
"required": false,
"fillable": true,
"hidden": false
}
},
"posts_id": {
"type": "relation",
"config": {
"label": "Posts",
"required": true,
"reference": "posts",
"target": "posts",
"target_field": "id",
"fillable": true,
"hidden": false,
"on_delete": "set null"
}
},
"users_id": {
"type": "relation",
"config": {
"label": "Users",
"required": true,
"reference": "users",
"target": "users",
"target_field": "id",
"fillable": true,
"hidden": false,
"on_delete": "set null"
}
}
},
"config": {
"sort_field": "id",
"sort_dir": "asc"
}
},
"media": {
"type": "system",
"fields": {
"id": {
"type": "primary",
"config": {
"fillable": false,
"required": false,
"hidden": false
}
},
"path": {
"type": "text",
"config": {
"required": true,
"fillable": true,
"hidden": false
}
},
"folder": {
"type": "boolean",
"config": {
"default_value": false,
"hidden": true,
"fillable": ["create"],
"required": false
}
},
"mime_type": {
"type": "text",
"config": {
"required": false,
"fillable": true,
"hidden": false
}
},
"size": {
"type": "number",
"config": {
"required": false,
"fillable": true,
"hidden": false
}
},
"scope": {
"type": "text",
"config": {
"hidden": true,
"fillable": ["create"],
"required": false
}
},
"etag": {
"type": "text",
"config": {
"required": false,
"fillable": true,
"hidden": false
}
},
"modified_at": {
"type": "date",
"config": {
"type": "datetime",
"required": false,
"fillable": true,
"hidden": false
}
},
"reference": {
"type": "text",
"config": {
"required": false,
"fillable": true,
"hidden": false
}
},
"entity_id": {
"type": "number",
"config": {
"required": false,
"fillable": true,
"hidden": false
}
},
"metadata": {
"type": "json",
"config": {
"required": false,
"fillable": true,
"hidden": false
}
}
},
"config": {
"sort_field": "id",
"sort_dir": "asc"
}
},
"users": {
"type": "system",
"fields": {
"id": {
"type": "primary",
"config": {
"fillable": false,
"required": false,
"hidden": false
}
},
"email": {
"type": "text",
"config": {
"required": true,
"fillable": true,
"hidden": false
}
},
"strategy": {
"type": "enum",
"config": {
"options": {
"type": "strings",
"values": ["password", "google"]
},
"required": true,
"fillable": ["create"],
"hidden": ["update", "form"]
}
},
"strategy_value": {
"type": "text",
"config": {
"fillable": ["create"],
"hidden": ["read", "table", "update", "form"],
"required": true
}
},
"role": {
"type": "enum",
"config": {
"options": {
"type": "strings",
"values": ["guest", "admin", "editor"]
},
"required": false,
"fillable": true,
"hidden": false
}
},
"avatar": {
"type": "media",
"config": {
"required": false,
"fillable": ["update"],
"hidden": false,
"mime_types": [],
"virtual": true,
"entity": "users",
"max_items": 1
}
}
},
"config": {
"sort_field": "id",
"sort_dir": "asc"
}
},
"test": {
"type": "regular",
"fields": {
"id": {
"type": "primary",
"config": {
"fillable": false,
"required": false,
"hidden": false
}
},
"title": {
"type": "text",
"config": {
"required": false,
"fillable": true,
"hidden": false
}
},
"number": {
"type": "number",
"config": {
"required": false,
"fillable": true,
"hidden": false
}
}
},
"config": {
"sort_field": "id",
"sort_dir": "asc"
}
}
},
"relations": {
"n1_comments_posts": {
"type": "n:1",
"source": "comments",
"target": "posts",
"config": {
"required": true,
"with_limit": 5
}
},
"poly_posts_media_images": {
"type": "poly",
"source": "posts",
"target": "media",
"config": {
"mappedBy": "images"
}
},
"n1_posts_users": {
"type": "n:1",
"source": "posts",
"target": "users",
"config": {
"with_limit": 5
}
},
"n1_comments_users": {
"type": "n:1",
"source": "comments",
"target": "users",
"config": {
"required": true,
"with_limit": 5
}
},
"poly_users_media_avatar": {
"type": "poly",
"source": "users",
"target": "media",
"config": {
"mappedBy": "avatar",
"targetCardinality": 1
}
}
},
"indices": {
"idx_unique_media_path": {
"entity": "media",
"fields": ["path"],
"unique": true
},
"idx_unique_users_email": {
"entity": "users",
"fields": ["email"],
"unique": true
},
"idx_users_strategy": {
"entity": "users",
"fields": ["strategy"],
"unique": false
},
"idx_users_strategy_value": {
"entity": "users",
"fields": ["strategy_value"],
"unique": false
},
"idx_media_reference": {
"entity": "media",
"fields": ["reference"],
"unique": false
},
"idx_media_entity_id": {
"entity": "media",
"fields": ["entity_id"],
"unique": false
}
}
},
"auth": {
"enabled": true,
"basepath": "/api/auth",
"entity_name": "users",
"jwt": {
"secret": "A%3jk*wD!Zruj123123123j$Wm8qS8m8qS8",
"alg": "HS256",
"fields": ["id", "email", "role"],
"issuer": "showoff"
},
"guard": {
"enabled": true
},
"strategies": {
"password": {
"enabled": true,
"type": "password",
"config": {
"hashing": "sha256"
}
},
"google": {
"enabled": true,
"type": "oauth",
"config": {
"type": "oidc",
"client": {
"client_id": "545948917277-123ieuifrag.apps.googleusercontent.com",
"client_secret": "123-123hTTZfDDGPDPp"
},
"name": "google"
}
}
},
"roles": {
"guest": {
"permissions": [
"data.entity.read",
"system.access.api",
"system.config.read"
],
"is_default": true
},
"admin": {
"is_default": false,
"implicit_allow": true
},
"editor": {
"permissions": [
"system.access.admin",
"system.config.read",
"system.schema.read",
"system.config.read.secrets",
"system.access.api",
"data.entity.read",
"data.entity.update",
"data.entity.delete",
"data.entity.create"
]
}
},
"allow_register": true,
"cookie": {
"path": "/",
"sameSite": "lax",
"secure": true,
"httpOnly": true,
"expires": 604800,
"renew": true,
"pathSuccess": "/",
"pathLoggedOut": "/"
}
},
"media": {
"enabled": true,
"basepath": "/api/media",
"entity_name": "media",
"storage": {},
"adapter": {
"type": "s3",
"config": {
"access_key": "123",
"secret_access_key": "123",
"url": "https://123.r2.cloudflarestorage.com/bknd-123"
}
}
},
"flows": {
"basepath": "/api/flows",
"flows": {
"test": {
"trigger": {
"type": "http",
"config": {
"mode": "sync",
"method": "GET",
"response_type": "html",
"path": "/json-posts"
}
},
"tasks": {
"fetching": {
"type": "fetch",
"params": {
"method": "GET",
"headers": [],
"url": "https://jsonplaceholder.typicode.com/posts"
}
},
"render": {
"type": "render",
"params": {
"render": "<h1>Posts</h1>\n<ul>\n {% for post in fetching.output %}\n <li>{{ post.title }}</li>\n {% endfor %}\n</ul>"
}
}
},
"connections": {
"5cce66b5-57c6-4541-88ac-b298794c6c52": {
"source": "fetching",
"target": "render",
"config": {
"condition": {
"type": "success"
}
}
}
},
"start_task": "fetching",
"responding_task": "render"
}
}
}
}
+5 -2
View File
@@ -2,10 +2,11 @@ import { beforeEach, describe, expect, it } from "bun:test";
import { Hono } from "hono";
import { Guard } from "../../src/auth";
import { DebugLogger } from "../../src/core";
import { EventManager } from "../../src/core/events";
import { Default, stripMark } from "../../src/core/utils";
import { EntityManager } from "../../src/data";
import type { Module, ModuleBuildContext } from "../../src/modules/Module";
import { Module, type ModuleBuildContext } from "../../src/modules/Module";
import { getDummyConnection } from "../helper";
export function makeCtx(overrides?: Partial<ModuleBuildContext>): ModuleBuildContext {
@@ -16,7 +17,9 @@ export function makeCtx(overrides?: Partial<ModuleBuildContext>): ModuleBuildCon
em: new EntityManager([], dummyConnection),
emgr: new EventManager(),
guard: new Guard(),
...overrides
flags: Module.ctx_flags,
logger: new DebugLogger(false),
...overrides,
};
}
+138
View File
@@ -0,0 +1,138 @@
import { describe, expect, test } from "bun:test";
import { Draft2019 } from "json-schema-library";
import type { JSONSchema } from "json-schema-to-ts";
import * as utils from "../../src/ui/components/form/json-schema-form/utils";
import type { IsTypeType } from "../../src/ui/components/form/json-schema-form/utils";
describe("json form", () => {
test("coerse", () => {
const examples = [
["test", { type: "string" }, "test"],
["1", { type: "integer" }, 1],
["1", { type: "number" }, 1],
["true", { type: "boolean" }, true],
["false", { type: "boolean" }, false],
["1", { type: "boolean" }, true],
["0", { type: "boolean" }, false],
["on", { type: "boolean" }, true],
["off", { type: "boolean" }, false],
["null", { type: "null" }, null],
] satisfies [string, Exclude<JSONSchema, boolean>, any][];
for (const [input, schema, output] of examples) {
expect(utils.coerce(input, schema)).toBe(output);
}
});
test("isType", () => {
const examples = [
["string", "string", true],
["integer", "number", false],
["number", "number", true],
["boolean", "boolean", true],
["null", "null", true],
["object", "object", true],
["array", "array", true],
["object", "array", false],
[["string", "number"], "number", true],
["number", ["string", "number"], true],
] satisfies [IsTypeType, IsTypeType, boolean][];
for (const [type, schemaType, output] of examples) {
expect(utils.isType(type, schemaType)).toBe(output);
}
});
test("getParentPointer", () => {
const examples = [
["#/nested/property/0/name", "#/nested/property/0"],
["#/nested/property/0", "#/nested/property"],
["#/nested/property", "#/nested"],
["#/nested", "#"],
];
for (const [input, output] of examples) {
expect(utils.getParentPointer(input)).toBe(output);
}
});
test("isRequired", () => {
const examples = [
[
"#/description",
{ type: "object", properties: { description: { type: "string" } } },
false,
],
[
"#/description",
{
type: "object",
required: ["description"],
properties: { description: { type: "string" } },
},
true,
],
[
"#/nested/property",
{
type: "object",
properties: {
nested: {
type: "object",
properties: { property: { type: "string" } },
},
},
},
false,
],
[
"#/nested/property",
{
type: "object",
properties: {
nested: {
type: "object",
required: ["property"],
properties: { property: { type: "string" } },
},
},
},
true,
],
] satisfies [string, Exclude<JSONSchema, boolean>, boolean][];
for (const [pointer, schema, output] of examples) {
expect(utils.isRequired(new Draft2019(schema), pointer, schema)).toBe(output);
}
});
test("prefixPath", () => {
const examples = [
["normal", "", "normal"],
["", "prefix", "prefix"],
["tags", "0", "0.tags"],
["tags", 0, "0.tags"],
["nested.property", "prefix", "prefix.nested.property"],
["nested.property", "", "nested.property"],
] satisfies [string, any, string][];
for (const [path, prefix, output] of examples) {
expect(utils.prefixPath(path, prefix)).toBe(output);
}
});
test("suffixPath", () => {
const examples = [
["normal", "", "normal"],
["", "suffix", "suffix"],
["tags", "0", "tags.0"],
["tags", 0, "tags.0"],
["nested.property", "suffix", "nested.property.suffix"],
["nested.property", "", "nested.property"],
] satisfies [string, any, string][];
for (const [path, suffix, output] of examples) {
expect(utils.suffixPath(path, suffix)).toBe(output);
}
});
});
-13
View File
@@ -1,13 +0,0 @@
//import type { BkndConfig } from "./src";
export default {
app: {
connection: {
type: "libsql",
config: {
//url: "http://localhost:8080"
url: ":memory:"
}
}
}
};
-257
View File
@@ -1,257 +0,0 @@
import { $, type Subprocess } from "bun";
import * as esbuild from "esbuild";
import postcss from "esbuild-postcss";
import { entryOutputMeta } from "./internal/esbuild.entry-output-meta.plugin";
import { guessMimeType } from "./src/media/storage/mime-types";
const args = process.argv.slice(2);
const watch = args.includes("--watch");
const minify = args.includes("--minify");
const types = args.includes("--types");
const sourcemap = args.includes("--sourcemap");
type BuildOptions = esbuild.BuildOptions & { name: string };
const baseOptions: Partial<Omit<esbuild.BuildOptions, "plugins">> & { plugins?: any[] } = {
minify,
sourcemap,
metafile: true,
format: "esm",
drop: ["console", "debugger"],
loader: {
".svg": "dataurl"
},
define: {
__isDev: "0"
}
};
// @ts-ignore
type BuildFn = (format?: "esm" | "cjs") => BuildOptions;
// build BE
const builds: Record<string, BuildFn> = {
backend: (format = "esm") => ({
...baseOptions,
name: `backend ${format}`,
entryPoints: [
"src/index.ts",
"src/data/index.ts",
"src/core/index.ts",
"src/core/utils/index.ts",
"src/ui/index.ts",
"src/ui/main.css"
],
outdir: "dist",
outExtension: { ".js": format === "esm" ? ".js" : ".cjs" },
platform: "browser",
splitting: false,
bundle: true,
plugins: [postcss()],
//target: "es2022",
format
}),
/*components: (format = "esm") => ({
...baseOptions,
name: `components ${format}`,
entryPoints: ["src/ui/index.ts", "src/ui/main.css"],
outdir: "dist/ui",
outExtension: { ".js": format === "esm" ? ".js" : ".cjs" },
format,
platform: "browser",
splitting: false,
//target: "es2022",
bundle: true,
//external: ["react", "react-dom", "@tanstack/react-query-devtools"],
plugins: [postcss()],
loader: {
".svg": "dataurl",
".js": "jsx"
}
}),*/
static: (format = "esm") => ({
...baseOptions,
name: `static ${format}`,
entryPoints: ["src/ui/main.tsx", "src/ui/main.css"],
entryNames: "[dir]/[name]-[hash]",
outdir: "dist/static",
outExtension: { ".js": format === "esm" ? ".js" : ".cjs" },
platform: "browser",
bundle: true,
splitting: true,
inject: ["src/ui/inject.js"],
target: "es2022",
format,
loader: {
".svg": "dataurl",
".js": "jsx"
},
define: {
__isDev: "0",
"process.env.NODE_ENV": '"production"'
},
chunkNames: "chunks/[name]-[hash]",
plugins: [
postcss(),
entryOutputMeta(async (info) => {
const manifest: Record<string, object> = {};
const toAsset = (output: string) => {
const name = output.split("/").pop()!;
return {
name,
path: output,
mime: guessMimeType(name)
};
};
for (const { output, meta } of info) {
manifest[meta.entryPoint as string] = toAsset(output);
if (meta.cssBundle) {
manifest["src/ui/main.css"] = toAsset(meta.cssBundle);
}
}
const manifest_file = "dist/static/manifest.json";
await Bun.write(manifest_file, JSON.stringify(manifest, null, 2));
console.log(`Manifest written to ${manifest_file}`, manifest);
})
]
})
};
function adapter(adapter: string, overrides: Partial<esbuild.BuildOptions> = {}): BuildOptions {
return {
...baseOptions,
name: `adapter ${adapter} ${overrides?.format === "cjs" ? "cjs" : "esm"}`,
entryPoints: [`src/adapter/${adapter}`],
platform: "neutral",
outfile: `dist/adapter/${adapter}/index.${overrides?.format === "cjs" ? "cjs" : "js"}`,
external: [
"cloudflare:workers",
"@hono*",
"hono*",
"bknd*",
"*.html",
"node*",
"react*",
"next*",
"libsql",
"@libsql*"
],
splitting: false,
treeShaking: true,
bundle: true,
...overrides
};
}
const adapters = [
adapter("vite", { platform: "node" }),
adapter("cloudflare"),
adapter("nextjs", { platform: "node", format: "esm" }),
adapter("nextjs", { platform: "node", format: "cjs" }),
adapter("remix", { format: "esm" }),
adapter("remix", { format: "cjs" }),
adapter("bun"),
adapter("node", { platform: "node", format: "esm" }),
adapter("node", { platform: "node", format: "cjs" })
];
const collect = [
builds.static(),
builds.backend(),
//builds.components(),
builds.backend("cjs"),
//builds.components("cjs"),
...adapters
];
if (watch) {
const _state: {
timeout: Timer | undefined;
cleanup: Subprocess | undefined;
building: Subprocess | undefined;
} = {
timeout: undefined,
cleanup: undefined,
building: undefined
};
async function rebuildTypes() {
if (!types) return;
if (_state.timeout) {
clearTimeout(_state.timeout);
if (_state.cleanup) _state.cleanup.kill();
if (_state.building) _state.building.kill();
}
_state.timeout = setTimeout(async () => {
_state.cleanup = Bun.spawn(["bun", "clean:types"], {
onExit: () => {
_state.cleanup = undefined;
_state.building = Bun.spawn(["bun", "build:types"], {
onExit: () => {
_state.building = undefined;
console.log("Types rebuilt");
}
});
}
});
}, 1000);
}
for (const { name, ...build } of collect) {
const ctx = await esbuild.context({
...build,
plugins: [
...(build.plugins ?? []),
{
name: "rebuild-notify",
setup(build) {
build.onEnd((result) => {
console.log(`rebuilt ${name} with ${result.errors.length} errors`);
rebuildTypes();
});
}
}
]
});
ctx.watch();
}
} else {
await $`rm -rf dist`;
async function _build() {
let i = 0;
const count = collect.length;
for await (const { name, ...build } of collect) {
await esbuild.build({
...build,
plugins: [
...(build.plugins || []),
{
name: "progress",
setup(build) {
i++;
build.onEnd((result) => {
const errors = result.errors.length;
const from = String(i).padStart(String(count).length);
console.log(`[${from}/${count}] built ${name} with ${errors} errors`);
});
}
}
]
});
}
console.log("All builds complete");
}
async function _buildtypes() {
if (!types) return;
Bun.spawn(["bun", "build:types"], {
onExit: () => {
console.log("Types rebuilt");
}
});
}
await Promise.all([_build(), _buildtypes()]);
}
+209 -130
View File
@@ -1,181 +1,260 @@
import { $ } from "bun";
import * as esbuild from "esbuild";
import postcss from "esbuild-postcss";
import * as tsup from "tsup";
import { guessMimeType } from "./src/media/storage/mime-types";
const args = process.argv.slice(2);
const watch = args.includes("--watch");
const minify = args.includes("--minify");
const types = args.includes("--types");
const sourcemap = args.includes("--sourcemap");
const clean = args.includes("--clean");
if (clean) {
console.log("Cleaning dist (w/o static)");
await $`find dist -mindepth 1 ! -path "dist/static/*" ! -path "dist/static" -exec rm -rf {} +`;
}
let types_running = false;
function buildTypes() {
if (types_running || !types) return;
types_running = true;
await $`rm -rf dist`;
if (types) {
Bun.spawn(["bun", "build:types"], {
stdout: "inherit",
onExit: () => {
console.log("Types built");
}
Bun.spawn(["bun", "tsc-alias"], {
stdout: "inherit",
onExit: () => {
console.log("Types aliased");
types_running = false;
},
});
},
});
}
/**
* Build static assets
* Using esbuild because tsup doesn't include "react"
*/
const result = await esbuild.build({
minify,
sourcemap,
entryPoints: ["src/ui/main.tsx"],
entryNames: "[dir]/[name]-[hash]",
outdir: "dist/static",
platform: "browser",
bundle: true,
splitting: true,
metafile: true,
drop: ["console", "debugger"],
inject: ["src/ui/inject.js"],
target: "es2022",
format: "esm",
plugins: [postcss()],
loader: {
".svg": "dataurl",
".js": "jsx"
},
define: {
__isDev: "0",
"process.env.NODE_ENV": '"production"'
},
chunkNames: "chunks/[name]-[hash]"
});
// Write manifest
{
const manifest: Record<string, object> = {};
const toAsset = (output: string) => {
const name = output.split("/").pop()!;
return {
name,
path: output,
mime: guessMimeType(name)
};
};
const info = Object.entries(result.metafile.outputs)
.filter(([, meta]) => {
return meta.entryPoint && meta.entryPoint === "src/ui/main.tsx";
})
.map(([output, meta]) => ({ output, meta }));
for (const { output, meta } of info) {
manifest[meta.entryPoint as string] = toAsset(output);
if (meta.cssBundle) {
manifest["src/ui/main.css"] = toAsset(meta.cssBundle);
}
let watcher_timeout: any;
function delayTypes() {
if (!watch || !types) return;
if (watcher_timeout) {
clearTimeout(watcher_timeout);
}
const manifest_file = "dist/static/manifest.json";
await Bun.write(manifest_file, JSON.stringify(manifest, null, 2));
console.log(`Manifest written to ${manifest_file}`, manifest);
watcher_timeout = setTimeout(buildTypes, 1000);
}
if (types && !watch) {
buildTypes();
}
function banner(title: string) {
console.log("");
console.log("=".repeat(40));
console.log(title.toUpperCase());
console.log("-".repeat(40));
}
// collection of always-external packages
const external = ["bun:test", "@libsql/client"] as const;
/**
* Building backend and general API
*/
await tsup.build({
minify,
sourcemap,
watch,
entry: ["src/index.ts", "src/data/index.ts", "src/core/index.ts", "src/core/utils/index.ts"],
outDir: "dist",
external: ["bun:test"],
metafile: true,
platform: "browser",
format: ["esm", "cjs"],
splitting: false,
loader: {
".svg": "dataurl"
}
});
async function buildApi() {
banner("Building API");
await tsup.build({
minify,
sourcemap,
watch,
entry: ["src/index.ts", "src/data/index.ts", "src/core/index.ts", "src/core/utils/index.ts"],
outDir: "dist",
external: [...external],
metafile: true,
platform: "browser",
format: ["esm"],
splitting: false,
treeshake: true,
loader: {
".svg": "dataurl",
},
onSuccess: async () => {
delayTypes();
},
});
}
async function rewriteClient(path: string) {
const bundle = await Bun.file(path).text();
await Bun.write(path, '"use client";\n' + bundle.replaceAll("ui/client", "bknd/client"));
}
/**
* Building UI for direct imports
*/
await tsup.build({
minify,
sourcemap,
watch,
entry: ["src/ui/index.ts", "src/ui/client/index.ts", "src/ui/main.css"],
outDir: "dist/ui",
external: ["bun:test"],
metafile: true,
platform: "browser",
format: ["esm", "cjs"],
splitting: true,
loader: {
".svg": "dataurl"
},
onSuccess: async () => {
console.log("--- ui built");
},
esbuildOptions: (options) => {
options.chunkNames = "chunks/[name]-[hash]";
}
});
async function buildUi() {
const base = {
minify,
sourcemap,
watch,
external: [
...external,
"react",
"react-dom",
"react/jsx-runtime",
"react/jsx-dev-runtime",
"use-sync-external-store",
/codemirror/,
"@xyflow/react",
"@mantine/core",
],
metafile: true,
platform: "browser",
format: ["esm"],
splitting: false,
bundle: true,
treeshake: true,
loader: {
".svg": "dataurl",
},
esbuildOptions: (options) => {
options.logLevel = "silent";
},
} satisfies tsup.Options;
banner("Building UI");
await tsup.build({
...base,
entry: ["src/ui/index.ts", "src/ui/main.css", "src/ui/styles.css"],
outDir: "dist/ui",
onSuccess: async () => {
await rewriteClient("./dist/ui/index.js");
delayTypes();
},
});
banner("Building Client");
await tsup.build({
...base,
entry: ["src/ui/client/index.ts"],
outDir: "dist/ui/client",
onSuccess: async () => {
await rewriteClient("./dist/ui/client/index.js");
delayTypes();
},
});
}
/**
* Building UI Elements
* - tailwind-merge is mocked, no exclude
* - ui/client is external, and after built replaced with "bknd/client"
*/
async function buildUiElements() {
banner("Building UI Elements");
await tsup.build({
minify,
sourcemap,
watch,
entry: ["src/ui/elements/index.ts"],
outDir: "dist/ui/elements",
external: [
"ui/client",
"react",
"react-dom",
"react/jsx-runtime",
"react/jsx-dev-runtime",
"use-sync-external-store",
],
metafile: true,
platform: "browser",
format: ["esm"],
splitting: false,
bundle: true,
treeshake: true,
loader: {
".svg": "dataurl",
},
esbuildOptions: (options) => {
options.alias = {
// not important for elements, mock to reduce bundle
"tailwind-merge": "./src/ui/elements/mocks/tailwind-merge.ts",
};
},
onSuccess: async () => {
await rewriteClient("./dist/ui/elements/index.js");
delayTypes();
},
});
}
/**
* Building adapters
*/
function baseConfig(adapter: string): tsup.Options {
function baseConfig(adapter: string, overrides: Partial<tsup.Options> = {}): tsup.Options {
return {
minify,
sourcemap,
watch,
entry: [`src/adapter/${adapter}`],
entry: [`src/adapter/${adapter}/index.ts`],
format: ["esm"],
platform: "neutral",
outDir: `dist/adapter/${adapter}`,
metafile: true,
splitting: false,
onSuccess: async () => {
delayTypes();
},
...overrides,
define: {
__isDev: "0"
__isDev: "0",
...overrides.define,
},
external: [
/^cloudflare*/,
/^@?(hono|libsql).*?/,
/^(bknd|react|next|node).*?/,
/.*\.(html)$/
/.*\.(html)$/,
...(Array.isArray(overrides.external) ? overrides.external : []),
],
metafile: true,
splitting: false,
treeshake: true
};
}
await tsup.build({
...baseConfig("vite"),
platform: "node"
});
async function buildAdapters() {
banner("Building Adapters");
// base adapter handles
await tsup.build({
...baseConfig(""),
entry: ["src/adapter/index.ts"],
outDir: "dist/adapter",
});
await tsup.build({
...baseConfig("cloudflare")
});
// specific adatpers
await tsup.build(baseConfig("react-router"));
await tsup.build(baseConfig("bun"));
await tsup.build(baseConfig("astro"));
await tsup.build(baseConfig("aws"));
await tsup.build(
baseConfig("cloudflare", {
external: [/^kysely/],
}),
);
await tsup.build({
...baseConfig("nextjs"),
format: ["esm", "cjs"],
platform: "node"
});
await tsup.build({
...baseConfig("vite"),
platform: "node",
});
await tsup.build({
...baseConfig("remix"),
format: ["esm", "cjs"]
});
await tsup.build({
...baseConfig("nextjs"),
platform: "node",
});
await tsup.build({
...baseConfig("bun")
});
await tsup.build({
...baseConfig("node"),
platform: "node",
});
}
await tsup.build({
...baseConfig("node"),
platform: "node",
format: ["esm", "cjs"]
});
await buildApi();
await buildUi();
await buildUiElements();
await buildAdapters();
+4 -1
View File
@@ -1,2 +1,5 @@
[install]
registry = "http://localhost:4873"
#registry = "http://localhost:4873"
[test]
coverageSkipTestFiles = true
@@ -5,8 +5,8 @@ export const entryOutputMeta = (
outputs: {
output: string;
meta: Metafile["outputs"][string];
}[]
) => void | Promise<void>
}[],
) => void | Promise<void>,
): Plugin => ({
name: "report-entry-output-plugin",
setup(build) {
@@ -29,5 +29,5 @@ export const entryOutputMeta = (
await onComplete?.(outputs);
}
});
}
},
});
+146 -85
View File
@@ -3,165 +3,208 @@
"type": "module",
"sideEffects": false,
"bin": "./dist/cli/index.js",
"version": "0.1.1",
"version": "0.10.0",
"description": "Lightweight Firebase/Supabase alternative built to run anywhere — incl. Next.js, React Router, Astro, Cloudflare, Bun, Node, AWS Lambda & more.",
"homepage": "https://bknd.io",
"repository": {
"type": "git",
"url": "https://github.com/bknd-io/bknd.git"
},
"bugs": {
"url": "https://github.com/bknd-io/bknd/issues"
},
"scripts": {
"build:all": "bun run build && bun run build:cli",
"dev": "vite",
"test": "ALL_TESTS=1 bun test --bail",
"build": "bun run build.ts --minify --types",
"test:coverage": "ALL_TESTS=1 bun test --bail --coverage",
"build": "NODE_ENV=production bun run build.ts --minify --types",
"build:all": "rm -rf dist && bun run build:static && NODE_ENV=production bun run build.ts --minify --types --clean && bun run build:cli",
"build:cli": "bun build src/cli/index.ts --target node --outdir dist/cli --env PUBLIC_* --minify",
"build:static": "vite build",
"watch": "bun run build.ts --types --watch",
"types": "bun tsc --noEmit",
"types": "bun tsc -p tsconfig.build.json --noEmit",
"clean:types": "find ./dist -name '*.d.ts' -delete && rm -f ./dist/tsconfig.tsbuildinfo",
"build:types": "tsc --emitDeclarationOnly",
"build:css": "bun tailwindcss -i src/ui/main.css -o ./dist/static/styles.css",
"watch:css": "bun tailwindcss --watch -i src/ui/main.css -o ./dist/styles.css",
"build:types": "tsc -p tsconfig.build.json --emitDeclarationOnly && tsc-alias",
"updater": "bun x npm-check-updates -ui",
"build:cli": "bun build src/cli/index.ts --target node --outdir dist/cli --minify",
"cli": "LOCAL=1 bun src/cli/index.ts"
"cli": "LOCAL=1 bun src/cli/index.ts",
"prepublishOnly": "bun run types && bun run test && bun run build:all && cp ../README.md ./",
"postpublish": "rm -f README.md"
},
"license": "FSL-1.1-MIT",
"dependencies": {
"@cfworker/json-schema": "^2.0.1",
"@cfworker/json-schema": "^4.1.1",
"@codemirror/lang-html": "^6.4.9",
"@codemirror/lang-json": "^6.0.1",
"@codemirror/lang-liquid": "^6.2.1",
"@dagrejs/dagre": "^1.1.4",
"@hello-pangea/dnd": "^17.0.0",
"@hono/typebox-validator": "^0.2.6",
"@hono/zod-validator": "^0.4.1",
"@hookform/resolvers": "^3.9.1",
"@codemirror/lang-liquid": "^6.2.2",
"@hello-pangea/dnd": "^18.0.1",
"@libsql/client": "^0.14.0",
"@libsql/kysely-libsql": "^0.4.1",
"@mantine/core": "^7.13.4",
"@mantine/hooks": "^7.13.4",
"@mantine/modals": "^7.13.4",
"@mantine/notifications": "^7.13.5",
"@radix-ui/react-scroll-area": "^1.2.0",
"@rjsf/core": "^5.22.2",
"@sinclair/typebox": "^0.32.34",
"@tabler/icons-react": "3.18.0",
"@tanstack/react-form": "0.19.2",
"@tanstack/react-query": "^5.59.16",
"@uiw/react-codemirror": "^4.23.6",
"@xyflow/react": "^12.3.2",
"aws4fetch": "^1.0.18",
"codemirror-lang-liquid": "^1.0.0",
"@mantine/core": "^7.17.1",
"@mantine/hooks": "^7.17.1",
"@sinclair/typebox": "^0.34.30",
"@tanstack/react-form": "^1.0.5",
"@uiw/react-codemirror": "^4.23.10",
"@xyflow/react": "^12.4.4",
"aws4fetch": "^1.0.20",
"dayjs": "^1.11.13",
"fast-xml-parser": "^4.4.0",
"hono": "^4.6.12",
"jotai": "^2.10.1",
"kysely": "^0.27.4",
"liquidjs": "^10.15.0",
"fast-xml-parser": "^5.0.8",
"hono": "^4.7.4",
"json-schema-form-react": "^0.0.2",
"json-schema-library": "^10.0.0-rc7",
"json-schema-to-ts": "^3.1.1",
"kysely": "^0.27.6",
"liquidjs": "^10.21.0",
"lodash-es": "^4.17.21",
"oauth4webapi": "^2.11.1",
"react-hook-form": "^7.53.1",
"react-icons": "5.2.1",
"react-json-view-lite": "^2.0.1",
"reactflow": "^11.11.4",
"tailwind-merge": "^2.5.4",
"tailwindcss-animate": "^1.0.7",
"wouter": "^3.3.5",
"zod": "^3.23.8",
"zod-to-json-schema": "^3.23.2"
"object-path-immutable": "^4.1.2",
"picocolors": "^1.1.1",
"radix-ui": "^1.1.3",
"swr": "^2.3.3"
},
"devDependencies": {
"@aws-sdk/client-s3": "^3.613.0",
"@hono/node-server": "^1.13.7",
"@hono/vite-dev-server": "^0.17.0",
"@tanstack/react-query-devtools": "^5.59.16",
"@types/diff": "^5.2.3",
"@types/node": "^22.10.0",
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
"@vitejs/plugin-react": "^4.3.3",
"autoprefixer": "^10.4.20",
"esbuild-postcss": "^0.0.4",
"node-fetch": "^3.3.2",
"@aws-sdk/client-s3": "^3.758.0",
"@bluwy/giget-core": "^0.1.2",
"@dagrejs/dagre": "^1.1.4",
"@hono/typebox-validator": "^0.3.2",
"@hono/vite-dev-server": "^0.19.0",
"@hookform/resolvers": "^4.1.3",
"@libsql/kysely-libsql": "^0.4.1",
"@mantine/modals": "^7.17.1",
"@mantine/notifications": "^7.17.1",
"@rjsf/core": "5.22.2",
"@tabler/icons-react": "3.18.0",
"@tailwindcss/postcss": "^4.0.12",
"@tailwindcss/vite": "^4.0.12",
"@types/node": "^22.13.10",
"@types/react": "^19.0.10",
"@types/react-dom": "^19.0.4",
"@vitejs/plugin-react": "^4.3.4",
"autoprefixer": "^10.4.21",
"clsx": "^2.1.1",
"dotenv": "^16.4.7",
"jotai": "^2.12.2",
"kysely-d1": "^0.3.0",
"open": "^10.1.0",
"openapi-types": "^12.1.3",
"postcss": "^8.4.47",
"postcss": "^8.5.3",
"postcss-preset-mantine": "^1.17.0",
"postcss-simple-vars": "^7.0.1",
"tailwindcss": "^3.4.14",
"tsup": "^8.3.5",
"vite": "^5.4.10",
"vite-plugin-static-copy": "^2.0.0",
"vite-tsconfig-paths": "^5.0.1"
"posthog-js-lite": "^3.4.2",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-hook-form": "^7.54.2",
"react-icons": "5.2.1",
"react-json-view-lite": "^2.4.1",
"sql-formatter": "^15.4.11",
"tailwind-merge": "^3.0.2",
"tailwindcss": "^4.0.12",
"tailwindcss-animate": "^1.0.7",
"tsc-alias": "^1.8.11",
"tsup": "^8.4.0",
"vite": "^6.2.1",
"vite-tsconfig-paths": "^5.1.4",
"wouter": "^3.6.0"
},
"optionalDependencies": {
"@hono/node-server": "^1.13.8"
},
"peerDependencies": {
"react": ">=18",
"react-dom": ">=18"
"react": ">=19",
"react-dom": ">=19"
},
"main": "./dist/index.js",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"types": "./dist/types/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"types": "./dist/types/index.d.ts",
"import": "./dist/index.js",
"require": "./dist/index.cjs"
},
"./ui": {
"types": "./dist/ui/index.d.ts",
"types": "./dist/types/ui/index.d.ts",
"import": "./dist/ui/index.js",
"require": "./dist/ui/index.cjs"
},
"./elements": {
"types": "./dist/types/ui/elements/index.d.ts",
"import": "./dist/ui/elements/index.js",
"require": "./dist/ui/elements/index.cjs"
},
"./client": {
"types": "./dist/ui/client/index.d.ts",
"types": "./dist/types/ui/client/index.d.ts",
"import": "./dist/ui/client/index.js",
"require": "./dist/ui/client/index.cjs"
},
"./data": {
"types": "./dist/data/index.d.ts",
"types": "./dist/types/data/index.d.ts",
"import": "./dist/data/index.js",
"require": "./dist/data/index.cjs"
},
"./core": {
"types": "./dist/core/index.d.ts",
"types": "./dist/types/core/index.d.ts",
"import": "./dist/core/index.js",
"require": "./dist/core/index.cjs"
},
"./utils": {
"types": "./dist/core/utils/index.d.ts",
"types": "./dist/types/core/utils/index.d.ts",
"import": "./dist/core/utils/index.js",
"require": "./dist/core/utils/index.cjs"
},
"./cli": {
"types": "./dist/cli/index.d.ts",
"types": "./dist/types/cli/index.d.ts",
"import": "./dist/cli/index.js",
"require": "./dist/cli/index.cjs"
},
"./adapter/cloudflare": {
"types": "./dist/adapter/cloudflare/index.d.ts",
"types": "./dist/types/adapter/cloudflare/index.d.ts",
"import": "./dist/adapter/cloudflare/index.js",
"require": "./dist/adapter/cloudflare/index.cjs"
},
"./adapter": {
"types": "./dist/types/adapter/index.d.ts",
"import": "./dist/adapter/index.js"
},
"./adapter/vite": {
"types": "./dist/adapter/vite/index.d.ts",
"types": "./dist/types/adapter/vite/index.d.ts",
"import": "./dist/adapter/vite/index.js",
"require": "./dist/adapter/vite/index.cjs"
},
"./adapter/nextjs": {
"types": "./dist/adapter/nextjs/index.d.ts",
"types": "./dist/types/adapter/nextjs/index.d.ts",
"import": "./dist/adapter/nextjs/index.js",
"require": "./dist/adapter/nextjs/index.cjs"
},
"./adapter/remix": {
"types": "./dist/adapter/remix/index.d.ts",
"import": "./dist/adapter/remix/index.js",
"require": "./dist/adapter/remix/index.cjs"
"./adapter/react-router": {
"types": "./dist/types/adapter/react-router/index.d.ts",
"import": "./dist/adapter/react-router/index.js",
"require": "./dist/adapter/react-router/index.cjs"
},
"./adapter/bun": {
"types": "./dist/adapter/bun/index.d.ts",
"types": "./dist/types/adapter/bun/index.d.ts",
"import": "./dist/adapter/bun/index.js",
"require": "./dist/adapter/bun/index.cjs"
},
"./adapter/node": {
"types": "./dist/adapter/node/index.d.ts",
"types": "./dist/types/adapter/node/index.d.ts",
"import": "./dist/adapter/node/index.js",
"require": "./dist/adapter/node/index.cjs"
},
"./dist/styles.css": "./dist/ui/main.css",
"./dist/manifest.json": "./dist/static/manifest.json"
"./adapter/astro": {
"types": "./dist/types/adapter/astro/index.d.ts",
"import": "./dist/adapter/astro/index.js",
"require": "./dist/adapter/astro/index.cjs"
},
"./adapter/aws": {
"types": "./dist/types/adapter/aws/index.d.ts",
"import": "./dist/adapter/aws/index.js",
"require": "./dist/adapter/aws/index.cjs"
},
"./dist/main.css": "./dist/ui/main.css",
"./dist/styles.css": "./dist/ui/styles.css",
"./dist/manifest.json": "./dist/static/.vite/manifest.json"
},
"publishConfig": {
"access": "public"
},
"files": [
"dist",
@@ -169,6 +212,24 @@
"!dist/*.tsbuildinfo",
"!dist/*.map",
"!dist/**/*.map",
"!dist/metafile*"
"!dist/metafile*",
"!dist/**/metafile*"
],
"keywords": [
"backend",
"database",
"authentication",
"media",
"workflows",
"api",
"jwt",
"serverless",
"cloudflare",
"nextjs",
"remix",
"react-router",
"astro",
"bun",
"node"
]
}
+5 -8
View File
@@ -1,9 +1,6 @@
export default {
plugins: {
"postcss-import": {},
"tailwindcss/nesting": {},
tailwindcss: {},
autoprefixer: {},
"@tailwindcss/postcss": {},
"postcss-preset-mantine": {},
"postcss-simple-vars": {
variables: {
@@ -11,8 +8,8 @@ export default {
"mantine-breakpoint-sm": "48em",
"mantine-breakpoint-md": "62em",
"mantine-breakpoint-lg": "75em",
"mantine-breakpoint-xl": "88em"
}
}
}
"mantine-breakpoint-xl": "88em",
},
},
},
};
+113 -29
View File
@@ -1,11 +1,12 @@
import type { SafeUser } from "auth";
import { AuthApi } from "auth/api/AuthApi";
import { DataApi } from "data/api/DataApi";
import { decode } from "hono/jwt";
import { omit } from "lodash-es";
import { MediaApi } from "media/api/MediaApi";
import { SystemApi } from "modules/SystemApi";
import { omitKeys } from "core/utils";
export type TApiUser = object;
export type TApiUser = SafeUser;
declare global {
interface Window {
@@ -16,12 +17,27 @@ declare global {
}
export type ApiOptions = {
host: string;
user?: TApiUser;
token?: string;
host?: string;
headers?: Headers;
key?: string;
localStorage?: boolean;
fetcher?: typeof fetch;
verbose?: boolean;
verified?: boolean;
} & (
| {
token?: string;
user?: TApiUser;
}
| {
request: Request;
}
);
export type AuthState = {
token?: string;
user?: TApiUser;
verified: boolean;
};
export class Api {
@@ -35,14 +51,26 @@ export class Api {
public auth!: AuthApi;
public media!: MediaApi;
constructor(private readonly options: ApiOptions) {
if (options.user) {
this.user = options.user;
this.token_transport = "none";
this.verified = true;
} else if (options.token) {
constructor(private options: ApiOptions = {}) {
// only mark verified if forced
this.verified = options.verified === true;
// prefer request if given
if ("request" in options) {
this.options.host = options.host ?? new URL(options.request.url).origin;
this.options.headers = options.headers ?? options.request.headers;
this.extractToken();
// then check for a token
} else if ("token" in options) {
this.token_transport = "header";
this.updateToken(options.token);
// then check for an user object
} else if ("user" in options) {
this.token_transport = "none";
this.user = options.user;
this.verified = options.verified !== false;
} else {
this.extractToken();
}
@@ -50,18 +78,24 @@ export class Api {
this.buildApis();
}
get baseUrl() {
return this.options.host ?? "http://localhost";
}
get tokenKey() {
return this.options.key ?? "auth";
}
private extractToken() {
// if token has to be extracted, it's never verified
this.verified = false;
if (this.options.headers) {
// try cookies
const cookieToken = getCookieValue(this.options.headers.get("cookie"), "auth");
if (cookieToken) {
this.updateToken(cookieToken);
this.token_transport = "cookie";
this.verified = true;
this.updateToken(cookieToken);
return;
}
@@ -85,7 +119,13 @@ export class Api {
updateToken(token?: string, rebuild?: boolean) {
this.token = token;
this.user = token ? omit(decode(token).payload as any, ["iat", "iss", "exp"]) : undefined;
this.verified = false;
if (token) {
this.user = omitKeys(decode(token).payload as any, ["iat", "iss", "exp"]) as any;
} else {
this.user = undefined;
}
if (this.options.localStorage) {
const key = this.tokenKey;
@@ -100,38 +140,82 @@ export class Api {
if (rebuild) this.buildApis();
}
markAuthVerified(verfied: boolean) {
private markAuthVerified(verfied: boolean) {
this.verified = verfied;
return this;
}
getAuthState() {
isAuthVerified(): boolean {
return this.verified;
}
getAuthState(): AuthState {
return {
token: this.token,
user: this.user,
verified: this.verified
verified: this.verified,
};
}
isAuthenticated(): boolean {
const { token, user } = this.getAuthState();
return !!token && !!user;
}
async getVerifiedAuthState(): Promise<AuthState> {
await this.verifyAuth();
return this.getAuthState();
}
async verifyAuth() {
if (!this.token) {
this.markAuthVerified(false);
return;
}
try {
const { ok, data } = await this.auth.me();
const user = data?.user;
if (!ok || !user) {
throw new Error();
}
this.user = user;
this.markAuthVerified(true);
} catch (e) {
this.markAuthVerified(false);
this.updateToken(undefined);
}
}
getUser(): TApiUser | null {
return this.user || null;
}
private buildApis() {
const baseParams = {
host: this.options.host,
getParams() {
return Object.freeze({
host: this.baseUrl,
token: this.token,
headers: this.options.headers,
token_transport: this.token_transport
};
this.system = new SystemApi(baseParams);
this.data = new DataApi(baseParams);
this.auth = new AuthApi({
...baseParams,
onTokenUpdate: (token) => this.updateToken(token, true)
token_transport: this.token_transport,
verbose: this.options.verbose,
});
this.media = new MediaApi(baseParams);
}
private buildApis() {
const baseParams = this.getParams();
const fetcher = this.options.fetcher;
this.system = new SystemApi(baseParams, fetcher);
this.data = new DataApi(baseParams, fetcher);
this.auth = new AuthApi(
{
...baseParams,
onTokenUpdate: (token) => this.updateToken(token, true),
},
fetcher,
);
this.media = new MediaApi(baseParams, fetcher);
}
}
+156 -60
View File
@@ -1,122 +1,166 @@
import type { CreateUserPayload } from "auth/AppAuth";
import { $console } from "core";
import { Event } from "core/events";
import { Connection, type LibSqlCredentials, LibsqlConnection } from "data";
import type { Hono } from "hono";
import {
type InitialModuleConfigs,
type ModuleBuildContext,
ModuleManager,
type ModuleManagerOptions,
type Modules
type Modules,
} from "modules/ModuleManager";
import * as SystemPermissions from "modules/permissions";
import { AdminController, type AdminControllerOptions } from "modules/server/AdminController";
import { SystemController } from "modules/server/SystemController";
export type AppPlugin<DB> = (app: App<DB>) => void;
// biome-ignore format: must be there
import { Api, type ApiOptions } from "Api";
export class AppConfigUpdatedEvent extends Event<{ app: App }> {
export type AppPlugin = (app: App) => Promise<void> | void;
abstract class AppEvent<A = {}> extends Event<{ app: App } & A> {}
export class AppConfigUpdatedEvent extends AppEvent {
static override slug = "app-config-updated";
}
export class AppBuiltEvent extends Event<{ app: App }> {
export class AppBuiltEvent extends AppEvent {
static override slug = "app-built";
}
export const AppEvents = { AppConfigUpdatedEvent, AppBuiltEvent } as const;
export class AppFirstBoot extends AppEvent {
static override slug = "app-first-boot";
}
export const AppEvents = { AppConfigUpdatedEvent, AppBuiltEvent, AppFirstBoot } as const;
export type AppOptions = {
plugins?: AppPlugin[];
seed?: (ctx: ModuleBuildContext & { app: App }) => Promise<void>;
manager?: Omit<ModuleManagerOptions, "initial" | "onUpdated" | "seed">;
};
export type CreateAppConfig = {
connection:
connection?:
| Connection
| {
// @deprecated
type: "libsql";
config: LibSqlCredentials;
};
}
| LibSqlCredentials;
initialConfig?: InitialModuleConfigs;
plugins?: AppPlugin<any>[];
options?: ModuleManagerOptions;
options?: AppOptions;
};
export type AppConfig = InitialModuleConfigs;
export type LocalApiOptions = Request | ApiOptions;
export class App<DB = any> {
export class App {
modules: ModuleManager;
static readonly Events = AppEvents;
adminController?: AdminController;
private trigger_first_boot = false;
private plugins: AppPlugin[];
private _id: string = crypto.randomUUID();
private _building: boolean = false;
constructor(
private connection: Connection,
_initialConfig?: InitialModuleConfigs,
private plugins: AppPlugin<DB>[] = [],
moduleManagerOptions?: ModuleManagerOptions
private options?: AppOptions,
) {
this.plugins = options?.plugins ?? [];
this.modules = new ModuleManager(connection, {
...moduleManagerOptions,
...(options?.manager ?? {}),
initial: _initialConfig,
onUpdated: async (key, config) => {
//console.log("[APP] config updated", key, config);
await this.build({ sync: true, save: true });
// if the EventManager was disabled, we assume we shouldn't
// respond to events, such as "onUpdated".
// this is important if multiple changes are done, and then build() is called manually
if (!this.emgr.enabled) {
$console.warn("App config updated, but event manager is disabled, skip.");
return;
}
$console.log("App config updated", key);
// @todo: potentially double syncing
await this.build({ sync: true });
await this.emgr.emit(new AppConfigUpdatedEvent({ app: this }));
}
},
onFirstBoot: async () => {
$console.log("App first boot");
this.trigger_first_boot = true;
},
onServerInit: async (server) => {
server.use(async (c, next) => {
c.set("app", this);
await next();
try {
// gracefully add the app id
c.res.headers.set("X-bknd-id", this._id);
} catch (e) {}
});
},
});
this.modules.ctx().emgr.registerEvents(AppEvents);
}
static create(config: CreateAppConfig) {
let connection: Connection | undefined = undefined;
if (Connection.isConnection(config.connection)) {
connection = config.connection;
} else if (typeof config.connection === "object") {
switch (config.connection.type) {
case "libsql":
connection = new LibsqlConnection(config.connection.config);
break;
}
} else {
throw new Error(`Unknown connection of type ${typeof config.connection} given.`);
}
if (!connection) {
throw new Error("Invalid connection");
}
return new App(connection, config.initialConfig, config.plugins, config.options);
}
get emgr() {
return this.modules.ctx().emgr;
}
async build(options?: { sync?: boolean; drop?: boolean; save?: boolean }) {
await this.modules.build();
if (options?.sync) {
const syncResult = await this.module.data.em
.schema()
.sync({ force: true, drop: options.drop });
//console.log("syncing", syncResult);
async build(options?: { sync?: boolean; fetch?: boolean; forceBuild?: boolean }) {
// prevent multiple concurrent builds
if (this._building) {
while (this._building) {
await new Promise((resolve) => setTimeout(resolve, 10));
}
if (!options?.forceBuild) return;
}
this._building = true;
if (options?.sync) this.modules.ctx().flags.sync_required = true;
await this.modules.build({ fetch: options?.fetch });
const { guard, server } = this.modules.ctx();
// load system controller
this.modules.ctx().guard.registerPermissions(Object.values(SystemPermissions));
this.modules.server.route("/api/system", new SystemController(this).getController());
guard.registerPermissions(Object.values(SystemPermissions));
server.route("/api/system", new SystemController(this).getController());
// load plugins
if (this.plugins.length > 0) {
this.plugins.forEach((plugin) => plugin(this));
await Promise.all(this.plugins.map((plugin) => plugin(this)));
}
//console.log("emitting built", options);
$console.log("App built");
await this.emgr.emit(new AppBuiltEvent({ app: this }));
// not found on any not registered api route
this.modules.server.all("/api/*", async (c) => c.notFound());
if (options?.save) {
await this.modules.save();
// first boot is set from ModuleManager when there wasn't a config table
if (this.trigger_first_boot) {
this.trigger_first_boot = false;
await this.emgr.emit(new AppFirstBoot({ app: this }));
await this.options?.seed?.({
...this.modules.ctx(),
app: this,
});
}
this._building = false;
}
mutateConfig<Module extends keyof Modules>(module: Module) {
return this.modules.get(module).schema();
return this.modules.mutateConfigSafe(module);
}
get fetch(): any {
return this.modules.server.fetch;
get server() {
return this.modules.server;
}
get em() {
return this.modules.ctx().em;
}
get fetch(): Hono["fetch"] {
return this.server.fetch;
}
get module() {
@@ -125,8 +169,8 @@ export class App<DB = any> {
{
get: (_, module: keyof Modules) => {
return this.modules.get(module);
}
}
},
},
) as Modules;
}
@@ -138,13 +182,65 @@ export class App<DB = any> {
return this.modules.version();
}
isBuilt(): boolean {
return this.modules.isBuilt();
}
registerAdminController(config?: AdminControllerOptions) {
// register admin
this.modules.server.route("/", new AdminController(this, config).getController());
this.adminController = new AdminController(this, config);
this.modules.server.route(config?.basepath ?? "/", this.adminController.getController());
return this;
}
toJSON(secrets?: boolean) {
return this.modules.toJSON(secrets);
}
static create(config: CreateAppConfig) {
return createApp(config);
}
async createUser(p: CreateUserPayload) {
return this.module.auth.createUser(p);
}
getApi(options?: LocalApiOptions) {
const fetcher = this.server.request as typeof fetch;
if (options && options instanceof Request) {
return new Api({ request: options, headers: options.headers, fetcher });
}
return new Api({ host: "http://localhost", ...(options ?? {}), fetcher });
}
}
export function createApp(config: CreateAppConfig = {}) {
let connection: Connection | undefined = undefined;
try {
if (Connection.isConnection(config.connection)) {
connection = config.connection;
} else if (typeof config.connection === "object") {
if ("type" in config.connection) {
$console.warn(
"Using deprecated connection type 'libsql', use the 'config' object directly.",
);
connection = new LibsqlConnection(config.connection.config);
} else {
connection = new LibsqlConnection(config.connection);
}
} else {
connection = new LibsqlConnection({ url: ":memory:" });
$console.warn("No connection provided, using in-memory database");
}
} catch (e) {
$console.error("Could not create connection", e);
}
if (!connection) {
throw new Error("Invalid connection");
}
return new App(connection, config.initialConfig, config.options);
}
+34
View File
@@ -0,0 +1,34 @@
import type { App } from "bknd";
import { type FrameworkBkndConfig, createFrameworkApp } from "bknd/adapter";
import { Api, type ApiOptions } from "bknd/client";
export type AstroBkndConfig<Args = TAstro> = FrameworkBkndConfig<Args>;
type TAstro = {
request: Request;
};
export type Options = {
mode?: "static" | "dynamic";
} & Omit<ApiOptions, "host"> & {
host?: string;
};
export async function getApi(Astro: TAstro, options: Options = { mode: "static" }) {
const api = new Api({
host: new URL(Astro.request.url).origin,
headers: options.mode === "dynamic" ? Astro.request.headers : undefined,
});
await api.verifyAuth();
return api;
}
let app: App;
export function serve<Context extends TAstro = TAstro>(config: AstroBkndConfig<Context> = {}) {
return async (args: Context) => {
if (!app) {
app = await createFrameworkApp(config, args);
}
return app.fetch(args.request);
};
}
+1
View File
@@ -0,0 +1 @@
export * from "./astro.adapter";
+68
View File
@@ -0,0 +1,68 @@
import type { App } from "bknd";
import { handle } from "hono/aws-lambda";
import { type RuntimeBkndConfig, createRuntimeApp } from "bknd/adapter";
export type AwsLambdaBkndConfig = RuntimeBkndConfig & {
assets?:
| {
mode: "local";
root: string;
}
| {
mode: "url";
url: string;
};
};
let app: App;
export async function createApp({
adminOptions = false,
assets,
...config
}: AwsLambdaBkndConfig = {}) {
if (!app) {
let additional: Partial<RuntimeBkndConfig> = {
adminOptions,
};
if (assets?.mode) {
switch (assets.mode) {
case "local":
// @todo: serve static outside app context
additional = {
adminOptions: adminOptions === false ? undefined : adminOptions,
serveStatic: (await import("@hono/node-server/serve-static")).serveStatic({
root: assets.root,
onFound: (path, c) => {
c.res.headers.set("Cache-Control", "public, max-age=31536000");
},
}),
};
break;
case "url":
additional.adminOptions = {
...(typeof adminOptions === "object" ? adminOptions : {}),
assets_path: assets.url,
};
break;
default:
throw new Error("Invalid assets mode");
}
}
app = await createRuntimeApp({
...config,
...additional,
});
}
return app;
}
export function serveLambda(config: AwsLambdaBkndConfig = {}) {
console.log("serving lambda");
return async (event) => {
const app = await createApp(config);
return await handle(app.server)(event);
};
}
+1
View File
@@ -0,0 +1 @@
export * from "./aws-lambda.adapter";
+49 -46
View File
@@ -1,55 +1,58 @@
/// <reference types="bun-types" />
import path from "node:path";
import { App, type CreateAppConfig } from "bknd";
import { LibsqlConnection } from "bknd/data";
import type { App } from "bknd";
import { type RuntimeBkndConfig, createRuntimeApp } from "bknd/adapter";
import { registerLocalMediaAdapter } from "bknd/adapter/node";
import { config } from "bknd/core";
import type { ServeOptions } from "bun";
import { serveStatic } from "hono/bun";
async function getConnection(conn?: CreateAppConfig["connection"]) {
if (conn) {
if (LibsqlConnection.isConnection(conn)) {
return conn;
}
let app: App;
return new LibsqlConnection(conn.config);
}
export type BunBkndConfig = RuntimeBkndConfig & Omit<ServeOptions, "fetch">;
const createClient = await import("@libsql/client/node").then((m) => m.createClient);
if (!createClient) {
throw new Error('libsql client not found, you need to install "@libsql/client/node"');
}
console.log("Using in-memory database");
return new LibsqlConnection(createClient({ url: ":memory:" }));
}
export function serve(_config: Partial<CreateAppConfig> = {}, distPath?: string) {
export async function createApp({ distPath, ...config }: RuntimeBkndConfig = {}) {
const root = path.resolve(distPath ?? "./node_modules/bknd/dist", "static");
let app: App;
return async (req: Request) => {
if (!app) {
const connection = await getConnection(_config.connection);
app = App.create({
..._config,
connection
});
if (!app) {
registerLocalMediaAdapter();
app = await createRuntimeApp({
...config,
serveStatic: serveStatic({ root }),
});
}
app.emgr.on(
"app-built",
async () => {
app.modules.server.get(
"/*",
serveStatic({
root
})
);
app.registerAdminController();
},
"sync"
);
await app.build();
}
return app.fetch(req);
};
return app;
}
export function serve({
distPath,
connection,
initialConfig,
options,
port = config.server.default_port,
onBuilt,
buildConfig,
adminOptions,
...serveOptions
}: BunBkndConfig = {}) {
Bun.serve({
...serveOptions,
port,
fetch: async (request: Request) => {
const app = await createApp({
connection,
initialConfig,
options,
onBuilt,
buildConfig,
adminOptions,
distPath,
});
return app.fetch(request);
},
});
console.log(`Server is running on http://localhost:${port}`);
}
@@ -0,0 +1,63 @@
/// <reference types="@cloudflare/workers-types" />
import { KyselyPluginRunner, SqliteConnection, SqliteIntrospector } from "bknd/data";
import type { QB } from "data/connection/Connection";
import { type DatabaseIntrospector, Kysely, ParseJSONResultsPlugin } from "kysely";
import { D1Dialect } from "kysely-d1";
export type D1ConnectionConfig = {
binding: D1Database;
};
class CustomD1Dialect extends D1Dialect {
override createIntrospector(db: Kysely<any>): DatabaseIntrospector {
return new SqliteIntrospector(db, {
excludeTables: ["_cf_KV", "_cf_METADATA"],
});
}
}
export class D1Connection extends SqliteConnection {
protected override readonly supported = {
batching: true,
};
constructor(private config: D1ConnectionConfig) {
const plugins = [new ParseJSONResultsPlugin()];
const kysely = new Kysely({
dialect: new CustomD1Dialect({ database: config.binding }),
plugins,
});
super(kysely, {}, plugins);
}
get client(): D1Database {
return this.config.binding;
}
protected override async batch<Queries extends QB[]>(
queries: [...Queries],
): Promise<{
[K in keyof Queries]: Awaited<ReturnType<Queries[K]["execute"]>>;
}> {
const db = this.config.binding;
const res = await db.batch(
queries.map((q) => {
const { sql, parameters } = q.compile();
return db.prepare(sql).bind(...parameters);
}),
);
// let it run through plugins
const kyselyPlugins = new KyselyPluginRunner(this.plugins);
const data: any = [];
for (const r of res) {
const rows = await kyselyPlugins.transformResultRows(r.results);
data.push(rows);
}
return data;
}
}

Some files were not shown because too many files have changed in this diff Show More