Compare commits

...

857 Commits

Author SHA1 Message Date
Cameron Pak 8c596781ff Merge pull request #380 from shishantbiswas/next-docs
add (docs): section for production build issue, React SDK setup
2026-03-28 06:06:29 -05:00
shishantbiswas 43cbdb3010 add(docs): update next.js docs to use colocation for <Admin /> component 2026-03-28 15:31:43 +05:30
shishantbiswas b3c9f258de add: prod section at the end 2026-03-28 14:55:38 +05:30
dswbx feb3911d46 Merge pull request #322 from bknd-io/fix/auth-strategy-respect-register-setting
fix(auth): `allow_register` was not respected in strategy controllers
2026-03-14 13:43:55 +01:00
dswbx ab545d3ca2 Merge pull request #347 from bknd-io/fix/with-builder-respect-alias
fix: `WithBuilder` produced embedding errored when `mappedBy` was used
2026-03-14 13:42:50 +01:00
dswbx 06c3a6f89b Merge remote-tracking branch 'origin/main' into fix/with-builder-respect-alias
# Conflicts:
#	bun.lock
2026-03-14 13:41:01 +01:00
dswbx 898a825f6e fix(WithBuilder test): updated snapshots 2026-03-14 13:40:35 +01:00
dswbx a5ddf6aa93 Merge pull request #363 from bknd-io/release/0.21
Release 0.21
2026-03-14 13:34:23 +01:00
dswbx 5aa286298c Merge remote-tracking branch 'origin/main' into release/0.21
# Conflicts:
#	bun.lock
2026-03-14 13:31:45 +01:00
dswbx 55cb2bacef chore: upgrade bun.lock 2026-03-14 13:29:47 +01:00
dswbx e6ea3f64d7 Merge pull request #351 from shishantbiswas/main
feat: add Tanstack Start adapter
2026-03-14 13:25:35 +01:00
dswbx a5f5ea2084 Merge pull request #339 from cameronapak/allow-seed
Allow CLI `sync` command to seed data properly when schema changes aren't present
2026-03-14 13:18:33 +01:00
dswbx 6fb8e9a0e7 Merge pull request #218 from cameronapak/cp/216-fix-users-link
Fix Sidebar Users Route Links
2026-03-14 13:12:53 +01:00
dswbx abc6de6ad3 Merge pull request #332 from bknd-io/feat/timestamps-index
feat(plugin/timestamps): add indexStrategy option for entity indexing
2026-03-14 13:07:49 +01:00
Shishant Biswas 71b9a0f546 Merge branch 'bknd-io:main' into main 2026-03-13 17:27:35 +05:30
Cameron Pak 038ec0d672 Merge pull request #357 from cameronapak/cp/add-contributing-md
Adding CONTRIBUTING.md doc
2026-03-08 14:16:10 -05:00
Cameron Pak 7832328a50 Merge pull request #359 from jonaspm/patch-1
Update client:only attribute in astro.mdx
2026-03-03 11:37:59 -06:00
Jonás Perusquía Morales e5bfc602e1 Update client:only attribute in astro.mdx
Astro requires client:only to specify the frontend library used
2026-03-03 11:27:59 -06:00
Cameron Pak 0318e595f2 docs: add CONTRIBUTING.md with dev setup, project structure, and contribution guidelines
Co-authored-by: Amp <amp@ampcode.com>
Amp-Thread-ID: https://ampcode.com/threads/T-019ca575-cf6e-7388-9bd4-6ddd4c27609c
2026-02-28 12:40:44 -06:00
shishantbiswas 80b0716a4a Remove unrelated Next.js file changes 2026-02-28 23:32:20 +05:30
Cameron Pak 3894271b17 Merge pull request #353 from jonaspm/docs/auth-change-password
docs: Document changePassword method in auth module
2026-02-28 11:38:33 -06:00
shishantbiswas 8bf8b22e57 refactor: remove duplicate tsup build config and clean up 2026-02-28 23:05:38 +05:30
Cameron Pak 367d5878df Fixed test 2026-02-28 11:26:27 -06:00
Cameron Pak fb429e57e2 fix: resolve undefined variable in MediaApi test and remove dead comment
Amp-Thread-ID: https://ampcode.com/threads/T-019ca53d-a225-706c-923e-28123c84d83e
Co-authored-by: Amp <amp@ampcode.com>
2026-02-28 11:21:39 -06:00
Cameron Pak 9008f9c6a3 fix: normalize admin basepath to prevent double slashes in URLs
Strip trailing slashes from adminBasepath in AdminController window context
and wouter Router base to prevent paths like /admin//data when users
configure adminBasepath with a trailing slash.

Amp-Thread-ID: https://ampcode.com/threads/T-019ca537-4cc4-7174-bf9a-5325d782f097
Co-authored-by: Amp <amp@ampcode.com>
2026-02-28 11:11:44 -06:00
Cameron Pak 6321a9935a Merge branch 'main' into cp/216-fix-users-link 2026-02-28 11:03:54 -06:00
Cameron Pak eb22289460 Update @types/bun to 1.3.9
This commit updates the `@types/bun` package to version 1.3.9.
2026-02-28 11:00:37 -06:00
shishantbiswas c3198394de docs: add tanstack start integration guide 2026-02-26 09:40:42 +05:30
shishantbiswas 51237f5e1a docs(tanstack-start): add JSDoc comments to adapter functions 2026-02-26 09:40:42 +05:30
shishantbiswas 01483b912f add: example for tanstack start 2026-02-26 09:40:42 +05:30
shishantbiswas 6288faef33 Triggering CI 2026-02-26 09:40:42 +05:30
shishantbiswas 384e337bbd add: Tanstack Start adapter 2026-02-26 09:38:28 +05:30
Jonas Perusquia Morales cc1e1976f7 chore: Document error cases for password update method 2026-02-23 13:03:37 -06:00
dswbx 224d98879a Merge pull request #356 from jonaspm/fix/cross-platform-cleanup
Make dist clean cross-platform without shell commands
2026-02-20 18:00:46 +01:00
Jonas Perusquia Morales d1522c97ea Make dist clean cross-platform without shell commands 2026-02-20 09:50:30 -06:00
Jonas Perusquia Morales 8805f017a1 Update auth docs to clarify server module usage 2026-02-18 11:12:14 -06:00
Jonas Perusquia Morales 0ef06375c0 docs: Document changePassword method in auth module 2026-02-18 11:03:58 -06:00
dswbx 3d5c2cd3a8 Merge pull request #350 from xretic/main
refactor adapter tests for improved structure
2026-02-11 10:02:56 +01:00
roma fc60dfd042 refactor adapter tests for improved structure
reduced test duplication by extracting a small helper to strip connection from configs
2026-02-09 03:07:58 +01:00
Cameron Pak c8ee30b3da Merge pull request #348 from bknd-io/intro-docs-updates
Docs: consolidate dual introductions, restructure setup page
2026-02-07 17:23:21 -06:00
Cameron Pak b85c3a8a9b Remove redundant redirects config 2026-02-07 17:17:36 -06:00
Cameron Pak e20c2ef92c Docs: move redirects to global config, remove client-side redirect page
Co-Authored-By: Craft Agent <agents-noreply@craft.do>
2026-02-07 17:14:11 -06:00
Cameron Pak 5873baad3c Docs: add redirect from /usage/introduction to /usage/setup
Co-Authored-By: Craft Agent <agents-noreply@craft.do>
2026-02-07 17:09:36 -06:00
Cameron Pak aca162f9c8 Refactor setup documentation for clarity 2026-02-07 16:58:49 -06:00
Cameron Pak 9f0997c28a Refactor and update documentation links for introduction 2026-02-07 16:50:51 -06:00
dswbx 1c114d9533 fix: WithBuilder produced embedding errored when mappedBy was used 2026-02-03 18:15:40 +01:00
dswbx 68063986d1 Merge pull request #343 from cameronapak/doc-touchups 2026-01-31 11:22:25 +01:00
Cameron Pak 53f8609e9d Docs: updating copy to reflect bknd's open opt-in philosophy 2026-01-30 03:39:53 -06:00
Cameron Pak 833bfa5049 Docs: Enhance the pun
https://github.com/bknd-io/bknd/pull/343#discussion_r2743055999
2026-01-30 03:28:12 -06:00
dswbx defcdfef0b Merge pull request #342 from cameronapak/guard-config
Docs: Guard lockout and how to set up Guard properly
2026-01-29 19:39:42 +01:00
cameronapak 348ac7f8b0 Docs: Refactor start page for clarity and flow 2026-01-25 15:20:20 -06:00
cameronapak 16b1cde8ea Docs: Add beta warning callout to start page 2026-01-25 13:58:43 -06:00
cameronapak 436b195242 Docs: Add warning about Guard lockout
Adds a callout to the `config.mdx` documentation warning users that
enabling the Guard without first creating an admin user will lock them
out.
2026-01-25 13:30:31 -06:00
dswbx ccd504440e Merge pull request #340 from bknd-io/chore/license
update license to Apache-2.0 in package.json
2026-01-21 13:30:02 +01:00
dswbx 023f5391e4 chore: update license to Apache-2.0 in package.json 2026-01-21 11:02:18 +01:00
cameronapak 11e3488917 Allow seeding without forcing sync 2026-01-19 11:46:48 -06:00
dswbx 9685c5d4e1 Merge pull request #337 from jonaspm/chore/upgrade-astro-version
chore: Update Astro to version 5.16.9 in example package
2026-01-15 14:25:20 +01:00
Jonas Perusquia Morales 9b008be512 chore: Update Astro to version 5.16.9 in example package 2026-01-14 12:52:38 -06:00
dswbx 5352331464 Merge pull request #327 from matschik/main
docs(cloudflare): fix import in Cloudflare configuration example
2026-01-11 19:32:50 +01:00
dswbx d9bce049ef feat(plugin/timestamps): add indexStrategy option for entity indexing
Introduced a new `indexStrategy` option in the timestamps plugin to configure index generation for `created_at` and `updated_at` fields. Supports "composite" and "individual" strategies for better control over database indexing. Added test coverage for different index strategies.
2026-01-09 16:04:54 +01:00
dswbx 0e4f25cf11 refactor: update type handling in SvelteKit adapter tests and MediaApi uploadFile method 2026-01-09 14:51:42 +01:00
dswbx 4a5bdade8d fix: handle lazy import errors for TestRoutes in development for nextjs 2026-01-09 14:42:18 +01:00
dswbx 047bb80a97 Merge pull request #330 from jonaspm/fix/upload-media-entity-overwrite
[WIP] fix: Add overwrite option to uploadToEntity method
2026-01-09 14:34:41 +01:00
dswbx d826cb23a3 Merge pull request #291 from bknd-io/release/0.20
Release 0.20
2026-01-09 14:32:01 +01:00
dswbx 23a9e7c278 bump version to 0.20.0 2026-01-09 14:30:31 +01:00
dswbx a9fe43130e Merge pull request #324 from screenfluent/feat/sveltekit-adapter
feat: add SvelteKit adapter
2026-01-09 14:23:57 +01:00
dswbx 3b7ec06703 Merge pull request #331 from bknd-io/change-license
change license to apache 2.0
2026-01-09 13:46:14 +01:00
dswbx 55a124b519 feat(plugin/email-otp): preserve additional payload data during user creation
Replaced `s.object` with `s.strictObject` to enforce schema validation. Updated logic to include unprocessed JSON properties (`...rest`) when creating a user, ensuring additional payload data is preserved.
2026-01-09 13:46:01 +01:00
dswbx dc3fe330d7 change license to Apache 2.0 2026-01-09 13:43:51 +01:00
dswbx d541167fb3 docs: add sveltekit integration guide 2026-01-09 13:26:28 +01:00
dswbx 7e8b357bbb add support for /admin route in backend handler and enable asset copying
updated `hooks.server.ts` to handle `/admin` routes and prevent fallback for 404 responses. added `adminBasepath` config to match backend paths. introduced assets copying step via `postinstall` script and included `robots.txt` and `favicon.ico` under `static`.
2026-01-09 13:11:01 +01:00
dswbx 521d8bc6ff refactor(adapter): replaced FrameworkBkndConfig with RuntimeBkndConfig 2026-01-09 12:59:03 +01:00
dswbx 19f1941544 Merge remote-tracking branch 'origin/main' into release/0.20 2026-01-09 11:31:43 +01:00
dswbx f9373bd0e6 test: remove only 2026-01-09 11:30:04 +01:00
dswbx 5384738b67 tests: ensuring overwrite is passed and respected in media api 2026-01-09 11:28:19 +01:00
dswbx d82e28e6b1 Merge remote-tracking branch 'origin/release/0.20' into fork/jonaspm/fix/upload-media-entity-overwrite 2026-01-09 10:12:21 +01:00
Jonas Perusquia Morales c38bc95b1d fix: Add query support to media upload API methods 2026-01-08 12:37:13 -06:00
Mathieu Schimmerling 6651fce099 Merge pull request #1 from matschik/matschik-patch-1
docs(cloudflare): modify import in Cloudflare configuration example
2026-01-02 19:38:19 +01:00
Mathieu Schimmerling 91a1fc8a0c docs(cloudflare): modify import in Cloudflare configuration example
Updated import statement to include 'd1' from 'bknd/adapter/cloudflare'.
2026-01-02 19:13:24 +01:00
cameronapak d00e5fe68c Remove redundant basepath option 2025-12-30 07:34:00 -06:00
cameronapak 6faf0d421f Fix test for Blob to File conversion
The Response.blob() method in JavaScript returns a Blob, not a File.
This means that file-specific metadata like `name` and `lastModified` is
lost. The tests were updated to reflect this by manually constructing a
File object from the Blob, mimicking how client code would handle this
conversion. The MIME type is now correctly preserved from the response
headers when creating the File.
2025-12-30 07:33:55 -06:00
cameronapak 19e51fae63 Refactor AppReduced to use basepath config option
The `admin_basepath` configuration option has been renamed to `basepath`
for clarity and consistency. This change affects how the admin base path
is accessed and utilized within the `AppReduced` utility. Tests have
been updated to reflect this renaming.
2025-12-30 07:09:00 -06:00
cameronapak 7e01cc6101 Updated bun lockfile 2025-12-30 06:59:36 -06:00
cameronapak 103cbd11df Merge branch 'main' into cp/216-fix-users-link 2025-12-30 06:55:20 -06:00
Szymon Rączka 03d3b8e407 feat(sveltekit): use createRuntimeApp for built-in Admin UI 2025-12-28 20:13:50 +01:00
Szymon Rączka a97a79f11e fix(sveltekit): make adapter runtime-agnostic
Remove process.env default to support all SvelteKit runtimes
(Node, Cloudflare Workers, Vercel Edge, Deno). Users now pass
env explicitly via $env/dynamic/private.
2025-12-28 12:47:09 +01:00
Szymon Rączka 90b4de7093 feat: add SvelteKit adapter
Add framework adapter for SvelteKit with:
- `getApp()` - get bknd app instance
- `serve()` - request handler for hooks.server.ts

Usage in hooks.server.ts:
```typescript
import { serve } from "bknd/adapter/sveltekit";
import config from "../bknd.config";

const bkndHandler = serve(config);

export const handle = async ({ event, resolve }) => {
  if (event.url.pathname.startsWith("/api/")) {
    return bkndHandler(event);
  }
  return resolve(event);
};
```

Includes:
- Adapter implementation (app/src/adapter/sveltekit/)
- Test suite
- Working example (examples/sveltekit/)
- Package exports and types
2025-12-26 17:30:03 +01:00
dswbx 81f3914e7f admin ui: made context overlays scrollable 2025-12-22 15:10:36 +01:00
dswbx 5163e2142e Merge pull request #323 from jonaspm/docs/add-readOneBy
docs: Document data.readOneBy method with usage examples
2025-12-17 17:40:37 +01:00
Jonas Perusquia Morales 15ed075de3 docs: Document data.readOneBy method with usage examples 2025-12-17 09:35:24 -06:00
dswbx 09b9a5e857 fix(auth): allow_register was not respected in strategy controllers 2025-12-06 20:51:09 +01:00
dswbx 4f7925dfd8 bump rc, hide flows by isDebug instead 2025-12-05 17:12:07 +01:00
dswbx 21e970cd8c Merge pull request #321 from bknd-io/feat/admin/mcp-in-nav
feat(admin): add mcp as main navigation item when enabled, and make it route-aware
2025-12-05 17:03:35 +01:00
dswbx 40b70e7a20 feat(admin): add mcp as main navigation item when enabled, and make it route-aware 2025-12-05 15:25:06 +01:00
dswbx 9958dd7308 Merge pull request #320 from bknd-io/feat/auth-role-and-password
feat: add `default_role_register` and password `minLength` config
2025-12-05 14:16:05 +01:00
dswbx fef77eea61 feat: add minLength to pw strategy, and enforce 2025-12-05 14:03:09 +01:00
dswbx ed41887d74 feat/fix: adding auth.default_role_register and disallow giving role on registration payload 2025-12-05 14:02:42 +01:00
dswbx 8ba4793160 Merge pull request #319 from jonaspm/docs/add-auth-logout
feat: Add documentation for auth.logout method in SDK usage
2025-12-04 22:06:09 +01:00
Jonas Perusquia Morales 65e17db2a2 feat: Add documentation for auth.logout method in SDK usage 2025-12-04 14:48:11 -06:00
dswbx e21b5e5b55 fix tsup broken watch 2025-12-03 18:08:54 +01:00
dswbx 959dee013e fix admin local auth 2025-12-03 17:41:25 +01:00
dswbx a39e76a440 Merge pull request #315 from bknd-io/feat/admin-local-auth
feat: add local auth support if api storage provided
2025-12-02 19:13:07 +01:00
dswbx acc10377ca feat: add local auth support if api storage provided 2025-12-02 18:18:45 +01:00
dswbx 506c7d84cc chore: bump version to 0.20.0-rc.1 and fix client context 2025-12-02 15:14:27 +01:00
dswbx 7ba542c040 removed sqlocal package, updated readme 2025-12-02 14:21:30 +01:00
dswbx b784d8611a Merge pull request #308 from bknd-io/feat/opfs-and-sqlocal
feat: opfs and sqlocal
2025-12-02 14:18:55 +01:00
dswbx a21d4ad6a7 docs: update sqlocal doc 2025-12-02 14:16:27 +01:00
dswbx 731b7a7e8d tests: fix node tests + add connection test suite for sqlocal 2025-12-02 14:11:26 +01:00
dswbx e56fc9c368 finalized sqlocal, added BkndBrowserApp, updated react example 2025-12-02 14:03:41 +01:00
dswbx d1aa2da5b1 Merge remote-tracking branch 'origin/release/0.20' into feat/opfs-and-sqlocal 2025-12-02 10:31:39 +01:00
dswbx 52fe9aa085 Merge pull request #289 from bknd-io/feat/postgres-fc
feat: move postgres as part of the main repo
2025-12-02 10:29:43 +01:00
dswbx 065821d9a5 chore: github tests with isolated deps 2025-12-02 10:25:49 +01:00
dswbx 0fc817382a Merge remote-tracking branch 'origin/release/0.20' into feat/postgres-fc
# Conflicts:
#	app/src/core/utils/runtime.ts
2025-12-02 10:20:58 +01:00
dswbx 2e6b95e484 Merge pull request #314 from bknd-io/docs/update-nov-react
docs: updated react-router and react sdk
2025-12-02 10:05:32 +01:00
dswbx 4e31f48917 docs: updated react-router and react sdk 2025-12-02 10:01:29 +01:00
dswbx b2872eb196 Merge pull request #313 from bknd-io/feat/data-implicit-joins
feat: Add implicit joins in repository where clauses
2025-12-02 09:49:44 +01:00
dswbx 3d804f5ac5 Merge pull request #312 from bknd-io/fix/controller-schema-access
fix: putting schema related endpoints behind schema permission and added tests
2025-12-02 09:49:25 +01:00
dswbx da025efee1 Merge pull request #307 from bknd-io/fix/ui-client-import
Fix UI client import
2025-12-02 09:48:35 +01:00
dswbx ab1fa4c895 fix: add infinite back to dropzone container 2025-12-02 09:46:09 +01:00
dswbx ebe3bc2ff5 Merge pull request #311 from bknd-io/fix/admin-field-config
fix config reconciliation for specific field types, remove lodash
2025-12-02 09:43:58 +01:00
dswbx f792d8b93e feat(repository): add implicit joins in where clauses 2025-12-02 09:38:16 +01:00
dswbx 319469f44b fix: putting schema related endpoints behind schema permission and add tests 2025-12-02 08:53:49 +01:00
dswbx 1359741e5f fix config reconciliation for specific field types, remove lodash
Replace lodash with native utilities and fix config merging to preserve common properties when switching between field types.
2025-11-29 14:18:57 +01:00
dswbx 8f4de33a76 use nullish coalescing and fix mode plugin pick up 2025-11-26 10:04:24 +01:00
dswbx 43dbc856ce fix SystemController config read permission 2025-11-26 07:41:19 +01:00
dswbx 5a8f2b4894 upgrade bun and set linker isolated 2025-11-25 16:28:11 +01:00
dswbx 2627213de7 Merge pull request #292 from bknd-io/feat/add-otp-plugin
OTP plugin
2025-11-25 16:24:20 +01:00
dswbx 36e1bb1867 init opfs and sqlocal as another browser adapter 2025-11-25 16:21:16 +01:00
dswbx 0cf1d86213 fix client imports to prevent multiple react context's 2025-11-25 16:16:10 +01:00
dswbx 3fac740771 Merge branch 'release/0.20' into feat/add-otp-plugin 2025-11-21 20:03:30 +01:00
dswbx 5e5dc62304 Merge pull request #299 from bknd-io/fix/rec-with-join
fix: recursive `with` omitted join clauses
2025-11-21 20:01:46 +01:00
dswbx c1d6384271 Merge pull request #301 from bknd-io/fix/cli-run-open
fix cli url opener and add minimal debug docker config
2025-11-21 19:40:54 +01:00
dswbx f2bd04770a Merge pull request #303 from bknd-io/feat/cf-vite-templates
Add Cloudflare Vite templates for fullstack deployments
2025-11-21 19:39:29 +01:00
dswbx 41d43126cb ignore generated worker-configuration.d.ts files 2025-11-21 18:29:13 +01:00
dswbx a39407c9a3 update cloudflare-vite-code readme to reflect code-only mode
The template documentation now emphasizes code-first approach with manual type management instead of automatic file generation. Includes updated configuration examples, deployment instructions, and a new comparison table between code and hybrid modes.
2025-11-21 18:18:56 +01:00
dswbx 5d6046b149 template: added cloudflare vite code template 2025-11-21 17:55:43 +01:00
dswbx 000f8d20a8 template: added cloudflare vite hybrid template 2025-11-21 17:55:24 +01:00
dswbx 9e7547e787 Merge pull request #302 from bknd-io/fix/cf-proxy-and-modes
Fix: cf proxy and modes
2025-11-21 17:54:23 +01:00
dswbx c3ae4a3999 fix cloudflare adapter warm mode and type improvements
Adds warm mode support to cloudflare adapter to enable app instance caching. Improves BkndConfig type handling and makes hybrid mode reader optional. Fixes entity hook types to properly handle Generated ID types from kysely.
2025-11-21 17:35:45 +01:00
dswbx 5c3eeb7642 fix json schema validation initialization
ensure `getJsonSchema` handles both object and non-object outputs to prevent errors during validation initialization. this improves robustness when handling edge cases in schema configurations.
2025-11-20 21:11:28 +01:00
dswbx a2fa11ccd0 refactor modes implementation and improve validation handling
refactor `code` and `hybrid` modes for better type safety and configuration flexibility. add `_isProd` helper to standardize environment checks and improve plugin syncing warnings. adjust validation logic for clean JSON schema handling and enhance test coverage for modes.
2025-11-20 21:08:16 +01:00
dswbx fdee39da62 fix cli url opener and add minimal debug docker config 2025-11-20 20:04:13 +01:00
dswbx 16a3a67634 fix: recursive with omitted join clauses 2025-11-14 22:17:27 +01:00
dswbx 2b5e1771de refactor: enhance email OTP responses and improve data structure
Updated the email OTP plugin to return a structured response containing the email, action, and expiration details. Adjusted the tests to validate the new response format. Increased password length for user creation
2025-11-14 21:59:06 +01:00
dswbx a16e017e39 Merge remote-tracking branch 'origin/release/0.20' into feat/add-otp-plugin 2025-11-14 09:01:36 +01:00
dswbx 079d613b2e adjust biome configuration for improved linting and file inclusion
add specific file exclusions in `biome.json` to refine file tracking and adapt linting script in `package.json` to automatically write changes.
2025-11-11 16:42:19 +01:00
dswbx ff56d616d9 chore: upgrade bun to 1.3.2 2025-11-11 15:57:24 +01:00
dswbx 5b3d36c527 Merge pull request #294 from cameronapak/claude/add-loops-email-driver-011CUxMpVqe8AT22gN2k5ZVm
Email Driver: Add Plunk Support
2025-11-11 15:48:18 +01:00
cameronapak 2885fea077 Remove multi-recipient support from Plunk driver 2025-11-11 08:30:26 -06:00
dswbx 5ce22162c3 Merge pull request #297 from jonaspm/patch-1
Update import path for getApi in astro.mdx
2025-11-11 13:56:24 +01:00
dswbx 3f55c0f2c5 fix imports and adjust biome and linting configuration
remove unused `SecretSchema` import, adjust biome settings to modify linting behavior, and clean formatting in `slugify` and other functions.
2025-11-11 13:29:24 +01:00
Jonás Perusquía Morales 21f642161d Update import path for getApi in astro.mdx
Fix import path for getApi.
2025-11-10 18:42:49 -06:00
dswbx 7912729159 Merge pull request #296 from jonaspm/patch-1
Update Astro integration documentation
2025-11-10 19:36:16 +01:00
Jonás Perusquía Morales d3e87be604 Update Astro integration documentation
Removed note about Astro v4 and issues with v5. Issue was fixed on Dec 16, 2024 [https://github.com/withastro/astro/issues/12699]
2025-11-10 12:02:22 -06:00
dswbx ba3d11edab Merge remote-tracking branch 'origin/release/0.20' into feat/add-otp-plugin 2025-11-10 13:25:01 +01:00
dswbx 9ec31373e1 Merge pull request #295 from bknd-io/fix/hybrid-schema-sync
fix hybrid schema sync
2025-11-10 10:36:52 +01:00
dswbx 70b25a9f9b hybrid: fix timing for automatic schema syncs to work with plugins 2025-11-10 10:34:50 +01:00
dswbx b6717f0237 otp: update docs on permissions, only require email driver if sendEmail is not false 2025-11-10 10:25:33 +01:00
dswbx c57f3e8070 otp: added missing tests 2025-11-10 10:17:16 +01:00
dswbx c2f4f92d1a refactor otp listeners 2025-11-10 09:40:47 +01:00
dswbx 6eb8525656 otp: add sendEmail option to disable sending for debugging 2025-11-10 09:30:24 +01:00
cameronapak e2ebb57564 Fixed tests :) 2025-11-09 08:11:49 -06:00
cameronapak 2b7bbc5df0 Update tests to use real information 2025-11-09 08:01:14 -06:00
Claude 8766b742c6 add PLUNK_API_KEY to .env.example 2025-11-09 13:50:12 +00:00
Claude 3befa43a9e remove PRD and task list documents from PR 2025-11-09 13:42:08 +00:00
Claude a842808464 mark all tasks as completed in task list 2025-11-09 13:36:18 +00:00
Claude bd1ef8ed57 implement Plunk email driver with comprehensive tests
- Add plunkEmail driver following IEmailDriver interface
- Support single and multiple recipients (up to 5 max per Plunk API)
- Handle both string and { text, html } body formats
- Include sender customization (from, name, reply)
- Add recipient validation and error handling
- Create comprehensive unit tests with 6 test cases
- Export plunkEmail from core drivers index
- Update task list with implementation progress
2025-11-09 13:35:48 +00:00
Claude a0019e5500 add detailed sub-tasks for Plunk email driver implementation 2025-11-09 13:29:42 +00:00
Claude 8ec67a5492 add task list for Plunk email driver implementation 2025-11-09 13:28:04 +00:00
Claude 960f7c3fb2 add PRD for Plunk email driver implementation 2025-11-09 13:25:38 +00:00
dswbx 793c214e6d Merge remote-tracking branch 'origin/release/0.20' into feat/add-otp-plugin 2025-11-07 09:58:48 +01:00
dswbx 4094004b83 Merge remote-tracking branch 'origin/release/0.20' into feat/add-otp-plugin 2025-11-07 09:50:33 +01:00
dswbx 300d86ff7c Merge pull request #293 from bknd-io/chore/biome-upgrade
upgrade biome and vscode settings
2025-11-07 09:49:22 +01:00
dswbx 4c9b662f6f fix bknd/util imports 2025-11-07 09:47:43 +01:00
dswbx a862cfdcf1 add .vscode/settings.json 2025-11-07 09:45:12 +01:00
dswbx de62f4e729 upgrade biome config 2025-11-07 09:39:02 +01:00
dswbx 7e399830e5 add validation for used OTP codes 2025-11-07 09:12:58 +01:00
dswbx ee2ab982df docs: added emailOTP plugin docs 2025-11-07 09:11:13 +01:00
dswbx 5be55a6fa6 clean up error, it's prefixed with the plugin name already 2025-11-07 08:51:44 +01:00
dswbx 341eb13425 rename to email otp to make it more explicit 2025-11-07 08:48:02 +01:00
dswbx 68fbb6e933 fix events reference for instance checks 2025-11-06 20:47:19 +01:00
dswbx 8b36985252 otp: add entity config, reduce result 2025-11-06 20:38:16 +01:00
dswbx ff86240b0e feat: add OTP plugin 2025-11-06 20:06:51 +01:00
dswbx 40bbdb904f refactor: expose additional kysely postgres options 2025-11-05 10:47:47 +01:00
dswbx a333d537b0 refactor postgres functions to not rely on the packages 2025-11-05 10:21:35 +01:00
dswbx 108c108d82 merge origin/release/0.20 2025-11-05 10:05:57 +01:00
dswbx 4575d89cfe Merge pull request #290 from bknd-io/chore/upgrade-deps
upgrade deps
2025-11-05 10:03:36 +01:00
dswbx 0ca0828581 fix examples 2025-11-05 09:37:49 +01:00
dswbx 07a57ebf67 fix cli build script, add commander, fix types 2025-11-05 08:27:47 +01:00
dswbx e9f1241ec3 fix tests: remove .only 2025-11-05 08:20:36 +01:00
dswbx 80903d4ffa upgrade bun, remove hoisting 2025-11-05 08:15:41 +01:00
dswbx c8290802e9 upgrade biome, typescript, icons, other dev deps. also keep hoisting for now 2025-11-05 07:48:33 +01:00
dswbx ac6cd4a900 upgrade vite + fix types 2025-10-31 21:40:55 +01:00
dswbx a91ddb1ec3 update deps 2025-10-31 21:33:11 +01:00
dswbx ed47c5bf51 postgres: move examples up 2025-10-31 20:11:16 +01:00
dswbx dbb19a27f4 fix pg imports for node tests 2025-10-31 17:48:50 +01:00
dswbx 71a17696eb fix ".only" tests 2025-10-31 17:44:37 +01:00
dswbx 2dbafebcea pg test: change is running check approach 2025-10-31 17:38:17 +01:00
dswbx 84d4635ec3 fix bun 1.3 new hoisting (for now) 2025-10-31 17:31:19 +01:00
dswbx e9c92b6086 upgrade bun, add zustand 2025-10-31 17:27:04 +01:00
dswbx 5fa7601ad5 revert bun upgrade (should be separate pr) 2025-10-31 17:21:58 +01:00
dswbx 2c7054c317 feat: move postgres as part of the main repo 2025-10-31 17:13:23 +01:00
dswbx 5417aa174e bump 0.19.0, update readme with use cases, small fixes
- fix password strategy openapi tags
- fix cli run picking up memory if specified
- fix view transition chrome crash
2025-10-31 11:55:27 +01:00
dswbx 6093f4f46f Merge pull request #278 from bknd-io/release/0.19
Release 0.19
2025-10-31 09:27:01 +01:00
dswbx 2178e0ee8b fix CodePreview shiki dynamic load for frameworks like Next.js 2025-10-31 09:24:45 +01:00
dswbx be39e8a391 chore: bump version to 0.19.0-rc.3 and update JsonField handling for improved value parsing 2025-10-28 16:07:43 +01:00
dswbx b57f362e3a fix json field 2025-10-28 16:00:58 +01:00
dswbx e055e477ae chore: bump version to 0.19.0-rc.2 and remove unused server initialization callback in Cloudflare config 2025-10-28 13:14:23 +01:00
dswbx 422f7893b5 docs: updated mcp tools 2025-10-28 11:09:37 +01:00
dswbx 0a50f9850c docs: add timestamps plugin description 2025-10-28 10:44:22 +01:00
dswbx 42f340b189 fix: update syncSecretsOptions condition and enhance documentation for mode configuration
- Changed the condition for syncSecretsOptions to check for both existence and false value.
- Added details about explicit fetch export and integration specifics in the introduction documentation.
- Clarified behavior of UI-only and code-only modes, including configuration application and schema syncing.
2025-10-28 10:42:06 +01:00
dswbx ef41b71921 fix: add modes export, fix event firing with modes and cloudflare 2025-10-28 09:18:16 +01:00
dswbx 2847e64b77 feat: enhance query handling by ignoring undefined values
- Updated query conversion logic to skip undefined values, improving robustness.
- Added tests to validate that undefined values are correctly ignored in query specifications.
2025-10-26 21:22:42 +01:00
dswbx 28390b0b84 refactor: move query.spec.ts 2025-10-26 21:08:11 +01:00
dswbx 0b58cadbd0 feat: implement mergeFilters function and enhance query object merging
- Added mergeFilters function to combine filter objects with priority handling.
- Introduced comprehensive tests for mergeFilters in permissions.spec.ts.
- Created query.spec.ts to validate query structure and expression handling.
- Enhanced error messages in query.ts for better debugging and clarity.
2025-10-26 21:05:11 +01:00
dswbx 574b37abcd fix: update key handling in StorageR2Adapter to conditionally prepend keyPrefix
- Modified getKey method to prepend keyPrefix only if it is not empty, ensuring correct key formatting.
2025-10-26 21:04:37 +01:00
dswbx 0dbf71e6b5 fix pagination on entity relations for softscan false 2025-10-26 16:00:15 +01:00
dswbx fd1f0f7c54 Merge pull request #288 from bknd-io/feat/deno-and-docs
deno: add to cli starters, improve `serveStaticViaImport` and add deno to the docs
2025-10-25 10:37:02 +02:00
dswbx ebad3d15ec Enhance Deno integration documentation with installation instructions and versioning options 2025-10-25 10:34:24 +02:00
dswbx 2fd5e71574 finalize deno addition to the cli starters 2025-10-25 10:26:05 +02:00
dswbx b787837dd2 add deno to the cli starters 2025-10-25 10:18:43 +02:00
dswbx 1fc6e810ae feat: improve Deno support and enhance serveStaticViaImport function
- Introduced support for Deno as a runtime in the documentation.
- Updated serveStaticViaImport function to accept additional options: appendRaw and package.
- Improved error logging in serveStaticViaImport for better debugging.
- Added new Deno integration documentation with examples for serving static assets.
2025-10-24 18:22:58 +02:00
dswbx 88cc406002 chore: update version to 0.19.0-rc.1 and improve error handling in App class
- Bumped version in package.json to 0.19.0-rc.1.
- Changed error throw to console.error in fetch method of App class for better debugging.
- Updated permissions in DataController for the "/types" endpoint to include context for schemaRead.
2025-10-24 18:22:13 +02:00
dswbx 511f639d7a Merge pull request #280 from bknd-io/feat/advanced-permissions
feat: advanced permissions (~RLS)
2025-10-24 15:18:05 +02:00
dswbx 0df17221df Merge branch 'release/0.19' into feat/advanced-permissions 2025-10-24 15:15:56 +02:00
dswbx 347fe0f6ce Merge pull request #286 from bknd-io/feat/data-types-endpoint
feat: add endpoint/tool to retrieve TypeScript definitions for data entities
2025-10-24 15:13:36 +02:00
dswbx f3c6cd7620 fix typo on AdminController flash message 2025-10-24 15:12:47 +02:00
dswbx 649c465bd0 Merge pull request #279 from bknd-io/fix/dropzone-improve-mime
fix: dropzone improve mime type validation
2025-10-24 15:11:19 +02:00
dswbx 67604b344a Merge pull request #284 from bknd-io/fix/sdk-api-auth
fix: SDK improvements to API and `useAuth` for remote instance
2025-10-24 15:10:29 +02:00
dswbx aedae8e757 Merge pull request #285 from bknd-io/feat/mode-helpers
feat: introduce new modes helpers
2025-10-24 15:09:05 +02:00
dswbx 166409fdf4 Revert "make non-fillable fields visible but disabled in UI"
This reverts commit f2aad9caac.
2025-10-24 14:08:32 +02:00
dswbx f2aad9caac make non-fillable fields visible but disabled in UI 2025-10-24 14:07:37 +02:00
dswbx 292e4595ea feat: add endpoint/tool to retrieve TypeScript definitions for data entities
Implemented a new endpoint at "/types" in the DataController to return TypeScript definitions for data entities, enhancing type safety and developer experience.
2025-10-24 12:49:39 +02:00
dswbx 869031bbfa Refactor CustomFieldWrapper and enhance schema handling in Policy component
- Updated `CustomFieldWrapper` to accept a more structured schema object, improving clarity and type safety.
- Modified schema handling in the `Policy` component to ensure proper context and variable naming, enhancing the overall user experience.
- Introduced `autoFormatString` utility for dynamic button labeling based on schema name.
2025-10-24 12:43:32 +02:00
dswbx 88e5c06e9d Enhance SystemController to improve config modification checks
Updated the `SystemController` to include additional checks for read-only status and user permissions when modifying configurations.
2025-10-24 10:37:52 +02:00
dswbx cfb4b0e336 Refactor JsonEditor and Permission components for improved state management and performance
- Implemented debounced input handling in `JsonEditor` to enhance user experience and reduce unnecessary updates.
- Updated `Permission` component to streamline permission state management and improve clarity in policy handling.
- Refactored `Policies` component to utilize derived field context for better data handling and rendering efficiency.
2025-10-24 09:59:00 +02:00
dswbx 2d56b54e0c Enhance Guard and Form components with improved error handling and debugging
- Added debug logging in the `Guard` class to track policy evaluations and conditions.
- Updated error logging in the `Form` component to provide more context on invalid submissions.
- Introduced state management for form errors in the `AuthRolesEdit` component, displaying alerts for invalid data submissions.
2025-10-24 09:40:02 +02:00
dswbx 5d4a77fb10 Update permission context handling and improve JSON field component
- Enhanced `MediaController` to include context in the `entityCreate` permission for better access control.
- Refactored permission checks in `useBkndAuth` to ensure correct validation of role permissions.
- Modified `JsonField` component to directly use `formData` in `JsonEditor`, simplifying data handling and improving user experience.
2025-10-24 09:20:59 +02:00
dswbx eb0822bbff Enhance authentication and authorization components
- Refactored `AppAuth` to introduce `getGuardContextSchema` for improved user context handling.
- Updated `Authenticator` to utilize `pickKeys` for user data extraction in JWT generation.
- Enhanced `Guard` class to improve permission checks and error handling.
- Modified `SystemController` to return context schema alongside permissions in API responses.
- Added new `permissions` method in `SystemApi` for fetching permissions.
- Improved UI components with additional props and tooltip support for better user experience.
2025-10-24 09:14:31 +02:00
dswbx 38902ebcba Update permissions handling and enhance Guard functionality
- Bump `jsonv-ts` dependency to 0.8.6.
- Refactor permission checks in the `Guard` class to improve context validation and error handling.
- Update tests to reflect changes in permission handling, ensuring robust coverage for new scenarios.
- Introduce new test cases for data permissions, enhancing overall test coverage and reliability.
2025-10-21 16:44:08 +02:00
dswbx 22e43c2523 feat: introduce new modes helpers 2025-10-18 16:58:54 +02:00
dswbx e68e5792be set raw state to ClientProviders auth state 2025-10-17 08:47:00 +02:00
dswbx 511c6539fb fix: update authentication verification logic in Api tests
- Adjusted test cases in Api.spec.ts to reflect the correct authentication verification state.
- Updated expectations to ensure that the `isAuthVerified` method returns true when no claims are provided, aligning with the intended behavior of the API.
2025-10-15 18:46:21 +02:00
dswbx 9070f96571 feat: enhance API and AuthApi with credentials support and async storage handling
- Added `credentials` option to `ApiOptions` and `BaseModuleApiOptions` for better request handling.
- Updated `AuthApi` to pass `verified` status during token updates.
- Refactored storage handling in `Api` to support async operations using a Proxy.
- Improved `Authenticator` to handle cookie domain configuration and JSON request detection.
- Adjusted `useAuth` to ensure logout and verify methods return promises for better async handling.
- Fixed navigation URL construction in `useNavigate` and updated context menu actions in `_data.root.tsx`.
2025-10-15 18:41:04 +02:00
dswbx 0347efa592 fix Role creation method and permission checks in tests 2025-10-14 16:49:42 +02:00
dswbx 1b8ce41837 role and permission handling in auth module
- Updated the `Role` class to change the `create` method signature for improved clarity and flexibility.
- Refactored the `guardRoleSchema` to utilize the new `roleSchema` for better consistency.
- Introduced a new `TPermission` type to enhance type safety in permission handling across the application.
- Updated various components and forms to accommodate the new permission structure, ensuring backward compatibility.
- Enhanced the `AuthRolesEdit` and `AuthRolesList` components to improve role management and permissions display.
- Added new API endpoints for fetching permissions, improving the overall functionality of the auth module.
2025-10-14 16:36:42 +02:00
dswbx 6624927286 enhance form field components and add JsonEditor support
- Updated `ObjectField`, `ArrayField`, and `FieldWrapper` components to improve flexibility and integration options by supporting additional props like `wrapperProps`.
- Added `JsonEditor` for enhanced object editing capabilities with state management and safety checks.
- Refactored utility functions and error handling for improved stability and developer experience.
- Introduced new test cases to validate `JsonEditor` functionality and schema-based forms handling.
2025-10-14 16:36:16 +02:00
dswbx 803f42a72b Merge remote-tracking branch 'origin/main' into feat/advanced-permissions 2025-10-14 10:16:43 +02:00
dswbx 7e5c28d621 enhance Guard and permission handling with new test cases
- Updated the `Guard` class to improve context validation and permission checks, ensuring clearer error messages for unmet conditions.
- Refactored the `Policy` and `RolePermission` classes to support default effects and better handle conditions and filters.
- Enhanced tests in `authorize.spec.ts` and `permissions.spec.ts` to cover new permission scenarios, including guest and member role behaviors.
- Added new tests for context validation in permission middleware, ensuring robust error handling for invalid contexts.
- Improved utility functions for better integration with the updated permission structure.
2025-10-13 21:03:49 +02:00
dswbx 2f88c2216c refactor: restructure permission handling and enhance Guard functionality
- Introduced a new `createGuard` function to streamline the creation of Guard instances with permissions and roles.
- Updated tests in `authorize.spec.ts` to reflect changes in permission checks, ensuring they now return undefined for denied permissions.
- Added new `Permission` and `Policy` classes to improve type safety and flexibility in permission management.
- Refactored middleware and controller files to utilize the updated permission structure, including context handling for permissions.
- Created a new `SystemController.spec.ts` file to test the integration of the new permission system within the SystemController.
- Removed legacy permission handling from core security files, consolidating permission logic within the new structure.
2025-10-13 18:20:46 +02:00
dswbx b89c090f87 Merge pull request #281 from stormbyte/main
Update docker image
2025-10-13 11:11:10 +02:00
dswbx f4a7cde487 chore: bump version to 0.18.1 and update jsonv-ts dependency to 0.8.5 2025-10-13 10:58:33 +02:00
dswbx a2d83d01a4 Merge pull request #283 from bknd-io/docs/cloudflare-images-hint
docs: add note about Cloudflare Image Optimization plugin requirement
2025-10-13 10:54:02 +02:00
dswbx 66392d094d Merge pull request #282 from bknd-io/fix/paginate-without-totals
fix pagination if endpoint's total is not available
2025-10-13 10:52:15 +02:00
dswbx 0352c72fb6 docs: add note about Cloudflare Image Optimization plugin requirement
Included a callout in the documentation for the Cloudflare Image Optimization plugin, clarifying that it does not function on the development server or with `workers.dev` subdomains, and requires enabling Cloudflare Image transformations.
2025-10-13 10:51:47 +02:00
dswbx 3f9be3a418 fix: refine FetchPromise execution in useApiInfiniteQuery
Updated the FetchPromise execution in the useApiInfiniteQuery function to include a refine parameter, enhancing the request handling process.
2025-10-13 10:46:04 +02:00
dswbx fd3dd310a5 refactor: enhance MediaApi typing and improve vite example config handling for d1
Updated `MediaApi` to include improved generic typing for upload methods, ensuring type safety and consistency. Refactored example configuration logic in development environment setup for better modularity and maintainability.
2025-10-13 10:41:15 +02:00
dswbx e6ff5c3f0b fix pagination if endpoint's total is not available
when using a connection that has softscans disabled (e.g. D1) pagination failed. Fixing it by overfetching and slicing
2025-10-11 20:37:14 +02:00
Your Name 5377ac1a41 Update docker builder 2025-10-08 22:41:43 +02:00
Your Name 0e870cda81 Bump version 2025-10-08 22:09:03 +02:00
dswbx b784e1c1c4 Merge remote-tracking branch 'origin/release/0.19' into feat/advanced-permissions 2025-10-03 20:27:07 +02:00
dswbx 90f93caff4 refactor: enhance permission handling and introduce new Permission and Policy classes
- Updated the `Guard` class to improve permission checking by utilizing the new `Permission` class.
- Refactored tests in `authorize.spec.ts` to use `Permission` instances instead of strings for better type safety.
- Introduced a new `permissions.spec.ts` file to test the functionality of the `Permission` and `Policy` classes.
- Enhanced the `recursivelyReplacePlaceholders` utility function to support various object structures and types.
- Updated middleware and controller files to align with the new permission handling structure.
2025-10-03 20:22:42 +02:00
dswbx d6dcfe3acc feat: implement file acceptance validation in utils and integrate with Dropzone component 2025-10-01 09:46:16 +02:00
dswbx b974fe7ec7 Merge pull request #270 from bknd-io/feat/cli-user-role
feat: add role selection and auth checks in user create command
2025-10-01 09:40:20 +02:00
dswbx 6483ff74bb Merge pull request #271 from bknd-io/feat/auth-helper-cookie
feat: add helper methods for auth cookie headers
2025-10-01 09:40:03 +02:00
dswbx 27e2064f0c Merge pull request #272 from bknd-io/feat/media-add-body-max
feat: add media option to module to restrict body max size
2025-10-01 09:39:38 +02:00
dswbx 5cf91bb35a Merge pull request #273 from bknd-io/feat/schema-batch-statements
feat: batch schema manager statements
2025-10-01 09:39:19 +02:00
dswbx 81533d855a Merge pull request #277 from bknd-io/feat/timestamps-plugin
feat: timestamps plugin
2025-10-01 09:39:10 +02:00
dswbx db58911df3 Merge pull request #250 from bknd-io/release/0.18
Release 0.18
2025-10-01 09:07:18 +02:00
dswbx 2540c83382 chore: bump version to 0.18.0 in package.json 2025-10-01 09:06:04 +02:00
dswbx 569d021316 fix: update OneToOneRelation and PolymorphicRelation configurations
Enhanced OneToOneRelation to allow source to create target with a mapping field and added a limit. Updated PolymorphicRelation to return a TextField for entity_id instead of NumberField, improving type consistency.
2025-10-01 09:00:44 +02:00
dswbx ba9713587c chore: update Cloudflare proxy build target to ESNext and adjust package import handling
Set the build target for the Cloudflare adapter to ESNext to keep json import attribute.
2025-10-01 09:00:11 +02:00
dswbx eecaeb7c06 chore: bump version to 0.18.0-rc.11 and add cloudflare makeConfig export
Updated package version to 0.18.0-rc.11 and included `makeConfig` in Cloudflare adapter exports to extend configuration capabilities.
2025-09-30 13:32:57 +02:00
dswbx 610e263477 fix: handle correct type conversion for relation fields
Updated `RelationField` to dynamically determine the type based on `target_field_type`. Added a test for proper TypeScript generation with text primary fields in system entities.
2025-09-30 13:32:22 +02:00
dswbx 0f54e8267f fix: exclude internal sqlite indices from introspection
Added a condition to filter out internal SQLite indices named 'sqlite_%' during introspection, ensuring cleaner and more relevant metadata retrieval.
2025-09-30 13:31:49 +02:00
dswbx 1fdee8435d feat: add timestamps plugin to manage created_at and updated_at fields
Introduced a new timestamps plugin that allows the addition of `created_at` and `updated_at` fields to specified entities. Included tests to verify functionality, ensuring timestamps are correctly set on entity creation and updates. Updated the plugin index to export the new timestamps functionality.
2025-09-29 22:12:23 +02:00
dswbx 1eeb23232a chore: bump version to 0.18.0-rc.10 in package.json and update EntityMediaFormField logic for single item handling 2025-09-29 22:10:07 +02:00
dswbx 6102759da8 chore: bump version to 0.18.0-rc.9 in package.json 2025-09-29 17:30:18 +02:00
dswbx 6cea581e42 fix: update database sync warning to clarify behavior in code mode
Revised the warning message for database sync requests to indicate that sync requests are ignored in code mode, as system tables may not be fully represented in the provided configuration.
2025-09-29 17:28:43 +02:00
dswbx 5e553a7fce Merge pull request #276 from bknd-io/feat/media-field-improvements
feat: improve media handling
2025-09-29 17:19:57 +02:00
dswbx 5e71fc8947 feat: implement media cleanup on entity deletion and enhance Dropzone logging
Added functionality to clean up media files associated with entities upon deletion, ensuring proper resource management. Enhanced logging in the Dropzone component to provide better feedback on file type validation and user interactions, improving overall user experience.
2025-09-29 16:47:44 +02:00
dswbx d1ba638cd5 fix: refine mime type checks and improve MediaInfoModal layout
Updated tests to check for mime types using `toStartWith` for better validation. Adjusted mime type patterns in DropzoneInner for XML handling. Modified MediaInfoModal to enhance layout responsiveness and broaden text format support for previews, ensuring better file handling and user experience.
2025-09-29 16:07:40 +02:00
dswbx d31416f85d feat: enhance mime type handling and improve file metadata management
Updated mime types to include additional formats for text and application categories. Improved file metadata handling in the Storage class to accurately reflect file properties during uploads. Enhanced the DropzoneInner component with new icons for various file types and refined the MediaInfoModal to support a broader range of text formats for previews.
2025-09-29 15:55:25 +02:00
dswbx 55082e9d0e feat: improve media handling
added local range requests, fix mime type detection, improve uploading using FormData, correctly use mime type allow list, added previews for audio, pdf and text
2025-09-29 14:55:19 +02:00
dswbx 0d74625270 feat: fix PDF mime type detection and enhance filename handling tests
Updated the mime types to include "pdf" in the application category. Enhanced the test suite for media mime types to validate filename handling for PDF files, ensuring correct extensions are maintained based on file type and name. This improves the robustness of the file handling logic in the application.
2025-09-29 09:55:02 +02:00
dswbx 800f14ede2 fix: fix wrangler platform proxy handling
Modified the external dependencies in the build configuration to include "bknd" and its submodules. Added a new asynchronous function to handle platform proxy options, improving error handling for the "wrangler" import and providing clearer logging for version resolution. Additionally, ensured that the schema manager skips processing for tables without names.
2025-09-29 09:28:37 +02:00
dswbx 560379bd89 docs: added basic Admin UI configuration documentation
Added a new `BkndAdminConfig` type to streamline Admin UI configuration options, consolidating properties for base path, logo return path, theme, entities, and app shell settings. Updated `BkndAdminProps` to utilize this new configuration type. Additionally, introduced a new documentation section for extending the Admin UI, detailing customization options and providing examples for advanced usage.
2025-09-25 10:45:10 +02:00
dswbx daafee2c06 Merge pull request #275 from bknd-io/fix/s3-client-minio
fix: set s3 client service to s3 to produce correct headers required by minio
2025-09-25 09:28:52 +02:00
dswbx ab3e8ce55f fix: set s3 client service to s3 to produce correct headers required by minio 2025-09-25 09:25:13 +02:00
dswbx a655c990ed feat: update documentation to reflect configuration changes and add progress callouts
Added callouts to various documentation modules indicating that the documentation is a work in progress. Updated references from `initialConfig` to `config` in multiple sections to align with recent changes in configuration handling.
2025-09-24 18:14:55 +02:00
dswbx 2c976adb77 chore: bump package version to 0.18.0-rc.8 2025-09-24 16:25:23 +02:00
dswbx 1128ac500d feat: add code-only tests and enhance CLI sync command with seeding option
Introduced a new test suite for code-only applications, validating app creation, database sync behavior, and seeding functionality. Enhanced the CLI sync command to include a seeding option, allowing for explicit seeding during database synchronization. Added error handling for unresolved config files in the run command.
2025-09-24 16:23:16 +02:00
dswbx 06d7558c3c feat: batch schema manager statements
run all schema modification queries in a single batch/transaction, to enable automatic rollbacks, and to stay within cloudflare's subrequest limits in free plan.
2025-09-24 14:48:45 +02:00
dswbx 7344b1cf3d feat: add media option to module to restrict body max size
updated media schema to enforce strict validation, introduced `options` for AppMedia, and added a key prefix feature for StorageR2Adapter to enhance flexibility and control.
2025-09-24 10:29:03 +02:00
dswbx ace9c1b2b9 feat: add helper methods for auth cookie headers
introduced `getAuthCookieHeader` and `removeAuthCookieHeader` methods to simplify header management for authentication cookies. added tests to validate the new methods.
2025-09-24 10:26:07 +02:00
dswbx 0629e1bc50 feat: add role selection and auth checks in user create command
integrated role selection prompt during user creation and added an auth-enabled check to ensure correct configuration before executing commands. adjusted CLI commands to include role assignment for newly created users.
2025-09-24 10:24:37 +02:00
dswbx 832eb6ac31 feat: add migration to version 10 and update tests
introduced a new config migration to version 10, updated related tests to validate migration.
2025-09-24 09:58:22 +02:00
dswbx aa8bf156b0 fix: throw error if fetch called before build
added a check in the fetch getter to prevent its usage before the application is built, ensuring proper usage and avoiding runtime issues.
2025-09-24 09:53:28 +02:00
dswbx 83c1c86eff chore: update Bun version to 1.2.22 and bump package version to 0.18.0-rc.7 2025-09-23 14:00:08 +02:00
dswbx ffe53d3fb5 fix: ensure form updates with latest data after mutation
added a form reset to reflect up-to-date data changes and adjusted mutation to prevent id exclusion, ensuring lists are updated properly.
2025-09-23 13:47:10 +02:00
dswbx 49aee37199 feat: lazy load mcp server 2025-09-23 13:46:39 +02:00
dswbx 54eee8cd34 feat: add schema marking and validation skip mechanism 2025-09-23 13:43:37 +02:00
dswbx 5e62e681e7 feat: introduce DummyConnection class for testing purposes 2025-09-21 14:20:07 +02:00
dswbx 99c1645411 chore: bump version to 0.18.0-rc.6 and fix EntityTypescript for system entities 2025-09-21 14:16:57 +02:00
dswbx 564eab23af docs: improve CLI documentation formatting 2025-09-20 20:02:56 +02:00
dswbx f2da54c92b docs: enhance documentation with new modes and plugins
- Updated documentation to include new modes for configuring bknd (UI-only, Code-only, Hybrid).
- Introduced `syncSecrets` plugin example in the extending plugins documentation.
- Added `react-icons` dependency to package.json and package-lock.json.
- Enhanced various documentation pages with icons and improved content structure.
2025-09-20 19:57:38 +02:00
dswbx cd262097dc chore: bump version to 0.18.0-rc.5 2025-09-20 14:58:11 +02:00
dswbx d1726b23f1 fix: prevent rendering of hidden fields in EntityForm component 2025-09-20 14:51:30 +02:00
dswbx 50e9be833b chore: updated cf deps, improved vite dev 2025-09-20 13:59:41 +02:00
dswbx 806b7427c1 Merge pull request #266 from bknd-io/feat/generic-sqlite-modes
feat: enhance SQLite connection configurations to allow WAL
2025-09-20 10:25:19 +02:00
dswbx 8742938074 docs: swap Bun and Node.js SQLite sections 2025-09-20 10:21:08 +02:00
dswbx 36e61cab3f feat: enhance SQLite connection configurations to allow WAL
Updated the Bun and Node SQLite connection implementations to support additional configuration options, including `onCreateConnection`. Introduced tests for connection creation to validate database instance types and ensure proper callback execution. Improved type exports for better integration with existing code.
2025-09-20 10:16:33 +02:00
dswbx 17d4adbbfa chore: bump version to 0.18.0-rc.4 and enhance test logging
Updated the package version to 0.18.0-rc.4. Improved test logging by disabling console output during tests to reduce noise and enhance readability. Adjusted various test files to implement console log management, ensuring cleaner test outputs.
2025-09-19 20:41:35 +02:00
dswbx d052871fe0 fix: update external dependencies and enhance error logging
Added "wrangler" to the external dependencies in the build configuration. Improved error logging in the Storage class to include the file name when image dimension retrieval fails. Enhanced the InvalidSchemaError to provide additional context by including the schema in the error message.
2025-09-19 20:05:53 +02:00
dswbx b0e5a49b0b Merge pull request #265 from bknd-io/feat/admin-ui-customizations
feat: add admin options for entities and app shell
2025-09-19 20:04:07 +02:00
dswbx 35cbcc221a added missing types export for options, init admin ui docs as patch 2025-09-19 20:01:03 +02:00
dswbx 26d1f2b583 feat: add admin options for entities and app shell
Introduced `BkndAdminEntitiesOptions` and `BkndAdminAppShellOptions` for advanced customization of entity actions, headers, footers, and app shell user menu. Updated related components, hooks, and types for seamless integration with the new configuration options.
2025-09-19 11:36:31 +02:00
dswbx 95d114ea68 test: adjust expectation for beforeBuild call count
Updated the test to expect `beforeBuild` to be called twice, reflecting changes in the test conditions or setup logic.
2025-09-19 11:30:38 +02:00
dswbx 059becbf09 refactor: update id handling for media entity
Revised `id` and `entity_id` types to support both string and number for flexibility. Adjusted `PolymorphicRelation` to use `PrimaryFieldType` for improved type safety and maintainability.
2025-09-19 11:28:51 +02:00
dswbx 91120091a3 feat: add fallback redirect for cloudflare image optimization plugin
Introduced `fallbackRedirect` option to enable a redirect to the original image if image optimization fails. Added logging for platform proxy usage and adjusted `beforeBuild` to handle undefined `app`. Minor improvements and process termination for clear command flow.
2025-09-19 11:28:04 +02:00
dswbx 99a812cc75 test: remove unused parameter from getDummyConnection calls 2025-09-18 10:23:18 +02:00
dswbx 29a2de31c6 bump version to 0.18.0-rc.3 2025-09-18 10:06:56 +02:00
dswbx 2688bf261b Merge pull request #264 from bknd-io/fix/admin-ui-form-improvements
ui: improve form field components, add support for custom fields, export components
2025-09-18 10:04:19 +02:00
dswbx 15c32c0e6d ui: improve form field components, add support for custom fields, export components
Enhanced `FieldLabel` to support tooltips and additional props. Refactored form field components for better customizability and ensured compatibility with new `CustomField`. Updated `onChange` handlers to include context for improved flexibility. Added exports for UI components.
2025-09-18 10:03:16 +02:00
dswbx d6f2500be2 Merge pull request #263 from bknd-io/fix/cf-vite-writer-plugin-large-payloads
fix: implement chunked request handling for cloudflare vite dev-fs plugin
2025-09-18 10:00:19 +02:00
dswbx fa8931ad15 fix: implement chunked request handling for cloudflare vite dev-fs plugin
Reworked the dev-fs plugin to handle chunked write requests efficiently. Added mechanisms to track, process, and validate chunks, ensuring reliable file writes even for large data. Enhanced verbose logging for better debugging.
2025-09-18 09:59:10 +02:00
dswbx 6e3060141b refactor: improve type handling and config structure
Updated various type definitions to improve flexibility and maintain consistency, including `MaybePromise` and `PartialRec`. Adjusted `App` class and related configurations to properly utilize these changes.
2025-09-18 09:58:12 +02:00
dswbx 07029e3797 Merge pull request #262 from bknd-io/fix/typegen-valid-identifiers-and-comments
fix: typegen: adjust field formatting and type name handling
2025-09-18 09:57:01 +02:00
dswbx 3672cc7f2c fix: adjust field formatting and type name handling
Enabled multiline comments for fields to align with entity settings. Updated `typeName` to remove spaces for consistent formatting.
2025-09-18 09:55:33 +02:00
dswbx ac32eb128e Merge pull request #261 from bknd-io/fix/unique-indexes-without-required-first
refactor: remove unique field validation from `EntityIndex`
2025-09-18 09:53:42 +02:00
dswbx ffdf453fea fix: remove non-unique index test
The removed test no longer aligns with the current `EntityIndex` implementation, as non-unique validation is not applicable anymore.
2025-09-18 09:52:05 +02:00
dswbx 9dd7432e6b refactor: remove unique field validation from EntityIndex
Eliminated validation requiring the first field in unique indices to be mandatory. This simplifies the logic as the requirement is no longer necessary.
2025-09-18 09:50:05 +02:00
dswbx 8c4a8d91a2 fix: secret handling and relation naming logic
Implemented improved handling for secrets in `DbModuleManager`, allowing secret merging and building before saving. Enhanced `ManyToManyRelation` naming to produce valid identifiers and ensured better testing coverage for both changes.
2025-09-18 09:47:47 +02:00
dswbx 9aae6e78d6 config: allow full property usage in app function and improve type consistency
Added support for all properties in the `app` function configuration and ensured consistent type definitions for `BkndConfig`. Updated `makeConfig` function to reflect these changes and added relevant unit tests.
2025-09-16 16:41:16 +02:00
dswbx 317b2b50ad fix cli for cloudflare proxy and plugins
- proper cli exists required for cloudflare proxy to dispose
- updated cloudflare proxy to allow proxy options (e.g. remote)
- updated config command to include proper required structure for the export on code mode
2025-09-16 16:08:01 +02:00
dswbx a0b2dde034 fix: improve asset handling, logging, and ui conditions
Refactored asset handling to support multiple CSS entries and fixed context handling in authenticator when executed through mcp. Adjusted UI readonly conditions.
2025-09-16 16:04:09 +02:00
dswbx 70eaa22327 Merge remote-tracking branch 'origin/main' into release/0.18
# Conflicts:
#	app/package.json
2025-09-15 16:29:15 +02:00
dswbx ddfc3e599f docs: corrected app function parameter signature 2025-09-15 14:36:00 +02:00
dswbx c11dd2bd9e Merge pull request #260 from bknd-io/fix/docs-env
update docs: add `bknd.config.ts` example and clarify cloudflare setup
2025-09-15 12:46:40 +02:00
dswbx 77c85bfd5c update docs: add bknd.config.ts example and clarify cloudflare setup
Added a detailed example to `bknd.config.ts` documentation showcasing database connection and custom routes. Updated Cloudflare guide with clearer sections, marking modern assets usage as recommended and workers sites as legacy.
2025-09-15 12:41:49 +02:00
dswbx 7f8d8d26ac Merge pull request #259 from bknd-io/release/0.17.2
Bugfix Release 0.17.2
2025-09-15 10:15:14 +02:00
dswbx 655bb241a8 bump version to 0.17.2 2025-09-15 10:14:14 +02:00
dswbx 1ef5bf7d15 fix: adjust mcp schema validation and add default path support
Updated `mcp.json` to enhance validation by supporting `anyOf` schema for `entity` properties and refined `role` typing. Introduced default `path` property for MCP system API and aligned build assets configuration accordingly.
2025-09-15 10:09:51 +02:00
dswbx 9a1589da0b bump @bknd/postgres version to 0.2.0 2025-09-15 10:07:34 +02:00
dswbx f231353228 bump version to 0.17.2-rc.1 2025-09-15 09:31:01 +02:00
dswbx d2de642c17 fix: update build config and improve e2e adapter handling
Added dynamic inclusion of dependencies as external packages and switched build platform to browser. Streamlined e2e adapter test setup, improved type safety, and standardized console output for better clarity.
2025-09-15 09:25:32 +02:00
dswbx 50c5383138 Merge pull request #257 from bknd-io/fix/postgres-0.17-updates
postgres: bump 0.17.1 and improve custom connection API
2025-09-14 17:10:32 +02:00
dswbx fff88a9237 Merge pull request #258 from bknd-io/fix/mcp-auth-and-insert
fix: handle numbered object conversion and update MCP tool URL
2025-09-14 17:09:55 +02:00
dswbx d768a80526 Merge pull request #256 from bknd-io/fix/docker-0.17
bump Dockerfile to use bknd 0.17.1
2025-09-14 17:09:34 +02:00
dswbx 0d79b19c66 Merge pull request #255 from bknd-io/fix/node-static-import
fix raw node execution by making sure import attributes stick on builds
2025-09-14 17:08:54 +02:00
dswbx fea2812688 fix: handle numbered object conversion and update MCP tool URL
Add `convertNumberedObjectToArray` utility for handling numbered object to array conversion, addressing MCP tool allOf behavior. Adjust MCP tool URL in configuration and ensure default inspect options in development environment. Minor improvement in role enumeration handling for auth.
2025-09-14 17:03:23 +02:00
dswbx 62368c691a postgres: bump 0.17.1 and improve custom connection API
Aligned connection constructors to include an explicit name parameter, updated documentation, and streamlined connection methods for consistency. Adjusted dependencies and cleaned unused references.
2025-09-14 16:01:37 +02:00
dswbx dd9617598f bump Dockerfile to use bknd 0.17.1
Updated the default bknd version to 0.17.1 and included the `jsonv-ts` dependency in the Dockerfile.
2025-09-14 14:07:56 +02:00
dswbx a7f6d45ca9 fix raw node execution by making sure import attributes stick on builds 2025-09-14 13:43:45 +02:00
dswbx 92656523ff Merge pull request #254 from quicujo/feat/validate-uuid
Add UUID validation utility
2025-09-14 13:19:57 +02:00
anton e6ef7d9ff4 Add UUID validation utility
Add uuidValidate function to validate UUID strings and check their version.
Supports validation for v4 and v7 UUIDs using the uuid library's validate
and version functions.
2025-09-08 05:06:42 +02:00
dswbx 732cd31e1f ui: fix appshell section tabs styling 2025-09-05 17:24:47 +02:00
dswbx 1a7670f57a Merge pull request #253 from bknd-io/feat/ui-readonly-and-fixes
ui: reflect readonly mode by hiding controls + various small styling fixes
2025-09-05 17:10:55 +02:00
dswbx af573cc79a ui: reflect readonly mode by hiding controls + various small styling fixes 2025-09-05 17:09:50 +02:00
dswbx 1df87c8a16 bump rc version 2025-09-05 15:47:54 +02:00
dswbx 3c5e3f9638 manager: fix secrets extraction for both modes 2025-09-05 14:14:12 +02:00
dswbx 7a0a7481c0 Merge pull request #252 from bknd-io/feat/manager-secrets-and-plugins
improved module manager's secrets extraction, updated plugins
2025-09-05 13:32:36 +02:00
dswbx bdcc81b2f1 improved module manager's secrets extraction, updated plugins 2025-09-05 13:31:20 +02:00
dswbx 210b22e307 Merge pull request #251 from bknd-io/fix/cf-adapter-exports
cloudflare: fix missing serve export
2025-09-05 09:18:36 +02:00
dswbx 734235c798 cloudflare: fix missing serve export 2025-09-05 09:17:08 +02:00
dswbx 94e168589d Merge pull request #249 from bknd-io/feat/code-first-and-secrets-extract
init code-first mode by splitting module manager
2025-09-04 20:49:45 +02:00
dswbx c22339e4bf Merge pull request #247 from bknd-io/fix/cli-externalize-deps
cli: externalize all direct dependencies to prevent being bundled
2025-09-04 20:43:32 +02:00
dswbx d01058595f update system info result, fix cloudflare export 2025-09-04 20:42:19 +02:00
dswbx fdec5f0693 adapters: remove runtime options, serve app always fresh to prevent race conditions 2025-09-04 19:36:21 +02:00
dswbx e8f2c70279 mm: added secrets extraction for db mode 2025-09-04 15:20:12 +02:00
dswbx 2c5371610b fix types, refactor db manager to db 2025-09-04 13:00:27 +02:00
dswbx 758a89b5d7 fix tests: replace initialConfig with config 2025-09-04 10:44:14 +02:00
dswbx e3888537f9 init code-first mode by splitting module manager 2025-09-04 09:21:35 +02:00
dswbx c9773d49a6 bump 0.17.1 2025-09-03 17:17:26 +02:00
dswbx a559a2eabc cli: externalize all direct dependencies to prevent being bundled 2025-09-03 17:17:07 +02:00
dswbx c9fb497c2d Merge pull request #246 from bknd-io/fix/mobile-input-zoom
style: fix mobile zoom on input focus/blur
2025-09-03 17:03:14 +02:00
dswbx ccd4eb10cb Merge pull request #245 from bknd-io/fix/cf-instance-flash
cloudflare: fixing multiple instances competing with configuration state by always serving fresh
2025-09-03 17:03:03 +02:00
dswbx 847f75b725 docs: update cloudflare imports 2025-09-03 17:00:24 +02:00
dswbx 8b4bf0d94a style: fix mobile zoom on input focus/blur 2025-09-03 08:28:27 +02:00
dswbx bf521e2931 cloudflare: fixing multiple instances competing with configuration state by always serving fresh 2025-09-03 07:54:40 +02:00
dswbx 111b2ae364 docs: fix generated mcp tools page title 2025-08-30 17:31:22 +02:00
dswbx dc08d12ce6 docs & readme: fix demo 2025-08-30 16:21:46 +02:00
dswbx ce773007f0 readme: update badges 2025-08-30 15:05:46 +02:00
dswbx e78c271c2f readme: added mcp as feature 2025-08-30 15:03:46 +02:00
dswbx 7ee07adc70 v0.17.0 2025-08-30 14:20:25 +02:00
dswbx 53e5c6fd98 Merge pull request #234 from bknd-io/release/0.17
Release 0.17
2025-08-30 14:16:37 +02:00
dswbx 4591631d3c fix e2e tests by forcely setting to be used db 2025-08-30 14:11:08 +02:00
dswbx 5a27c41516 mcp: fix tests changing new default mcp endpoint 2025-08-30 14:08:20 +02:00
dswbx 24eefa5357 mcp: added path config, register at /api path by default to work with frameworks 2025-08-30 14:06:13 +02:00
dswbx d898018b49 mcp ui: add copy on history 2025-08-30 13:41:15 +02:00
dswbx 09fdde1c5b fix mcp auth, styling fixes 2025-08-30 13:31:48 +02:00
dswbx fb0dbb9a51 fix system mcp notification console 2025-08-30 11:19:26 +02:00
dswbx 81f578895e bump version, fix cloudflare export 2025-08-30 10:20:07 +02:00
dswbx 2de12674d9 docs: mcp (#243)
* added mcp documentation

* added mcp docs
2025-08-30 10:15:56 +02:00
dswbx 5ed1cf19b6 docs: plugins, cloudflare, sdk, elements, database (#240)
* docs: added plugins docs, updated cloudflare docs

* updated cli help text

* added `systemEntity` and added docs on how to work with system entities

* docs: added defaults to cloudflare image plugin

* docs: updated sdk and elements
2025-08-29 12:50:23 +02:00
dswbx 5b879ac6bf Merge pull request #242 from bknd-io/feat/cli-global-options
cli: align config aware commands options
2025-08-29 09:41:52 +02:00
dswbx c04ad89a86 cli: align config aware commands options 2025-08-29 09:40:27 +02:00
dswbx 93019827b0 cli: updated config to get config from app, added sync command (#241)
* cli: updated config to get config from app, added sync command

* updated command description
2025-08-29 08:40:14 +02:00
dswbx 9436b8bac5 cloudflare: enable fs with @cloudflare/vite-plugin (#239)
* init dev vite write plugin

* reorganized cf proxy, fixed dev write
2025-08-29 06:44:20 +02:00
dswbx 8f471a29b0 json viewer: updated size display 2025-08-28 19:35:32 +02:00
dswbx dcf88cf587 mcp: improve auth id type + styling fixes 2025-08-21 10:58:31 +02:00
dswbx f9184a71db Merge pull request #225 from bknd-io/feat/mcp
initialized mcp support
2025-08-20 19:14:05 +02:00
dswbx bc1ab0f6d3 mcp: sorting tools, fixed cloudflare example, fixed some styling 2025-08-20 18:26:48 +02:00
dswbx 0b21157f92 Merge pull request #237 from bknd-io/docs/fix-nextjs-and-events
docs: fixed events, commented edge runtime for nextjs
2025-08-20 17:06:35 +02:00
dswbx cacb13e464 docs: fixed events, commented edge runtime for nextjs 2025-08-20 16:05:56 +02:00
dswbx 8ec03afbe8 fix mcp ui heights 2025-08-20 15:45:03 +02:00
dswbx ab8be017c1 updated docs mcp tools 2025-08-15 10:28:04 +02:00
dswbx 5c28511fe6 added getMcpClient to app 2025-08-15 10:27:05 +02:00
dswbx deb8aacca4 added mcp ui as tool 2025-08-15 10:12:09 +02:00
dswbx aafd333d12 fix form isRequired utils and test 2025-08-14 16:56:12 +02:00
dswbx 9e0eaf42e5 fix jsonv-ts version 2025-08-14 16:52:22 +02:00
dswbx 63254de13a added a simple mcp ui in tests 2025-08-14 16:49:31 +02:00
dswbx 9ac5fa03c6 optimized performance 2025-08-14 10:05:15 +02:00
dswbx 70f0240da5 mcp: added auth tests, updated data tests 2025-08-12 22:13:09 +02:00
dswbx a6ed74d904 added mcp tests for media 2025-08-12 20:57:13 +02:00
dswbx bd3d2ea900 added data mcp tests 2025-08-12 20:22:38 +02:00
dswbx 871cec9251 Merge remote-tracking branch 'origin/release/0.17' into feat/mcp
# Conflicts:
#	app/src/data/AppData.ts
#	app/src/data/server/query.ts
#	examples/cloudflare-worker/src/index.ts
2025-08-12 16:17:26 +02:00
dswbx 97d6af3792 cleaned up left over bknd/utils imports (#235) 2025-08-12 16:12:10 +02:00
dswbx 725a2d453a adapter(cf): fix exports and type name for do db (#232) 2025-08-12 15:30:45 +02:00
dswbx 375d2c205f adapter(cloudflare): removed durable mode, added withPlatformProxy (#233)
* removed `durable` mode as it requires an import from "cloudflare:" that often fails in non-cf environments

* remove worker configuration types

* add `withPlatformProxy`

* withPlatformProxy: make configuration optional
2025-08-12 15:29:09 +02:00
dswbx f40ea0ec5b init mcp data tests, added crud for $record 2025-08-12 12:55:14 +02:00
dswbx 1e8c373dd4 added mcp server tests 2025-08-12 09:17:38 +02:00
dswbx 2e145bbf95 init mcp tools test 2025-08-12 08:51:32 +02:00
dswbx 822e6fd644 fix tools when auth is disabled + log notifications to console 2025-08-09 15:07:20 +02:00
dswbx ad04d5273e docs: update tools, test bun 2025-08-09 14:23:11 +02:00
dswbx c1c23d24fe switch to node api for generate-mcp for cf builds 2025-08-09 14:18:44 +02:00
dswbx 06300427cb update jsonv-ts, fixed tests 2025-08-09 14:16:51 +02:00
dswbx cb873381f1 auto generated tools docs, added stdio transport, added additional mcp config and permissions 2025-08-09 14:14:51 +02:00
dswbx b5b4c5eef0 Merge pull request #231 from DimplesY/main
fix: add missing @clack/prompts dependency
2025-08-08 13:37:53 +02:00
Dimples_YJ 7af8e09468 fix: add missing @clack/prompts dependency 2025-08-08 13:51:12 +08:00
dswbx 170ea2c45b added additional permissions, implemented mcp authentication 2025-08-07 15:20:29 +02:00
dswbx 42db5f55c7 added authentication, $schema, fixed media adapter mcp 2025-08-07 11:33:46 +02:00
dswbx 1b02feca93 added mcp tools from routes 2025-08-07 08:36:12 +02:00
dswbx fd966b6aef Merge pull request #230 from stormbyte/main
Update Dockerfile to node 24
2025-08-06 20:01:54 +02:00
stormbyte 6bed2e4fca Update to node 24 2025-08-06 16:06:03 +02:00
dswbx dfb1457dfa Merge pull request #229 from DimplesY/main
docs: fix <Admin /> config props
2025-08-06 10:00:33 +02:00
Jie 4f945842a9 docs: update astro.mdx 2025-08-06 15:52:05 +08:00
Jie 44fdfcc8d9 docs: Correct the incorrect value of the config attribute in the admin component 2025-08-06 15:19:28 +08:00
dswbx 07810ff63c Merge remote-tracking branch 'origin/main' into feat/mcp
# Conflicts:
#	app/package.json
#	bun.lock
2025-08-06 08:44:46 +02:00
dswbx 143d6a55ff updated lock to 0.16.1 2025-08-06 08:31:19 +02:00
dswbx 8fa905a8f1 docs: adding poster to assets (#228)
* docs: adding poster to assets

* readme: fix poster image
2025-08-06 08:29:46 +02:00
dswbx ad0d2e6ff8 add jsonv-ts as dependency for type inference, fix media api types (#227)
* add jsonv-ts as dependency for type inference, fix media api types

* add jsonv-ts as dependency for type inference, fix media api types

* add jsonv-ts as dependency for type inference, fix media api types
2025-08-06 08:21:17 +02:00
dswbx aa0e6f90d9 examples: fixing imports due to 0.16 (#226) 2025-08-06 08:19:29 +02:00
dswbx 3e2938f77d added initial $record 2025-08-05 13:20:00 +02:00
dswbx ac891a4b61 Merge remote-tracking branch 'origin/fix/release-0.16-examples' into fork/cameronapak/cp/216-fix-users-link 2025-08-03 13:21:05 +02:00
dswbx 412eab6653 examples: fixing imports due to 0.16 2025-08-03 13:15:01 +02:00
dswbx 2fb07698d5 fixing AppReduced.spec.ts 2025-08-03 13:10:49 +02:00
dswbx 64e400c4f1 ui AppReduced: fixing basepath handling in static and embedded modes 2025-08-03 13:08:04 +02:00
dswbx 86d6e891a8 Merge remote-tracking branch 'origin/main' into fork/cameronapak/cp/216-fix-users-link
# Conflicts:
#	bun.lock
2025-08-03 12:55:20 +02:00
dswbx 5e5f0ef70f fixing jsonv-ts imports 2025-08-02 16:47:24 +02:00
dswbx 104885ea5c fix tests failing because of new module ctx 2025-08-02 16:36:23 +02:00
dswbx ffbb61d58a initialized mcp support 2025-08-02 16:33:05 +02:00
dswbx bd48bb7a18 v0.16 2025-08-01 16:03:23 +02:00
dswbx a298b65abf Release 0.16 (#196)
* initial refactor

* fixes

* test secrets extraction

* updated lock

* fix secret schema

* updated schemas, fixed tests, skipping flow tests for now

* added validator for rjsf, hook form via standard schema

* removed @sinclair/typebox

* remove unneeded vite dep

* fix jsonv literal on Field.tsx

* fix schema import path

* fix schema modals

* fix schema modals

* fix json field form, replaced auth form

* initial waku

* finalize waku example

* fix jsonv-ts version

* fix schema updates with falsy values

* fix media api to respect options' init, improve types

* checking media controller test

* checking media controller test

* checking media controller test

* clean up mediacontroller test

* added cookie option `partitioned`, as well as cors `origin` to be array, option to enable `credentials` (#214)

* added cookie option `partitioned`, as well as cors `origin` to be array, option to enable `credentials`

* fix server test

* fix data api (updated jsonv-ts)

* enhance cloudflare image optimization plugin with new options and explain endpoint (#215)

* feat: add ability to serve static by using dynamic imports (#197)

* feat: add ability to serve static by using dynamic imports

* serveStaticViaImport: make manifest optional

* serveStaticViaImport: add error log

* refactor/imports (#217)

* refactored core and core/utils imports

* refactored core and core/utils imports

* refactored media imports

* refactored auth imports

* refactored data imports

* updated package json exports, fixed mm config

* fix tests

* feat/deno (#219)

* update bun version

* fix module manager's em reference

* add basic deno example

* finalize

* docs: fumadocs migration (#185)

* feat(docs): initialize documentation structure with Fumadocs

* feat(docs): remove home route and move /docs route to /route

* feat(docs): add redirect to /start page

* feat(docs): migrate Getting Started chapters

* feat(docs): migrate Usage and Extending chapters

* feat(callout): add CalloutCaution, CalloutDanger, CalloutInfo, and CalloutPositive

* feat(layout): add Discord and GitHub links to documentation layout

* feat(docs): add integration chapters draft

* feat(docs): add modules chapters draft

* refactor(mdx-components): remove unused Icon import

* refactor(StackBlitz): enhance type safety by using unknown instead of any

* refactor(layout): update navigation mode to 'top' in layout configuration

* feat(docs): add @iconify/react package

* docs(mdx-components): add Icon component to MDX components list

* feat(docs): update Next.js integration guide

* feat(docs): update React Router integration guide

* feat(docs): update Astro integration guide

* feat(docs): update Vite integration guide

* fix(docs): update package manager initialization commands

* feat(docs): migrate Modules chapters

* chore(docs): update package.json with new devDependencies

* feat(docs): migrate Integration Runtimes chapters

* feat(docs): update Database usage chapter

* feat(docs): restructure documentation paths

* chore(docs): clean up unused imports and files in documentation

* style(layout): revert navigation mode to previous state

* fix(docs): routing for documentation structure

* feat(openapi): add API documentation generation from OpenAPI schema

* feat(docs): add icons to documentation pages

* chore(dependencies): remove unused content-collections packages

* fix(types): fix type error for attachFile in source.ts

* feat(redirects): update root redirect destination to '/start'

* feat(search): add static search functionality

* chore(dependencies): update fumadocs-core and fumadocs-ui to latest versions

* feat(search): add Powered by Orama link

* feat(generate-openapi): add error handling for missing OpenAPI schema

* feat(scripts): add OpenAPI generation to build process

* feat(config): enable dynamic redirects and rewrites in development mode

* feat(layout): add GitHub token support for improved API rate limits

* feat(redirects): add 301 redirects for cloudflare pages

* feat(docs): add Vercel redirects configuration

* feat(config): enable standalone output for development environment

* chore(layout): adjust layout settings

* refactor(package): clean up ajv dependency versions

* feat(docs): add twoslash support

* refactor(layout): update DocsLayout import and navigation configuration

* chore(layout): clean up layout.tsx by commenting out GithubInfo

* fix(Search): add locale to search initialization

* chore(package): update fumadocs and orama to latest versions

* docs: add menu items descriptions

* feat(layout): add GitHub URL to the layout component

* feat(docs): add AutoTypeTable component to MDX components

* feat(app): implement AutoTypeTable rendering for AppEvents type

* docs(layout): switch callouts back to default components

* fix(config): use __filename and __dirname for module paths

* docs: add note about node.js 22 requirement

* feat(styles): add custom color variables for light and dark themes

* docs: add S3 setup instructions for media module

* docs: fix typos and indentation in media module docs

* docs: add local media adapter example for Node.js

* docs(media): add S3/R2 URL format examples and fix typo

* docs: add cross-links to initial config and seeding sections

* indent numbered lists content, clarified media serve locations

* fix mediacontroller tests

* feat(layout): add AnimatedGridPattern component for dynamic background

* style(layout): configure fancy ToC style ('clerk')

* fix(AnimatedGridPattern): correct strokeDasharray type

* docs: actualize docs

* feat: add favicon

* style(cloudflare): format code examples

* feat(layout): add Github and Discord footer icons

* feat(footer): add SVG social media icons for GitHub and Discord

* docs: adjusted auto type table, added llm functions

* added static deployment to cloudflare workers

* docs: change cf redirects to proxy *.mdx instead of redirecting

---------

Co-authored-by: dswbx <dennis.senn@gmx.ch>
Co-authored-by: cameronapak <cameronandrewpak@gmail.com>

* build: improve build script

* add missing exports, fix EntityTypescript imports

* media: Dropzone: add programmatic upload, additional events, loading state

* schema object: disable extended defaults to allow empty config values

* Feat/new docs deploy (#224)

* test

* try fixing pm

* try fixing pm

* fix docs on imports, export events correctly

---------

Co-authored-by: Tim Seriakov <59409712+timseriakov@users.noreply.github.com>
Co-authored-by: cameronapak <cameronandrewpak@gmail.com>
2025-08-01 15:55:59 +02:00
cameronapak 7b86611f35 bun run format 2025-07-22 08:58:30 -05:00
cameronapak 9e316a92f6 refactor: use AppTheme type and handle empty admin_basepath
Replace literal theme union with AppTheme type in AdminController options and
make path helpers resilient when admin_basepath is empty.
2025-07-22 08:57:06 -05:00
cameronapak 12ef72d595 test: add comprehensive tests for AppReduced utility and path normalization 2025-07-22 08:52:32 -05:00
cameronapak 75c29ae5e2 Add .env.local to app/.gitignore 2025-07-22 08:37:31 -05:00
cameronapak f0551f50d1 fix: set default admin_basepath and logo_return_path in constructor
Move default option values into the constructor parameter initializer to
prevent them from being overwritten by the spread operator in
`get options()`.
2025-07-22 08:36:38 -05:00
cameronapak 03aac3437b refactor: unify theme type and admin options from AdminController 2025-07-22 08:33:42 -05:00
cameronapak f07f3d1007 refactor: rename basepath to admin_basepath and normalize admin paths 2025-07-22 08:28:23 -05:00
cameronapak c833b2b539 feat: add @clack/prompts because somehow it was missing? 2025-07-22 05:53:23 -05:00
cameronapak 6070394cca Updated bun.lock file 2025-07-22 05:50:44 -05:00
dswbx daaaae82b6 Merge pull request #203 from bknd-io/fix/auth-redirect-and-adapter-config
fix auth redirect on error and adapter app fn
2025-07-19 09:15:23 +02:00
dswbx 79ace4f1e5 Merge pull request #206 from cameronapak/main
docs: add documentation on S3 integration
2025-07-19 09:13:20 +02:00
dswbx f19977ac28 fix mediacontroller tests 2025-07-19 09:12:28 +02:00
dswbx a3782728f9 indent numbered lists content, clarified media serve locations 2025-07-19 08:39:19 +02:00
cameronapak 8b832f02ac docs: add cross-links to initial config and seeding sections 2025-07-16 23:03:38 -05:00
cameronapak cec32ae881 docs(media): add S3/R2 URL format examples and fix typo 2025-07-16 22:57:15 -05:00
cameronapak 0fffa26d55 docs: add local media adapter example for Node.js 2025-07-16 22:47:09 -05:00
cameronapak 2b6f520f46 docs: fix typos and indentation in media module docs 2025-07-16 22:41:55 -05:00
cameronapak 7379bf2b1b docs: add S3 setup instructions for media module 2025-07-16 22:38:36 -05:00
dswbx db3c2cea10 docs: add note about node.js 22 requirement 2025-07-11 12:49:35 +02:00
dswbx 2b32a836cd auth error should not redirect to parameter, adapter app function should fire correctly 2025-07-09 08:00:52 +02:00
dswbx 957355c862 bump v0.15 2025-07-05 11:08:26 +02:00
dswbx fc282b795e Merge pull request #189 from bknd-io/release/0.15
Release 0.15
2025-07-05 10:24:03 +02:00
dswbx bc65f5f311 Merge remote-tracking branch 'origin/main' into release/0.15 2025-07-05 10:21:25 +02:00
dswbx 5355f2593e readme: add filesystem 2025-07-05 09:43:22 +02:00
dswbx e939debba5 auth: add delete auth after flash in case only one is picked up 2025-07-05 09:43:14 +02:00
dswbx b35ee36fb1 admin: fix back behavior to not rely on history object 2025-07-05 09:42:53 +02:00
dswbx 109c72e84f update readme and docs on dbs 2025-07-03 15:31:14 +02:00
dswbx 7e0b02a125 Merge pull request #194 from stormbyte/main
Update docker.mdx
2025-07-03 15:16:06 +02:00
dswbx d4076803c6 docs: move file name to block, slightly reformulate docker compose instructions 2025-07-03 15:15:18 +02:00
dswbx a5959acb34 fix admin's assetPath for remote static assets 2025-07-03 15:06:21 +02:00
N.A. Walhof a4ddf780ce Finish the sentences 2025-07-03 15:05:49 +02:00
dswbx 2f684765de libsql: update typings to allow minimal client fns (execute, batch) 2025-07-03 14:11:58 +02:00
dswbx 22b54862e8 admin: fix theme flash 2025-07-02 18:14:35 +02:00
dswbx 45138c25f0 refactor auth/media entities to separate files, suppress node:sqlite warning 2025-07-02 16:36:06 +02:00
dswbx 80034b9b0a fix plugins imports 2025-07-02 16:13:22 +02:00
dswbx e6ee75c712 update edge sqlite import (libsql) 2025-07-02 16:08:33 +02:00
dswbx 56287eb05e fixing sqlite imports, clean up bun and cf examples 2025-07-02 15:46:57 +02:00
dswbx 144f35ec66 Merge pull request #195 from bknd-io/fix/cloudflare-media-range
fix cloudflare r2 adapter range requests
2025-07-02 14:10:43 +02:00
dswbx d1378c6c51 fix cloudflare r2 adapter range requests 2025-07-02 14:07:26 +02:00
dswbx 4e10b36d0d Merge pull request #193 from bknd-io/feat/refactor-sqlites
feat/refactor-sqlites
2025-07-02 14:05:58 +02:00
dswbx 70737c04cd refactor console imports, added config update event 2025-07-02 14:02:50 +02:00
dswbx d41fd5541f rewrite libsql and cloudflare sqlite's to use the generic adapter 2025-07-02 14:02:33 +02:00
stormbyte e76e3531d7 Update docker.mdx
Add docker compose self build image
2025-06-26 15:43:51 +02:00
dswbx a9f3a582eb fix tests and imports 2025-06-25 09:42:25 +02:00
dswbx 57ae2f333c inlined libsql dialect, rewrote d1 to use generic sqlite 2025-06-25 09:35:47 +02:00
dswbx b2086c4da7 fix: plugin schema reconciliation 2025-06-18 10:31:40 +02:00
dswbx 344d729320 Merge pull request #191 from bknd-io/feat/admin-add-options-for-static
admin: add options such as logo return path when served static
2025-06-18 08:15:45 +02:00
dswbx c86f4c12b7 admin: add options such as logo return path when served static 2025-06-18 07:56:58 +02:00
dswbx aaa97ed113 finalize initial app resources/drivers 2025-06-17 19:51:12 +02:00
dswbx 69c8aec6fb Merge pull request #190 from bknd-io/feat/app-resources
init app resources
2025-06-17 19:49:09 +02:00
dswbx f5ceffd80e fix tests 2025-06-17 13:34:31 +02:00
dswbx a9f367aa2c updated build to exclude libsql 2025-06-17 13:18:13 +02:00
dswbx b87696a0db init app resources 2025-06-14 16:59:03 +02:00
dswbx 3338804c34 simplify and export generic sqlite functions 2025-06-14 16:58:38 +02:00
dswbx 6b3ac9e6e2 fix toDriver mutation convertion not respecting default values, react re-renders on navigation, mutator result logging 2025-06-14 08:06:05 +02:00
dswbx 8e1ecfcfe3 change app plugins to be a map to prevent duplicates and provide easier access 2025-06-14 07:22:44 +02:00
dswbx 6e2596fb77 fixing tests 2025-06-14 07:08:46 +02:00
dswbx 2ada4e9f20 various fixes: refactored imports, introduced fromDriver/toDriver to improve compat 2025-06-13 21:15:29 +02:00
dswbx cc038a0a9a Merge pull request #187 from bknd-io/feat/plugin-improvements
improved app plugins
2025-06-13 17:29:42 +02:00
dswbx af6d1960b9 fix tests 2025-06-13 17:27:58 +02:00
dswbx 96ae8f9d63 Merge remote-tracking branch 'origin/release/0.15' into feat/plugin-improvements
# Conflicts:
#	app/package.json
#	app/src/App.ts
2025-06-13 17:24:54 +02:00
dswbx c09d1d53b3 Merge pull request #186 from bknd-io/feat/unify-connections
feat/unify-connections
2025-06-13 17:20:05 +02:00
dswbx 2239333f08 updated docs on databases 2025-06-13 17:18:29 +02:00
dswbx 0710b5bf1f Merge pull request #188 from bknd-io/feat/generic-sqlite
env aware sqlite (node, bun)
2025-06-13 16:00:08 +02:00
dswbx 03c4c41d2d cli now uses adapter logic, require node v22 2025-06-13 15:58:06 +02:00
dswbx 85d8542a2e test with node setup pre step 2025-06-13 14:44:55 +02:00
dswbx f8d2a9090e fixing tests, move node tests to vitest 2025-06-13 14:38:30 +02:00
dswbx bbb7bfb7a1 feat: adding env aware endpoint to obtain sqlite connection, remove libsql hard dependency 2025-06-13 11:09:47 +02:00
dswbx 28e277afe1 updated cf image optimization plugin 2025-06-13 08:30:54 +02:00
dswbx 8517c9b90b added a few initial plugins 2025-06-12 19:58:18 +02:00
dswbx fe5ccd4206 refactor and move cloudflare image transformation plugin 2025-06-12 17:00:06 +02:00
dswbx 8b4b63b3cd feat: improved abilities of plugins, moved schema fns to ctx 2025-06-12 15:29:53 +02:00
dswbx 9c4aac8843 docs: simplified custom postgres description 2025-06-12 10:42:49 +02:00
dswbx d5bb6ffa61 fix adapters, handle entity enum more gracefully 2025-06-12 10:24:50 +02:00
dswbx fc513bb413 Merge pull request #184 from bknd-io/release/0.14
Release 0.14
2025-06-12 09:52:01 +02:00
dswbx 4162b9878a fix admin controller to only serve if defined, and only from specified endpoints 2025-06-12 09:45:14 +02:00
dswbx c75f8d0937 reduce schema manager query log to debug, fix useSearch 2025-06-12 09:23:31 +02:00
dswbx 046c1d21b1 fixed tests 2025-06-12 09:12:40 +02:00
dswbx 6c2e579596 connection: rewrote query execution, batching, added generic sqlite, added node/bun sqlite, aligned repo/mutator results 2025-06-12 09:02:18 +02:00
dswbx 88419548c7 admin: fix useSearch 2025-06-10 08:38:10 +02:00
dswbx 12c955155d bump rc 2025-06-09 20:27:02 +02:00
dswbx 15af9f5261 fix use-search 2025-06-09 20:25:57 +02:00
dswbx 39efb911e9 admin header: added links to docs and version 2025-06-09 07:29:20 +02:00
dswbx 4729203d47 fix useSearch and admin path registration 2025-06-09 07:21:32 +02:00
dswbx 0db052acca Merge pull request #182 from bknd-io/feat/postgres-improvements
postgres: added `pg` and `postgres`, and examples for xata and neon
2025-06-07 11:14:45 +02:00
dswbx 6e08f45857 updated docs on new postgres instructions 2025-06-07 11:11:42 +02:00
dswbx 7bff84d601 updated postgres package build 2025-06-07 10:31:22 +02:00
dswbx e66e05b2b0 postgres: make sure to store id as varchar if uuid 2025-06-07 09:59:50 +02:00
dswbx 5b318ce485 Merge remote-tracking branch 'origin/release/0.14' into feat/postgres-improvements 2025-06-07 09:41:32 +02:00
dswbx 22de16fe17 Merge pull request #183 from bknd-io/feat/uuid
feat: adding initial uuid support
2025-06-07 09:40:43 +02:00
dswbx 5d26673bc6 Merge remote-tracking branch 'origin/release/0.14' into feat/uuid
# Conflicts:
#	app/tsconfig.json
#	bun.lock
2025-06-07 09:38:59 +02:00
dswbx dfa4bc0e33 Merge pull request #181 from bknd-io/feat/cf-sessions
adding d1 session support
2025-06-07 09:32:16 +02:00
dswbx e51b89a18a store session cookie/header name in constants, updated docs 2025-06-07 09:29:56 +02:00
dswbx 63988e0c5f store session cookie/header name in constants, updated docs 2025-06-07 09:29:38 +02:00
dswbx 297fd85a4f Merge branch 'release/0.14' into feat/postgres-improvements 2025-06-07 09:01:49 +02:00
dswbx a82fbe7400 Merge branch 'release/0.14' into feat/cf-sessions 2025-06-07 09:01:05 +02:00
dswbx 74e1e9a03f Merge pull request #179 from bknd-io/fix/hono-cookie-body-disturb
fix: lock hono version because of changed header setting in 4.7.7
2025-06-07 08:59:44 +02:00
dswbx 05a81b5bdc Merge remote-tracking branch 'origin/release/0.14' into fix/hono-cookie-body-disturb 2025-06-07 08:52:54 +02:00
dswbx 9ff49103fb bump rc 2025-06-07 08:50:23 +02:00
dswbx a5b59c004e fix logout by setting the api route as logout target 2025-06-07 08:48:28 +02:00
dswbx a0edcf483b added more tests, updated json schema generation 2025-06-07 08:24:21 +02:00
dswbx 9a18e354cd feat: adding initial uuid support 2025-06-06 20:46:01 +02:00
dswbx 58c7aba1a4 postgres: added pg and postgres, and examples for xata and neon 2025-06-06 16:52:07 +02:00
dswbx f8aa242d2b fix tests 2025-06-06 11:30:27 +02:00
dswbx 3da9570abe reduce worker config type 2025-06-06 11:18:44 +02:00
dswbx 6ee898e606 adding d1 session support 2025-06-06 11:13:35 +02:00
dswbx abbd372ddf Merge pull request #180 from bknd-io/docs/bknd-config-ts
docs: added docs about how to use bknd.config.ts
2025-06-05 17:14:30 +02:00
dswbx 3e77982996 docs: added docs about how to use bknd.config.ts 2025-06-05 17:11:50 +02:00
dswbx 3fbea8ace7 fix: cookie setting by only setting on admin routes 2025-06-03 13:11:18 +02:00
dswbx a41f943b43 fix: cookie setting by only setting on admin routes 2025-06-03 13:06:49 +02:00
dswbx 15a9c549e7 fix: cookie setting by only setting on admin routes 2025-06-03 13:04:03 +02:00
dswbx 50cadbaa8e fix: lock hono version because of changed header setting in 4.7.7 2025-06-03 11:12:47 +02:00
dswbx 7b128c9701 Merge pull request #176 from bknd-io/chores/repo-cleanup
repo chores: fixed root dir, removed unused class, added .env.example
2025-05-27 20:27:38 +02:00
dswbx 061181d59d repo chores: fixed root dir, removed unused class, added .env.example 2025-05-27 20:25:37 +02:00
dswbx af6cb0c8f0 bump 0.13.0 + separated cli build into separate file 2025-05-27 16:53:49 +02:00
dswbx 5a693c0370 Merge pull request #169 from bknd-io/release/0.13
Release 0.13
2025-05-27 16:29:43 +02:00
dswbx 262588decc update github action to use bun 1.2.14 + added .nvmrc (24 breaks for node tests) 2025-05-27 13:30:02 +02:00
dswbx 17ab35e245 api: added custom storage option (#174) 2025-05-27 13:09:24 +02:00
dswbx db795ec050 Controllers: New validation + auto OpenAPI (#173)
* updated controllers to use custom json schema and added auto openapi specs

* fix data routes parsing body

* added schema exports to core

* added swagger link to Admin, switched use-search
2025-05-27 09:06:36 +02:00
dswbx 773df544dd feat/custom-json-schema (#172)
* init

* update

* finished new repo query, removed old implementation

* remove debug folder
2025-05-22 08:52:25 +02:00
dswbx 0ac7d1fd6e remove batching workaround for Turso AWS endpoints (#171)
the underlying issue with batching on Turso AWS endpoints appears resolved, making the workaround unnecessary.
2025-05-21 07:47:24 +02:00
dswbx 6694c63990 admin: data/auth route-driven settings and collapsible components (#168)
introduced `useRoutePathState` for managing active states via routes, added `CollapsibleList` for reusable collapsible UI, and updated various components to leverage route awareness for improved navigation state handling. Also adjusted routing for entities, strategies, and schema to support optional sub-paths.
2025-05-03 11:05:38 +02:00
dswbx b3f95f9552 v0.12.0 2025-05-01 10:13:40 +02:00
dswbx 372f94d22a Release 0.12 (#143)
* changed tb imports

* cleanup: replace console.log/warn with $console, remove commented-out code

Removed various commented-out code and replaced direct `console.log` and `console.warn` usage across the codebase with `$console` from "core" for standardized logging. Also adjusted linting rules in biome.json to enable warnings for `console.log` usage.

* ts: enable incremental

* fix imports in test files

reorganize imports to use "@sinclair/typebox" directly, replacing local utility references, and add missing "override" keywords in test classes.

* added media permissions (#142)

* added permissions support for media module

introduced `MediaPermissions` for fine-grained access control in the media module, updated routes to enforce these permissions, and adjusted permission registration logic.

* fix: handle token absence in getUploadHeaders and add tests for transport modes

ensure getUploadHeaders does not set Authorization header when token is missing. Add unit tests to validate behavior for different token_transport options.

* remove console.log on DropzoneContainer.tsx

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* add bcrypt and refactored auth resolve (#147)

* reworked auth architecture with improved password handling and claims

Refactored password strategy to prepare supporting bcrypt, improving hashing/encryption flexibility. Updated authentication flow with enhanced user resolution mechanisms, safe JWT generation, and consistent profile handling. Adjusted dependencies to include bcryptjs and updated lock files accordingly.

* fix strategy forms handling, add register route and hidden fields

Refactored strategy forms to include hidden fields for type and name. Added a registration route with necessary adjustments to the admin controller and routes. Corrected field handling within relevant forms and components.

* refactored auth handling to support bcrypt, extracted user pool

* update email regex to allow '+' and '_' characters

* update test stub password for AppAuth spec

* update data exceptions to use HttpStatus constants, adjust logging level in AppUserPool

* rework strategies to extend a base class instead of interface

* added simple bcrypt test

* add validation logs and improve data validation handling (#157)

Added warning logs for invalid data during mutator validation, refined field validation logic to handle undefined values, and adjusted event validation comments for clarity. Minor improvements include exporting events from core and handling optional chaining in entity field validation.

* modify MediaApi to support custom fetch implementation, defaults to native fetch (#158)

* modify MediaApi to support custom fetch implementation, defaults to native fetch

added an optional `fetcher` parameter to allow usage of a custom fetch function in both `upload` and `fetcher` methods. Defaults to the standard `fetch` if none is provided.

* fix tests and improve api fetcher types

* update admin basepath handling and window context integration (#155)

Refactored `useBkndWindowContext` to include `admin_basepath` and updated its usage in routing. Improved type consistency with `AdminBkndWindowContext` and ensured default values are applied for window context.

* trigger `repository-find-[one|many]-[before|after]` based on `limit` (#160)

* refactor error handling in authenticator and password strategy (#161)

made `respondWithError` method public, updated login and register routes in `PasswordStrategy` to handle errors using `respondWithError` for consistency.

* add disableSubmitOnError prop to NativeForm and export getFlashMessage (#162)

Introduced a `disableSubmitOnError` prop to NativeForm to control submit button behavior when errors are present. Also exported `getFlashMessage` from the core for external usage.

* update dependencies in package.json (#156)

moved several dependencies between devDependencies and dependencies for better categorization and removed redundant entries.

* update imports to adjust nodeTestRunner path and remove unused export (#163)

updated imports in test files to reflect the correct path for nodeTestRunner. removed redundant export of nodeTestRunner from index file to clean up module structure. In some environments this could cause issues requiring to exclude `node:test`, just removing it for now.

* fix sync events not awaited (#164)

* refactor(dropzone): extract DropzoneInner and unify state management with zustand (#165)

Simplified Dropzone implementation by extracting inner logic to a new component, `DropzoneInner`. Replaced local dropzone state logic with centralized state management using zustand. Adjusted API exports and props accordingly for consistency and maintainability.

* replace LiquidJs rendering with simplified renderer (#167)

* replace LiquidJs rendering with simplified renderer

Removed dependency on LiquidJS and replaced it with a custom templating solution using lodash `get`. Updated corresponding components, editors, and tests to align with the new rendering approach. Removed unused filters and tags.

* remove liquid js from package json

* feat/cli-generate-types (#166)

* init types generation

* update type generation for entities and fields

Refactored `EntityTypescript` to support improved field types and relations. Added `toType` method overrides for various fields to define accurate TypeScript types. Enhanced CLI `types` command with new options for output style and file handling. Removed redundant test files.

* update type generation code and CLI option description

removed unused imports definition, adjusted formatting in EntityTypescript, and clarified the CLI style option description.

* fix json schema field type generation

* reworked system entities to prevent recursive types

* reworked system entities to prevent recursive types

* remove unused object function

* types: use number instead of Generated

* update data hooks and api types

* update data hooks and api types

* update data hooks and api types

* update data hooks and api types

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-05-01 10:12:18 +02:00
dswbx d6f94a2ce1 fix docs mdx snippets imports 2025-04-26 07:34:56 +02:00
dswbx 89a39a7dc6 fix docs: try locking version 2025-04-25 08:52:52 +02:00
dswbx 88cf65f792 fix docs by tmp renaming intro page 2025-04-24 21:20:12 +02:00
dswbx 07723ce6ae bump to 0.11.2 2025-04-22 15:55:20 +02:00
dswbx 9010401af6 remove unused useTheme import and add loading state for entity detail view (#154) 2025-04-22 15:43:27 +02:00
dswbx 4c11789ea8 Fix Release 0.11.1 (#150)
* fix strategy forms handling, add register route and hidden fields

Refactored strategy forms to include hidden fields for type and name. Added a registration route with necessary adjustments to the admin controller and routes. Corrected field handling within relevant forms and components.

* fix admin access permissions and refactor routing structure

display a fixed error for unmet permissions when retrieving the schema. moved auth routes outside of BkndProvider and reorganized remaining routes to include BkndWrapper.

* fix: properly type BkndWrapper

* bump fix release version

* ModuleManager: update diff checking and AppData validation

Revised diff handling includes validation of diffs, reverting changes on failure, and enforcing module constraints with onBeforeUpdate hooks. Introduced `validateDiffs` and backup of stable configs. Applied changes in related modules, tests, and UI layer to align with updated diff logic.

* fix: cli: running from config file were using invalid args

* fix: cli: improve sequence of onBuilt trigger to allow custom routes from cli

* fix e2e tests
2025-04-20 09:29:58 +02:00
dswbx 2988e4c3bd fix: force json-schema-library version 2025-04-18 16:06:32 +02:00
dswbx ad7926db4c Merge pull request #121 from bknd-io/release/0.11
Release 0.11
2025-04-08 12:51:34 +02:00
dswbx 53a3dcdee7 release: v0.11 2025-04-08 12:50:37 +02:00
dswbx 53467d6750 fix: create: cloudflare starter wasn't creating a r2 bucket 2025-04-08 12:43:50 +02:00
dswbx a80a731498 fix: cli: user command now uses the same app env setup as run 2025-04-08 11:22:10 +02:00
dswbx 7e1757b7f4 fix: updated cloudflare adapter to use runtime config, aligned vite 2025-04-05 18:03:58 +02:00
dswbx 2c29e06fb8 fix: media infinite should be disabled for dropzone in entities 2025-04-05 18:01:04 +02:00
dswbx ca86fa58ac cli: user: add token generation (#140)
* cli: user: add token generation

* cli: user: add token generation

* cli: user: check for value being cancel before continuing
2025-04-05 17:57:03 +02:00
dswbx de984fa101 fixes issues in firefox where view transitions are not available (#139) 2025-04-04 09:00:50 +02:00
dswbx a12d4e13d0 e2e: added script to auto test adapters 2025-04-03 16:40:51 +02:00
dswbx fa6c7acaf5 implement/init e2e tests (#135)
* init e2e

* updated/moved vitest, finished merge

* fix bun picking up e2e tests

* e2e: overwrite webserver config with env

* e2e: added adapter configs

* e2e: replaced image
2025-04-03 11:08:16 +02:00
dswbx 0b41aa5a2d cli: create: allow non-interactive create (#137) 2025-04-03 09:17:29 +02:00
dswbx a5ec40c517 docker: add option to overwrite bknd version used (#136) 2025-04-03 07:58:00 +02:00
dswbx 75e2b96344 fixed limbo batching issue by disabling batching (#133)
* fixed limbo batching issue by disabling batching

* updated @libsql/client to `0.15.2`
2025-04-02 20:19:20 +02:00
dswbx aaae8d9681 aws cli create: added guided db creation (#134) 2025-04-02 20:18:52 +02:00
dswbx e4608b7df7 cosmetics: fixed admin skeleton, use password field on auth, use $console in auth middleware 2025-04-01 13:49:58 +02:00
dswbx 44b3f72005 added media overlay preview fallback 2025-04-01 13:37:11 +02:00
dswbx 9134d121cd keep extension from file when generating random name (#127)
* keep extension from file when generating random name

* added test for random name generation
2025-04-01 13:24:32 +02:00
Cameron Pak 2f067451b4 chore: update dependencies and enhance TypeScript configuration for Astro example (#128) 2025-04-01 12:58:13 +02:00
dswbx 3f26c45dd9 refactored adapters to run test suites (#126)
* refactored adapters to run test suites

* fix bun version for tests

* added missing adapter tests and refactored examples to use `bknd.config.ts` where applicable
2025-04-01 11:43:11 +02:00
dswbx 36e4224b33 refactored EventManager to run asyncs on call only, app defaults to run before response (#129)
* refactored EventManager to run asyncs on call only, app defaults to run before response

* fix tests
2025-04-01 11:19:55 +02:00
dswbx 434d56672c Merge pull request #125 from bknd-io/feat/cli-load-creds
improve cli creds extraction
2025-03-29 08:11:43 +01:00
dswbx b2fd907e8c updated docs, fixed run with node/tsx 2025-03-28 21:12:50 +01:00
dswbx b29c04e8c9 added more cli instructions 2025-03-28 20:52:00 +01:00
dswbx 11a28eba88 improve cli creds extraction 2025-03-28 18:03:09 +01:00
dswbx 9e3c081e50 reorganized storage adapter and added test suites for adapter and fields (#124)
* reorganized storage adapter and added test suites for adapter and fields

* added build command in ci pipeline

* updated workflow to also run node tests

* updated workflow: try with separate tasks

* updated workflow: try with separate tasks

* updated workflow: added tsx as dev dependency

* updated workflow: try with find instead of glob
2025-03-27 20:41:42 +01:00
dswbx 40c9ef9d90 Merge pull request #123 from bknd-io/feat/media-dialog-and-infinite
add media detail dialog and infinite loading
2025-03-27 10:24:48 +01:00
dswbx 7facef47da fix media styling on mobile 2025-03-27 09:57:31 +01:00
dswbx e2bf6a0724 Merge remote-tracking branch 'origin/feat/media-detect-dimensions' into feat/media-dialog-and-infinite 2025-03-27 09:26:09 +01:00
dswbx f6a511d998 add media detail dialog and infinite loading 2025-03-27 09:23:14 +01:00
dswbx 9407f3d212 add image dimension detection for most common formats 2025-03-27 09:21:58 +01:00
dswbx 0424c08a9e Merge pull request #120 from bknd-io/feat/cf-create-infra
cli create: improve cloudflare (create d1, r2)
2025-03-26 10:32:48 +01:00
dswbx 9d122da4b9 cli create: improve cloudflare (create d1, r2) 2025-03-26 10:30:47 +01:00
dswbx 0f56d554b0 Merge pull request #119 from bknd-io/fix/cf-d1-introspect-issue-2
fix d1 introspect issue by excluding `_cf_METADATA`
2025-03-26 09:59:38 +01:00
dswbx 06e92d268b fix d1 introspect issue by excluding _cf_METADATA 2025-03-26 09:58:49 +01:00
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
1144 changed files with 80288 additions and 18458 deletions
+7
View File
@@ -0,0 +1,7 @@
{
"mcpServers": {
"bknd": {
"url": "http://localhost:28623/api/system/mcp"
}
}
}
+54
View File
@@ -0,0 +1,54 @@
name: Run Tests
on:
pull_request:
push:
branches: [ main ]
jobs:
test:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:17
env:
POSTGRES_PASSWORD: postgres
POSTGRES_USER: postgres
POSTGRES_DB: bknd
ports:
- 5430:5432
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "22.x"
- name: Setup Bun
uses: oven-sh/setup-bun@v1
with:
bun-version: "1.3.3"
- name: Install dependencies
working-directory: ./app
run: bun install #--linker=hoisted
- name: Build
working-directory: ./app
run: bun run build:ci
- name: Run Bun tests
working-directory: ./app
run: bun run test:bun
- name: Run Node tests
working-directory: ./app
run: npm run test:node
+8 -2
View File
@@ -19,6 +19,7 @@ packages/media/.env
.history .history
**/*/.db/* **/*/.db/*
**/*/.configs/* **/*/.configs/*
**/*/.template/*
**/*/*.db **/*/*.db
**/*/*.db-shm **/*/*.db-shm
**/*/*.db-wal **/*/*.db-wal
@@ -26,6 +27,11 @@ packages/media/.env
.npmrc .npmrc
/.verdaccio /.verdaccio
.idea .idea
.vscode .vscode/*
!.vscode/settings.json
.git_old .git_old
docker/tmp docker/tmp
.debug
.history
.aider*
.vercel
+1
View File
@@ -0,0 +1 @@
22
+19
View File
@@ -0,0 +1,19 @@
{
"typescript.tsdk": "node_modules/typescript/lib",
"biome.enabled": true,
"editor.defaultFormatter": "biomejs.biome",
"editor.codeActionsOnSave": {
//"source.organizeImports.biome": "explicit",
"source.fixAll.biome": "explicit"
},
"typescript.preferences.importModuleSpecifier": "non-relative",
"typescript.preferences.autoImportFileExcludePatterns": [
"**/dist/**",
"**/node_modules/**/dist/**",
"**/node_modules/**/!(src|lib|esm)/**" // optional, stricter
],
"typescript.preferences.includePackageJsonAutoImports": "on",
"typescript.tsserver.watchOptions": {
"excludeDirectories": ["**/dist", "**/node_modules/**/dist"]
}
}
+267
View File
@@ -0,0 +1,267 @@
# Contributing to bknd
Thank you for your interest in contributing to bknd. This guide will help you get started, understand the codebase, and submit contributions that align with the project's direction.
## Table of Contents
- [Before You Start](#before-you-start)
- [Important Resources](#important-resources)
- [Development Setup](#development-setup)
- [Project Structure](#project-structure)
- [Understanding the Codebase](#understanding-the-codebase)
- [Running Tests](#running-tests)
- [Code Style](#code-style)
- [Submitting Changes](#submitting-changes)
- [AI-Generated Code](#ai-generated-code)
- [Contributing to Documentation](#contributing-to-documentation)
- [Reporting Bugs](#reporting-bugs)
- [Getting Help](#getting-help)
- [Contributors](#contributors)
- [Maintainers](#maintainers)
## Before You Start
**Open a GitHub Issue before writing code.** This is the preferred way to propose any change. It lets maintainers and the community align on the approach before time is spent on implementation. If you have discussed a contribution in the [Discord server](https://discord.com/invite/952SFk8Tb8) instead, include a link to the relevant Discord thread in your pull request. Pull requests submitted without a corresponding issue or Discord discussion may be closed.
**Unsolicited architectural changes will be closed.** The internal architecture of bknd is intentional. Refactors, restructuring, or changes to core patterns must be discussed and approved before any code is written.
Contributions that are generally welcome (after opening an issue):
- Bug fixes
- New adapters, strategies, or storage backends
- Documentation improvements
- New examples
- Test coverage improvements
**A note on versioning**: bknd is pre-1.0 and under active development. Full backward compatibility is not guaranteed before v1.0.0. Contributors should be aware that APIs and internal interfaces may change between releases.
## Important Resources
- **Documentation**: https://docs.bknd.io
- **Issue Tracker**: https://github.com/bknd-io/bknd/issues
- **Discord**: https://discord.com/invite/952SFk8Tb8
- **FAQ / Search**: https://www.answeroverflow.com/c/1308395750564302952
## Development Setup
### Prerequisites
- Node.js 22.13 or higher
- Bun 1.3.3
### Getting Started
1. Fork the repository and clone your fork.
2. Install dependencies from the repo root:
```bash
bun install
```
3. Navigate to the main app package:
```bash
cd app
```
4. Copy the example environment file:
```bash
cp .env.example .env
```
The default `.env.example` includes a file-based SQLite database (`file:.db/dev.db`). You can change `VITE_DB_URL` to `:memory:` for an in-memory database instead.
5. Start the dev server:
```bash
bun run dev
```
The dev server runs on `http://localhost:28623` using Vite with Hono.
## Project Structure
This is a Bun monorepo using workspaces. The vast majority of the code lives in `app/`.
```
bknd/
app/ # Main "bknd" npm package (this is where most work happens)
src/
adapter/ # Runtime/framework adapters (node, bun, cloudflare, nextjs, astro, etc.)
auth/ # Authentication module (strategies, sessions, user pool)
cli/ # CLI commands
core/ # Shared utilities, event system, drivers, server helpers
data/ # Data module (entities, fields, relations, queries, schema)
flows/ # Workflow engine and tasks
media/ # Media module (storage adapters, uploads)
modes/ # Configuration modes (code, hybrid, db)
modules/ # Module system, MCP server, permissions framework
plugins/ # Built-in plugins (auth, data, cloudflare, dev)
ui/ # All frontend code
client/ # TypeScript SDK and React hooks (exported as bknd/client)
elements/ # React components for auth/media (exported as bknd/elements)
(everything else) # Admin UI (exported as bknd/ui)
App.ts # Central application orchestrator
index.ts # Main package exports
__test__/ # Unit tests (mirrors src/ structure)
e2e/ # End-to-end tests (Playwright)
build.ts # Build script (tsup/esbuild)
build.cli.ts # CLI build script
packages/ # Small satellite packages
cli/ # Standalone CLI package (bknd-cli)
plasmic/ # Plasmic integration
postgres/ # Postgres helper
sqlocal/ # SQLocal helper
docs/ # Documentation site (Next.js + Fumadocs, deployed to docs.bknd.io)
examples/ # Example projects across runtimes and frameworks
docker/ # Docker configuration
```
## Understanding the Codebase
### Path Aliases
The project uses TypeScript path aliases defined in `app/tsconfig.json`. Imports like `core/utils`, `data/connection`, or `auth/authenticate` resolve to directories under `app/src/`. When reading source code, keep this in mind -- an import like `import { Connection } from "data/connection"` refers to `app/src/data/connection`, not an external package.
### Module System
bknd is built around four core modules, each with its own schema, API routes, and permissions:
- **Data** -- entity definitions, field types, relations, queries (backed by Kysely)
- **Auth** -- authentication strategies, sessions, user management
- **Media** -- file storage with pluggable adapters (S3, Cloudinary, local filesystem, etc.)
- **Flows** -- workflow automation and task execution
These modules are managed by the `ModuleManager` (in `app/src/modules/`), which handles configuration, building, and lifecycle.
### Adapter Pattern
Adapters in `app/src/adapter/` allow bknd to run on different runtimes and frameworks. Each adapter provides the glue between bknd's Hono-based server and a specific environment (Node, Bun, Cloudflare Workers, Next.js, Astro, etc.).
### Plugin System
Plugins (in `app/src/plugins/`) hook into the app lifecycle via callbacks like `beforeBuild`, `onBuilt`, `onServerInit`, and `schema`. They can add entities, register routes, and extend behavior.
### Build System
The build is handled by a custom `app/build.ts` script using tsup (esbuild under the hood). It builds four targets in parallel:
- **API** -- the core backend
- **UI** -- the admin interface
- **Elements** -- standalone React components
- **Adapters** -- all runtime/framework adapters
## Running Tests
All test commands run from the `app/` directory.
| Command | Runner | What it runs |
|---|---|---|
| `bun run test` | Bun test | Unit tests (`*.spec.ts` files) |
| `bun run test:node` | Vitest | Node-specific tests (`*.vi-test.ts`, `*.vitest.ts`) |
| `bun run test:e2e` | Playwright | End-to-end tests (`*.e2e-spec.ts` in `e2e/`) |
| `bun run types` | TypeScript | Type checking (no emit) |
CI runs both Bun and Node tests, with a Postgres 17 service for database integration tests.
## Code Style
The project uses **Biome** for formatting and linting.
- 3-space indentation for JavaScript/TypeScript
- 100-character line width
- Spaces (not tabs)
To format and lint, run from the repo root:
```bash
bunx biome format --write ./app
bunx biome lint --changed --write ./app
```
Run both before submitting a PR. CI will catch style issues, but it is better to fix them locally first.
## Submitting Changes
1. Open a GitHub Issue describing your proposed change, or link to a Discord thread where it was discussed. Wait for feedback before writing code.
2. Fork the repo and create a branch from `main`.
3. Keep changes focused and minimal. One PR per issue.
4. Add or update tests if applicable.
5. Run the test suite and linter before pushing.
6. Open a pull request against `main`. Reference the issue number in the PR description.
Expect a response from maintainers, but be patient -- this is an actively developed project and review may take some time.
## AI-Generated Code
If you use AI tools to write or assist with your code, you must:
- **Fully review all AI-generated code yourself** before submitting. You are responsible for understanding and standing behind every line in your PR.
- **Include the prompts used** in the PR description. This gives maintainers additional context for reviewing the code.
Pull requests with unreviewed AI-generated code will be closed.
## Contributing to Documentation
The documentation site lives in the `docs/` directory and is a **separate Next.js application** built with [Fumadocs](https://fumadocs.dev). It uses **npm** (not Bun) as its package manager.
### Docs Setup
```bash
cd docs
npm install
npm run dev
```
The docs dev server runs on `http://localhost:3000`.
### Where Documentation Lives
Documentation content is written in MDX and located in `docs/content/docs/`. The directory is organized into:
- `(documentation)/` -- core documentation pages
- `guide/` -- guides and tutorials
- `api-reference/` -- API reference (partially auto-generated from OpenAPI)
To add or edit a page, create or modify an `.mdx` file in the appropriate directory. Page metadata is defined via frontmatter at the top of each file. Navigation order is controlled by `meta.json` files in each directory.
### Building the Docs
```bash
npm run build
```
This generates the OpenAPI and MCP reference pages before building the Next.js site. Make sure the build succeeds locally before submitting a docs PR.
## Reporting Bugs
Open a GitHub Issue with:
- A clear title describing the problem.
- Steps to reproduce the bug.
- Expected behavior vs. actual behavior.
- Your environment (runtime, database, adapter, bknd version).
- Any relevant error messages or logs.
## Getting Help
- **Discord**: https://discord.com/invite/952SFk8Tb8
- **FAQ / Search**: https://www.answeroverflow.com/c/1308395750564302952
- **GitHub Issues**: https://github.com/bknd-io/bknd/issues
## Contributors
Thank you to everyone who has contributed to bknd.
<a href="https://github.com/bknd-io/bknd/graphs/contributors">
<img src="https://contrib.rocks/image?repo=bknd-io/bknd" />
</a>
Made with [contrib.rocks](https://contrib.rocks).
## Maintainers
- **Dennis** ([@dswbx](https://github.com/dswbx)) -- Creator and maintainer of bknd
- **Cameron Pak** ([@cameronapak](https://github.com/cameronapak)) -- Maintainer of bknd docs
+169 -77
View File
@@ -1,110 +1,202 @@
# Functional Source License, Version 1.1, MIT Future License
## Abbreviation Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
FSL-1.1-MIT TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
## Notice 1. Definitions.
Copyright 2025 Dennis Senn "License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
## Terms and Conditions "Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
### Licensor ("We") "Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
The party offering the Software under these Terms and Conditions. "You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
### The Software "Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
The "Software" is each version of the software that we make available under "Object" form shall mean any form resulting from mechanical
these Terms and Conditions, as indicated by our inclusion of these Terms and transformation or translation of a Source form, including but
Conditions with the Software. not limited to compiled object code, generated documentation,
and conversions to other media types.
### License Grant "Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
Subject to your compliance with this License Grant and the Patents, "Derivative Works" shall mean any work, whether in Source or Object
Redistribution and Trademark clauses below, we hereby grant you the right to form, that is based on (or derived from) the Work and for which the
use, copy, modify, create derivative works, publicly perform, publicly display editorial revisions, annotations, elaborations, or other modifications
and redistribute the Software for any Permitted Purpose identified below. represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
### Permitted Purpose "Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
A Permitted Purpose is any purpose other than a Competing Use. A Competing Use "Contributor" shall mean Licensor and any individual or Legal Entity
means making the Software available to others in a commercial product or on behalf of whom a Contribution has been received by Licensor and
service that: subsequently incorporated within the Work.
1. substitutes for the Software; 2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
2. substitutes for any other product or service we offer using the Software 3. Grant of Patent License. Subject to the terms and conditions of
that exists as of the date we make the Software available; or this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
3. offers the same or substantially similar functionality as the Software. 4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
Permitted Purposes specifically include using the Software: (a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
1. for your internal use and access; (b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
2. for non-commercial education; (c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
3. for non-commercial research; and (d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
4. in connection with professional services that you provide to a licensee You may add Your own copyright statement to Your modifications and
using the Software in accordance with these Terms and Conditions. may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
### Patents 5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
To the extent your use for a Permitted Purpose would necessarily infringe our 6. Trademarks. This License does not grant permission to use the trade
patents, the license grant above includes a license under our patents. If you names, trademarks, service marks, or product names of the Licensor,
make a claim against any party that the Software infringes or contributes to except as required for reasonable and customary use in describing the
the infringement of any patent, then your patent license to the Software ends origin of the Work and reproducing the content of the NOTICE file.
immediately.
### Redistribution 7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
The Terms and Conditions apply to all copies, modifications and derivatives of 8. Limitation of Liability. In no event and under no legal theory,
the Software. whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
If you redistribute any copies, modifications or derivatives of the Software, 9. Accepting Warranty or Additional Liability. While redistributing
you must include a copy of or a link to these Terms and Conditions and not the Work or Derivative Works thereof, You may choose to offer,
remove any copyright notices provided in or with the Software. and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
### Disclaimer END OF TERMS AND CONDITIONS
THE SOFTWARE IS PROVIDED "AS IS" AND WITHOUT WARRANTIES OF ANY KIND, EXPRESS OR APPENDIX: How to apply the Apache License to your work.
IMPLIED, INCLUDING WITHOUT LIMITATION WARRANTIES OF FITNESS FOR A PARTICULAR
PURPOSE, MERCHANTABILITY, TITLE OR NON-INFRINGEMENT.
IN NO EVENT WILL WE HAVE ANY LIABILITY TO YOU ARISING OUT OF OR RELATED TO THE To apply the Apache License to your work, attach the following
SOFTWARE, INCLUDING INDIRECT, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES, boilerplate notice, with the fields enclosed by brackets "[]"
EVEN IF WE HAVE BEEN INFORMED OF THEIR POSSIBILITY IN ADVANCE. replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
### Trademarks Copyright 2026 Dennis Senn
Except for displaying the License Details and identifying us as the origin of Licensed under the Apache License, Version 2.0 (the "License");
the Software, you have no right under these Terms and Conditions to use our you may not use this file except in compliance with the License.
trademarks, trade names, service marks or product names. You may obtain a copy of the License at
## Grant of Future License http://www.apache.org/licenses/LICENSE-2.0
We hereby irrevocably grant you an additional license to use the Software under Unless required by applicable law or agreed to in writing, software
the MIT license that is effective on the second anniversary of the date we make distributed under the License is distributed on an "AS IS" BASIS,
the Software available. On or after that date, you may use the Software under WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
the MIT license, in which case the following will apply: See the License for the specific language governing permissions and
limitations under the License.
Permission is hereby granted, free of charge, to any person obtaining a copy of
this software and associated documentation files (the "Software"), to deal in
the Software without restriction, including without limitation the rights to
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
of the Software, and to permit persons to whom the Software is furnished to do
so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+159 -32
View File
@@ -1,47 +1,158 @@
[![npm version](https://img.shields.io/npm/v/bknd.svg)](https://npmjs.org/package/bknd [![npm version](https://img.shields.io/npm/v/bknd.svg)](https://npmjs.org/package/bknd)
"View this project on NPM")
[![npm downloads](https://img.shields.io/npm/dm/bknd)](https://www.npmjs.com/package/bknd)
![bknd](https://raw.githubusercontent.com/bknd-io/bknd/refs/heads/main/docs/_assets/poster.png) ![bknd](https://raw.githubusercontent.com/bknd-io/bknd/refs/heads/main/docs/public/assets/poster.png)
bknd simplifies app development by providing fully functional backend for data management, <p align="center" width="100%">
authentication, workflows and media. Since it's lightweight and built on Web Standards, it can <a href="https://stackblitz.com/github/bknd-io/bknd-demo?hideExplorer=1&embed=1&view=preview&initialPath=%2Fdata%2Fschema" target="_blank">
be deployed nearly anywhere, including running inside your framework of choice. No more <strong>⭐ Live Demo</strong>
deploying multiple separate services! </a>
</p>
bknd simplifies app development by providing a fully functional visual 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!
It's designed to avoid vendor lock-in and architectural limitations. Built exclusively on [WinterTC Minimum Common Web Platform API](https://min-common-api.proposal.wintertc.org/) for universal compatibility, all functionality (data, auth, media, flows) is modular and opt-in, and infrastructure access is adapter-based with direct access to underlying drivers giving you full control without abstractions getting in your way.
* **Runtimes**: Node.js 22+, Bun 1.0+, Deno, Browser, Cloudflare Workers/Pages, Vercel, Netlify, AWS Lambda, etc.
* **Databases**:
* SQLite: LibSQL, Node SQLite, Bun SQLite, Cloudflare D1, Cloudflare Durable Objects SQLite, SQLocal
* Postgres: Vanilla Postgres, Supabase, Neon, Xata
* **Frameworks**: React, Next.js, React Router, Astro, Vite, Waku
* **Storage**: AWS S3, S3-compatible (Tigris, R2, Minio, etc.), Cloudflare R2 (binding), Cloudinary, Filesystem, Origin Private File System (OPFS)
* **Deployment**: Standalone, Docker, Cloudflare Workers, Vercel, Netlify, Deno Deploy, AWS Lambda, Valtown etc.
**For documentation and examples, please visit https://docs.bknd.io.** **For documentation and examples, please visit https://docs.bknd.io.**
> [!WARNING] > [!WARNING]
> This project requires Node.js 22.13 or higher (because of `node:sqlite`).
>
> Please keep in mind that **bknd** is still under active development > Please keep in mind that **bknd** is still under active development
> and therefore full backward compatibility is not guaranteed before reaching v1.0.0. > and therefore full backward compatibility is not guaranteed before reaching v1.0.0.
## ✨ Features ## Use Cases
**📊 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.
**🔐 Auth**: Easily implement reliable authentication strategies. bknd is a general purpose backend system that implements the primitives almost any backend needs. This way, you can use it for any backend use case, including but not limited to:
- 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.
**🖼️ Media**: Effortlessly manage and serve all your media files. - **Content Management System (CMS)** as Wordpress alternative, hosted separately or embedded in your frontend
- Upload files with ease. - **AI Agent Backends** for managing agent state with built-in data persistence, regardless where it is hosted. Optionally communicate over the integrated MCP server.
- Adapter-based support for S3, S3-compatible storage (e.g., R2, Tigris), and Cloudinary. - **SaaS Products** with multi-tenant data isolation (RLS) and user management, with freedom to choose your own database and storage provider
- **Prototypes & MVPs** to validate ideas quickly without infrastructure overhead
- **API-First Applications** where you need a reliable, type-safe backend without vendor lock-in either with the integrated TypeScript SDK or REST API using OpenAPI
- **IoT & Embedded Devices** where minimal footprint matters
**🔄 Flows** (UI integration coming soon!): Design and run workflows with seamless automation.
- Create and run workflows with trigger-based automation: ## Size
- Manual triggers or events from data, auth, media, or server actions. ![gzipped size of bknd](https://img.shields.io/bundlejs/size/bknd?label=bknd)
- HTTP triggers for external integrations. ![gzipped size of bknd/client](https://img.badgesize.io/https://unpkg.com/bknd@latest/dist/ui/client/index.js?compression=gzip&label=bknd/client)
- Define tasks in sequence, parallel, or loops, with conditional execution. ![gzipped size of bknd/elements](https://img.badgesize.io/https://unpkg.com/bknd@latest/dist/ui/elements/index.js?compression=gzip&label=bknd/elements&t=1)
- Use reusable sub-workflows to organize complex processes. ![gzipped size of bknd/ui](https://img.badgesize.io/https://unpkg.com/bknd@latest/dist/ui/index.js?compression=gzip&label=bknd/ui&t=1)
- Leverage OpenAPI specifications for API-based tasks.
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.
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 300 kB gzipped (e.g. deployed as Cloudflare Worker).
## 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
* 🛠️ MCP server, client and UI built-in to control your backend
* 🏃‍♂️ 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 ## 🚀 Quick start
@@ -54,3 +165,19 @@ npx bknd run
```bash ```bash
npm install bknd npm install bknd
``` ```
## Contributing
We welcome contributions! Please read our [CONTRIBUTING.md](CONTRIBUTING.md) before getting started. It covers dev setup, project structure, testing, code style, and how to submit changes.
Before writing code, please open a GitHub Issue or start a conversation in [Discord](https://discord.com/invite/952SFk8Tb8) so we can align on the approach.
## Contributors
Thank you to everyone who has contributed to bknd.
<a href="https://github.com/bknd-io/bknd/graphs/contributors">
<img src="https://contrib.rocks/image?repo=bknd-io/bknd" />
</a>
Made with [contrib.rocks](https://contrib.rocks).
+43
View File
@@ -0,0 +1,43 @@
# ===== DB Settings =====
VITE_DB_URL=:memory:
# you can set a location for a database here, it'll overwrite the previous setting
# ideally use the ".db" folder (create it first), it's git ignored
VITE_DB_URL=file:.db/dev.db
# alternatively, you can use url/token combination
#VITE_DB_URL=
#VITE_DB_TOKEN=
# ===== DEV Server =====
# restart the dev server on every change (enable with "1")
VITE_APP_FRESH=
# displays react-scan widget (enable with "1")
VITE_DEBUG_RERENDERS=
# console logs registered routes on start (enable with "1")
VITE_SHOW_ROUTES=
# ===== Test Credentials =====
RESEND_API_KEY=
PLUNK_API_KEY=
R2_TOKEN=
R2_ACCESS_KEY=
R2_SECRET_ACCESS_KEY=
R2_URL=
AWS_ACCESS_KEY=
AWS_SECRET_KEY=
AWS_S3_URL=
OAUTH_CLIENT_ID=
OAUTH_CLIENT_SECRET=
PUBLIC_POSTHOG_KEY=
PUBLIC_POSTHOG_HOST=
# ===== Internals =====
BKND_CLI_CREATE_REF=main
BKND_CLI_LOG_LEVEL=debug
BKND_MODULES_DEBUG=1
+5
View File
@@ -0,0 +1,5 @@
playwright-report
test-results
bknd.config.*
__test__/helper.d.ts
.env.local
+12
View File
@@ -0,0 +1,12 @@
{
"typescript.preferences.includePackageJsonAutoImports": "off",
"typescript.suggest.autoImports": true,
"typescript.preferences.importModuleSpecifier": "relative",
"search.exclude": {
"**/dist/**": true,
"**/node_modules/**": true
},
"files.exclude": {
"**/dist/**": true
}
}
+137 -4
View File
@@ -1,15 +1,148 @@
import { afterAll, describe, expect, test } from "bun:test"; import { afterEach, describe, test, expect, beforeAll, afterAll } from "bun:test";
import { App } from "../src"; import { App, createApp } from "core/test/utils";
import { getDummyConnection } from "./helper"; import { getDummyConnection } from "./helper";
import { Hono } from "hono";
import * as proto from "../src/data/prototype";
import { pick } from "lodash-es";
import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
beforeAll(() => disableConsoleLog());
const { dummyConnection, afterAllCleanup } = getDummyConnection(); const { dummyConnection, afterAllCleanup } = getDummyConnection();
afterAll(afterAllCleanup); afterEach(async () => (await afterAllCleanup()) && enableConsoleLog());
describe("App tests", async () => { describe("App tests", async () => {
test("boots and pongs", async () => { test("boots and pongs", async () => {
const app = new App(dummyConnection); const app = new App(dummyConnection);
await app.build(); await app.build();
//expect(await app.data?.em.ping()).toBeTrue(); expect(await app.em.ping()).toBeTrue();
});
test("plugins", async () => {
const called: string[] = [];
const app = createApp({
config: {
auth: {
enabled: true,
},
},
options: {
plugins: [
(app) => {
expect(app).toBeDefined();
expect(app).toBeInstanceOf(App);
return {
name: "test",
schema: () => {
called.push("schema");
return proto.em(
{
posts: proto.entity("posts", {
title: proto.text(),
}),
comments: proto.entity("comments", {
content: proto.text(),
}),
users: proto.entity("users", {
email_verified: proto.boolean(),
}),
},
(fn, s) => {
fn.relation(s.comments).manyToOne(s.posts);
fn.index(s.posts).on(["title"]);
},
);
},
onBoot: async () => {
called.push("onBoot");
},
beforeBuild: async () => {
called.push("beforeBuild");
},
onBuilt: async () => {
called.push("onBuilt");
},
onServerInit: async (server) => {
called.push("onServerInit");
expect(server).toBeDefined();
expect(server).toBeInstanceOf(Hono);
},
onFirstBoot: async () => {
called.push("onFirstBoot");
},
};
},
],
},
});
await app.build();
expect(app.em.entities.map((e) => e.name)).toEqual(["users", "posts", "comments"]);
expect(app.em.indices.map((i) => i.name)).toEqual([
"idx_unique_users_email",
"idx_users_strategy",
"idx_users_strategy_value",
"idx_posts_title",
]);
expect(
app.em.relations.all.map((r) => pick(r.toJSON(), ["type", "source", "target"])),
).toEqual([
{
type: "n:1",
source: "comments",
target: "posts",
},
]);
expect(called).toEqual([
"onBoot",
"onServerInit",
"beforeBuild",
"onServerInit",
"schema",
"onFirstBoot",
"onBuilt",
]);
expect(app.plugins.size).toBe(1);
expect(Array.from(app.plugins.keys())).toEqual(["test"]);
});
test("drivers", async () => {
const called: string[] = [];
const app = new App(dummyConnection, undefined, {
drivers: {
email: {
send: async (to, subject, body) => {
called.push("email.send");
return {
id: "",
};
},
},
cache: {
get: async (key) => {
called.push("cache.get");
return "";
},
set: async (key, value, ttl) => {
called.push("cache.set");
},
del: async (key) => {
called.push("cache.del");
},
},
},
});
await app.build();
expect(app.drivers.cache).toBeDefined();
expect(app.drivers.email).toBeDefined();
await app.drivers.email.send("", "", "");
await app.drivers.cache.get("");
await app.drivers.cache.set("", "", 0);
await app.drivers.cache.del("");
expect(called).toEqual(["email.send", "cache.get", "cache.set", "cache.del"]);
}); });
}); });
+2
View File
@@ -0,0 +1,2 @@
tmp/*
!tmp/.gitkeep
Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.
+1
View File
@@ -0,0 +1 @@
hello
+76
View File
@@ -0,0 +1,76 @@
import { expect, describe, it, beforeAll, afterAll, mock } from "bun:test";
import * as adapter from "adapter";
import { disableConsoleLog, enableConsoleLog, omitKeys } from "core/utils";
import { adapterTestSuite } from "adapter/adapter-test-suite";
import { bunTestRunner } from "adapter/bun/test";
const stripConnection = <T extends Record<string, any>>(cfg: T) =>
omitKeys(cfg, ["connection"]);
beforeAll(disableConsoleLog);
afterAll(enableConsoleLog);
describe("adapter", () => {
describe("makeConfig", () => {
it("returns empty config for empty inputs", async () => {
const cases: Array<Parameters<typeof adapter.makeConfig>> = [
[{}],
[{}, { env: { TEST: "test" } }],
];
for (const args of cases) {
const cfg = await adapter.makeConfig(...(args as any));
expect(stripConnection(cfg)).toEqual({});
}
});
it("merges app output into config", async () => {
const cfg = await adapter.makeConfig(
{ app: (a) => ({ config: { server: { cors: { origin: a.env.TEST } } } }) },
{ env: { TEST: "test" } },
);
expect(stripConnection(cfg)).toEqual({
config: { server: { cors: { origin: "test" } } },
});
});
it("allows all properties in app() result", async () => {
const called = mock(() => null);
const cfg = await adapter.makeConfig(
{
app: (env) => ({
connection: { url: "test" },
config: { server: { cors: { origin: "test" } } },
options: { mode: "db" as const },
onBuilt: () => {
called();
expect(env).toEqual({ foo: "bar" });
},
}),
},
{ foo: "bar" },
);
expect(cfg.connection).toEqual({ url: "test" });
expect(cfg.config).toEqual({ server: { cors: { origin: "test" } } });
expect(cfg.options).toEqual({ mode: "db" });
await cfg.onBuilt?.({} as any);
expect(called).toHaveBeenCalledTimes(1);
});
});
describe("adapter test suites", () => {
adapterTestSuite(bunTestRunner, {
makeApp: adapter.createFrameworkApp,
label: "framework app",
});
adapterTestSuite(bunTestRunner, {
makeApp: adapter.createRuntimeApp,
label: "runtime app",
});
});
});
+73
View File
@@ -0,0 +1,73 @@
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");
// verified is true, because no token, user, headers or request given
// therefore nothing to check, auth state is verified
expect(api.isAuthVerified()).toBe(true);
});
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(true);
});
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();
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");
});
});
+166 -14
View File
@@ -1,11 +1,12 @@
import { afterAll, beforeAll, describe, expect, it } from "bun:test"; import { afterAll, beforeAll, describe, expect, it } from "bun:test";
import { Guard } from "../../src/auth"; import { Guard } from "../../src/auth/authorize/Guard";
import { parse } from "../../src/core/utils";
import { DataApi } from "../../src/data/api/DataApi"; import { DataApi } from "../../src/data/api/DataApi";
import { DataController } from "../../src/data/api/DataController"; import { DataController } from "../../src/data/api/DataController";
import { dataConfigSchema } from "../../src/data/data-schema"; import { dataConfigSchema } from "../../src/data/data-schema";
import * as proto from "../../src/data/prototype"; import * as proto from "../../src/data/prototype";
import { disableConsoleLog, enableConsoleLog, schemaToEm } from "../helper"; import { schemaToEm } from "../helper";
import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
import { parse } from "core/utils/schema";
beforeAll(disableConsoleLog); beforeAll(disableConsoleLog);
afterAll(enableConsoleLog); afterAll(enableConsoleLog);
@@ -17,16 +18,16 @@ describe("DataApi", () => {
const get = api.readMany("a".repeat(300), { select: ["id", "name"] }); const get = api.readMany("a".repeat(300), { select: ["id", "name"] });
expect(get.request.method).toBe("GET"); expect(get.request.method).toBe("GET");
expect(new URL(get.request.url).pathname).toBe(`/api/data/${"a".repeat(300)}`); expect(new URL(get.request.url).pathname).toBe(`/api/data/entity/${"a".repeat(300)}`);
const post = api.readMany("a".repeat(1000), { select: ["id", "name"] }); const post = api.readMany("a".repeat(1000), { select: ["id", "name"] });
expect(post.request.method).toBe("POST"); expect(post.request.method).toBe("POST");
expect(new URL(post.request.url).pathname).toBe(`/api/data/${"a".repeat(1000)}/query`); expect(new URL(post.request.url).pathname).toBe(`/api/data/entity/${"a".repeat(1000)}/query`);
}); });
it("returns result", async () => { it("returns result", async () => {
const schema = proto.em({ const schema = proto.em({
posts: proto.entity("posts", { title: proto.text() }) posts: proto.entity("posts", { title: proto.text() }),
}); });
const em = schemaToEm(schema); const em = schemaToEm(schema);
await em.schema().sync({ force: true }); await em.schema().sync({ force: true });
@@ -39,20 +40,18 @@ describe("DataApi", () => {
const app = controller.getController(); const app = controller.getController();
{ {
const res = (await app.request("/posts")) as Response; const res = (await app.request("/entity/posts")) as Response;
const { data } = await res.json(); const { data } = (await res.json()) as any;
expect(data.length).toEqual(3); expect(data.length).toEqual(3);
} }
// @ts-ignore tests // @ts-ignore tests
const api = new DataApi({ basepath: "/", queryLengthLimit: 50 }); const api = new DataApi({ basepath: "/", queryLengthLimit: 50 }, app.request as typeof fetch);
// @ts-ignore protected
api.fetcher = app.request as typeof fetch;
{ {
const req = api.readMany("posts", { select: ["title"] }); const req = api.readMany("posts", { select: ["title"] });
expect(req.request.method).toBe("GET"); expect(req.request.method).toBe("GET");
const res = await req; const res = await req;
expect(res.data).toEqual(payload); expect(res.data).toEqual(payload as any);
} }
{ {
@@ -60,11 +59,164 @@ describe("DataApi", () => {
select: ["title"], select: ["title"],
limit: 100000, limit: 100000,
offset: 0, offset: 0,
sort: "id" sort: "id",
}); });
expect(req.request.method).toBe("POST"); expect(req.request.method).toBe("POST");
const res = await req; const res = await req;
expect(res.data).toEqual(payload); expect(res.data).toEqual(payload as any);
}
{
// make sure sort is working
const req = await api.readMany("posts", {
select: ["title"],
sort: "-id",
});
expect(req.data).toEqual(payload.reverse() 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.items).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();
} }
}); });
}); });
+192
View File
@@ -0,0 +1,192 @@
/// <reference types="@types/bun" />
import { describe, expect, it, mock } from "bun:test";
import { Hono } from "hono";
import { getFileFromContext, isFile, isReadableStream, s, jsc } from "core/utils";
import { MediaApi } from "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(new File([await file.bytes()], name, { type: file.type }), {
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",
token_transport: "header",
});
expect(api.getUploadHeaders().get("Authorization")).toBe("Bearer token");
});
it("should return empty headers if not using `header` transport", () => {
expect(
new MediaApi({
token_transport: "cookie",
})
.getUploadHeaders()
.has("Authorization"),
).toBe(false);
expect(
new MediaApi({
token_transport: "none",
})
.getUploadHeaders()
.has("Authorization"),
).toBe(false);
});
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 = 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()) as File;
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.getFile(name);
const stream = await api.getFileStream(name);
expect(isReadableStream(stream)).toBe(true);
const blob = (await res.res.blob()) as File;
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 () => {
const api = new MediaApi(
{
upload_fetcher: mockedBackend.request,
},
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(api.upload(file.stream(), { filename: "readable.png" }), "readable.png");
// upload via readable from response
{
const response = (await mockedBackend.request(url)) as Response;
await matches(api.upload(response.body!, { filename: "readable.png" }), "readable.png");
}
});
it("should add overwrite query for entity upload", async (c) => {
const call = mock(() => null);
const hono = new Hono().post(
"/api/media/entity/:entity/:id/:field",
jsc("query", s.object({ overwrite: s.boolean().optional() })),
async (c) => {
const { overwrite } = c.req.valid("query");
expect(overwrite).toBe(true);
call();
return c.json({ ok: true });
},
);
const api = new MediaApi(
{
upload_fetcher: hono.request,
},
hono.request,
);
const file = Bun.file(`${assetsPath}/image.png`);
const res = await api.uploadToEntity("posts", 1, "cover", file as any, {
overwrite: true,
});
expect(res.ok).toBe(true);
expect(call).toHaveBeenCalled();
});
});
+12 -10
View File
@@ -27,10 +27,7 @@ describe("ModuleApi", () => {
it("fetches endpoint", async () => { it("fetches endpoint", async () => {
const app = new Hono().get("/endpoint", (c) => c.json({ foo: "bar" })); const app = new Hono().get("/endpoint", (c) => c.json({ foo: "bar" }));
const api = new Api({ host }); const api = new Api({ host }, app.request as typeof fetch);
// @ts-expect-error it's protected
api.fetcher = app.request as typeof fetch;
const res = await api.get("/endpoint"); const res = await api.get("/endpoint");
expect(res.res.ok).toEqual(true); expect(res.res.ok).toEqual(true);
@@ -41,10 +38,7 @@ describe("ModuleApi", () => {
it("has accessible request", async () => { it("has accessible request", async () => {
const app = new Hono().get("/endpoint", (c) => c.json({ foo: "bar" })); const app = new Hono().get("/endpoint", (c) => c.json({ foo: "bar" }));
const api = new Api({ host }); const api = new Api({ host }, app.request as typeof fetch);
// @ts-expect-error it's protected
api.fetcher = app.request as typeof fetch;
const promise = api.get("/endpoint"); const promise = api.get("/endpoint");
expect(promise.request).toBeDefined(); expect(promise.request).toBeDefined();
@@ -67,7 +61,7 @@ describe("ModuleApi", () => {
it("adds additional headers from options", () => { it("adds additional headers from options", () => {
const headers = new Headers({ const headers = new Headers({
"X-Test": "123" "X-Test": "123",
}); });
const api = new Api({ host, headers }); const api = new Api({ host, headers });
expect(api.get("/").request.headers.get("X-Test")).toEqual("123"); expect(api.get("/").request.headers.get("X-Test")).toEqual("123");
@@ -81,7 +75,7 @@ describe("ModuleApi", () => {
it("uses search params", () => { it("uses search params", () => {
const api = new Api({ host }); const api = new Api({ host });
const search = new URLSearchParams({ const search = new URLSearchParams({
foo: "bar" foo: "bar",
}); });
expect(api.get("/", search).request.url).toEqual("http://localhost/?" + search.toString()); expect(api.get("/", search).request.url).toEqual("http://localhost/?" + search.toString());
}); });
@@ -95,6 +89,14 @@ describe("ModuleApi", () => {
expect(api.delete("/").request.method).toEqual("DELETE"); 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 error response
// @todo: test method shortcut functions // @todo: test method shortcut functions
}); });
+170
View File
@@ -0,0 +1,170 @@
import { afterAll, beforeAll, describe, expect, mock, test } from "bun:test";
import type { ModuleBuildContext } from "../../src";
import { App, createApp } from "core/test/utils";
import * as proto from "data/prototype";
import { DbModuleManager } from "modules/db/DbModuleManager";
import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
beforeAll(disableConsoleLog);
afterAll(enableConsoleLog);
describe("App", () => {
test("use db mode by default", async () => {
const app = createApp();
await app.build();
expect(app.mode).toBe("db");
expect(app.isReadOnly()).toBe(false);
expect(app.modules instanceof DbModuleManager).toBe(true);
});
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",
"mcp",
"helper",
]);
},
},
}).build();
expect(called).toHaveBeenCalled();
const app = createApp({
config: {
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");
});
test("lifecycle events are triggered", async () => {
const firstBoot = mock(() => null);
const configUpdate = mock(() => null);
const appBuilt = mock(() => null);
const appRequest = mock(() => null);
const beforeResponse = mock(() => null);
const app = createApp();
app.emgr.onEvent(
App.Events.AppFirstBoot,
(event) => {
expect(event).toBeInstanceOf(App.Events.AppFirstBoot);
expect(event.params.app.version()).toBe(app.version());
firstBoot();
},
"sync",
);
app.emgr.onEvent(
App.Events.AppBuiltEvent,
(event) => {
expect(event).toBeInstanceOf(App.Events.AppBuiltEvent);
expect(event.params.app.version()).toBe(app.version());
appBuilt();
},
"sync",
);
app.emgr.onEvent(
App.Events.AppConfigUpdatedEvent,
() => {
configUpdate();
},
"sync",
);
app.emgr.onEvent(
App.Events.AppRequest,
(event) => {
expect(event).toBeInstanceOf(App.Events.AppRequest);
expect(event.params.app.version()).toBe(app.version());
expect(event.params.request).toBeInstanceOf(Request);
appRequest();
},
"sync",
);
app.emgr.onEvent(
App.Events.AppBeforeResponse,
(event) => {
expect(event).toBeInstanceOf(App.Events.AppBeforeResponse);
expect(event.params.app.version()).toBe(app.version());
expect(event.params.response).toBeInstanceOf(Response);
beforeResponse();
},
"sync",
);
await app.build();
expect(firstBoot).toHaveBeenCalled();
expect(appBuilt).toHaveBeenCalled();
//expect(configUpdate).toHaveBeenCalled();
expect(appRequest).not.toHaveBeenCalled();
expect(beforeResponse).not.toHaveBeenCalled();
});
test("emgr exec modes", async () => {
const called = mock(() => null);
const app = createApp({
options: {
asyncEventsMode: "sync",
},
});
// register async listener
app.emgr.onEvent(App.Events.AppFirstBoot, async () => {
called();
});
await app.build();
await app.server.request(new Request("http://localhost"));
// expect async listeners to be executed sync after request
expect(called).toHaveBeenCalled();
});
test("getMcpClient", async () => {
const app = createApp({
config: {
server: {
mcp: {
enabled: true,
},
},
},
});
await app.build();
const client = app.getMcpClient();
const res = await client.listTools();
expect(res).toBeDefined();
expect(res?.tools.length).toBeGreaterThan(0);
});
});
+47
View File
@@ -0,0 +1,47 @@
import { AppServer, serverConfigSchema } from "modules/server/AppServer";
import { describe, test, expect } from "bun:test";
describe("AppServer", () => {
test("config", () => {
{
const server = new AppServer();
expect(server).toBeDefined();
expect(server.config).toEqual({
cors: {
allow_credentials: true,
origin: "*",
allow_methods: ["GET", "POST", "PATCH", "PUT", "DELETE"],
allow_headers: ["Content-Type", "Content-Length", "Authorization", "Accept"],
},
mcp: {
enabled: false,
path: "/api/system/mcp",
logLevel: "warning",
},
});
}
{
const server = new AppServer({
cors: {
origin: "https",
allow_methods: ["GET", "POST"],
},
});
expect(server).toBeDefined();
expect(server.config).toEqual({
cors: {
allow_credentials: true,
origin: "https",
allow_methods: ["GET", "POST"],
allow_headers: ["Content-Type", "Content-Length", "Authorization", "Accept"],
},
mcp: {
enabled: false,
path: "/api/system/mcp",
logLevel: "warning",
},
});
}
});
});
+131
View File
@@ -0,0 +1,131 @@
import { describe, expect, mock, test, beforeAll, afterAll } from "bun:test";
import { createApp as internalCreateApp, type CreateAppConfig } from "bknd";
import { getDummyConnection } from "../../__test__/helper";
import { ModuleManager } from "modules/ModuleManager";
import { em, entity, text } from "data/prototype";
import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
beforeAll(disableConsoleLog);
afterAll(enableConsoleLog);
async function createApp(config: CreateAppConfig = {}) {
const app = internalCreateApp({
connection: getDummyConnection().dummyConnection,
...config,
options: {
...config.options,
mode: "code",
},
});
await app.build();
return app;
}
describe("code-only", () => {
test("should create app with correct manager", async () => {
const app = await createApp();
await app.build();
expect(app.version()).toBeDefined();
expect(app.modules).toBeInstanceOf(ModuleManager);
});
test("should not perform database syncs", async () => {
const app = await createApp({
config: {
data: em({
test: entity("test", {
name: text(),
}),
}).toJSON(),
},
});
expect(app.em.entities.map((e) => e.name)).toEqual(["test"]);
expect(
await app.em.connection.kysely
.selectFrom("sqlite_master")
.where("type", "=", "table")
.selectAll()
.execute(),
).toEqual([]);
// only perform when explicitly forced
await app.em.schema().sync({ force: true });
expect(
await app.em.connection.kysely
.selectFrom("sqlite_master")
.where("type", "=", "table")
.selectAll()
.execute()
.then((r) => r.map((r) => r.name)),
).toEqual(["test", "sqlite_sequence"]);
});
test("should not perform seeding", async () => {
const called = mock(() => null);
const app = await createApp({
config: {
data: em({
test: entity("test", {
name: text(),
}),
}).toJSON(),
},
options: {
seed: async (ctx) => {
called();
await ctx.em.mutator("test").insertOne({ name: "test" });
},
},
});
await app.em.schema().sync({ force: true });
expect(called).not.toHaveBeenCalled();
expect(
await app.em
.repo("test")
.findMany({})
.then((r) => r.data),
).toEqual([]);
});
test("should sync and perform seeding", async () => {
const called = mock(() => null);
const app = await createApp({
config: {
data: em({
test: entity("test", {
name: text(),
}),
}).toJSON(),
},
options: {
seed: async (ctx) => {
called();
await ctx.em.mutator("test").insertOne({ name: "test" });
},
},
});
await app.em.schema().sync({ force: true });
await app.options?.seed?.({
...app.modules.ctx(),
app: app,
});
expect(called).toHaveBeenCalled();
expect(
await app.em
.repo("test")
.findMany({})
.then((r) => r.data),
).toEqual([{ id: 1, name: "test" }]);
});
test("should not allow to modify config", async () => {
const app = await createApp();
// biome-ignore lint/suspicious/noPrototypeBuiltins: <explanation>
expect(app.modules.hasOwnProperty("mutateConfigSafe")).toBe(false);
expect(() => {
app.modules.configs().auth.enabled = true;
}).toThrow();
});
});
+232
View File
@@ -0,0 +1,232 @@
import { describe, test, expect, beforeEach, beforeAll, afterAll } from "bun:test";
import { type App, createApp, createMcpToolCaller } from "core/test/utils";
import { disableConsoleLog, enableConsoleLog } from "core/utils";
import type { McpServer } from "bknd/utils";
beforeAll(disableConsoleLog);
afterAll(enableConsoleLog);
/**
* - [x] auth_me
* - [x] auth_strategies
* - [x] auth_user_create
* - [x] auth_user_token
* - [x] auth_user_password_change
* - [x] auth_user_password_test
* - [x] config_auth_get
* - [x] config_auth_update
* - [x] config_auth_strategies_get
* - [x] config_auth_strategies_add
* - [x] config_auth_strategies_update
* - [x] config_auth_strategies_remove
* - [x] config_auth_roles_get
* - [x] config_auth_roles_add
* - [x] config_auth_roles_update
* - [x] config_auth_roles_remove
*/
describe("mcp auth", async () => {
let app: App;
let server: McpServer;
beforeEach(async () => {
app = createApp({
config: {
auth: {
enabled: true,
jwt: {
secret: "secret",
},
},
server: {
mcp: {
enabled: true,
},
},
},
});
await app.build();
await app.getMcpClient().ping();
server = app.mcp!;
server.setLogLevel("error");
server.onNotification((message) => {
console.dir(message, { depth: null });
});
});
const tool = createMcpToolCaller();
test("auth_*", async () => {
const me = await tool(server, "auth_me", {});
expect(me.user).toBeNull();
// strategies
const strategies = await tool(server, "auth_strategies", {});
expect(Object.keys(strategies.strategies).length).toEqual(1);
expect(strategies.strategies.password.enabled).toBe(true);
// create user
const user = await tool(
server,
"auth_user_create",
{
email: "test@test.com",
password: "12345678",
},
new Headers(),
);
expect(user.email).toBe("test@test.com");
// create token
const token = await tool(
server,
"auth_user_token",
{
email: "test@test.com",
},
new Headers(),
);
expect(token.token).toBeDefined();
expect(token.user.email).toBe("test@test.com");
// me
const me2 = await tool(
server,
"auth_me",
{},
new Request("http://localhost", {
headers: new Headers({
Authorization: `Bearer ${token.token}`,
}),
}),
);
expect(me2.user.email).toBe("test@test.com");
// change password
const changePassword = await tool(
server,
"auth_user_password_change",
{
email: "test@test.com",
password: "87654321",
},
new Headers(),
);
expect(changePassword.changed).toBe(true);
// test password
const testPassword = await tool(
server,
"auth_user_password_test",
{
email: "test@test.com",
password: "87654321",
},
new Headers(),
);
expect(testPassword.valid).toBe(true);
});
test("config_auth_{get,update}", async () => {
expect(await tool(server, "config_auth_get", {})).toEqual({
path: "",
secrets: false,
partial: false,
value: app.toJSON().auth,
});
// update
await tool(server, "config_auth_update", {
value: {
allow_register: false,
},
});
expect(app.toJSON().auth.allow_register).toBe(false);
});
test("config_auth_strategies_{get,add,update,remove}", async () => {
const strategies = await tool(server, "config_auth_strategies_get", {
key: "password",
});
expect(strategies).toEqual({
secrets: false,
module: "auth",
key: "password",
value: {
enabled: true,
type: "password",
},
});
// add google oauth
const addGoogleOauth = await tool(server, "config_auth_strategies_add", {
key: "google",
value: {
type: "oauth",
enabled: true,
config: {
name: "google",
type: "oidc",
client: {
client_id: "client_id",
client_secret: "client_secret",
},
},
},
return_config: true,
});
expect(addGoogleOauth.config.google.enabled).toBe(true);
expect(app.toJSON().auth.strategies.google?.enabled).toBe(true);
// update (disable) google oauth
await tool(server, "config_auth_strategies_update", {
key: "google",
value: {
enabled: false,
},
});
expect(app.toJSON().auth.strategies.google?.enabled).toBe(false);
// remove google oauth
await tool(server, "config_auth_strategies_remove", {
key: "google",
});
expect(app.toJSON().auth.strategies.google).toBeUndefined();
});
test("config_auth_roles_{get,add,update,remove}", async () => {
// add role
const addGuestRole = await tool(server, "config_auth_roles_add", {
key: "guest",
value: {
permissions: ["read", "write"],
},
return_config: true,
});
expect(addGuestRole.config.guest.permissions.map((p) => p.permission)).toEqual([
"read",
"write",
]);
// update role
await tool(server, "config_auth_roles_update", {
key: "guest",
value: {
permissions: ["read"],
},
});
expect(app.toJSON().auth.roles?.guest?.permissions?.map((p) => p.permission)).toEqual([
"read",
]);
// get role
const getGuestRole = await tool(server, "config_auth_roles_get", {
key: "guest",
});
expect(getGuestRole.value.permissions.map((p) => p.permission)).toEqual(["read"]);
// remove role
await tool(server, "config_auth_roles_remove", {
key: "guest",
});
expect(app.toJSON().auth.roles?.guest).toBeUndefined();
});
});
+44
View File
@@ -0,0 +1,44 @@
import { describe, it, expect, beforeAll, afterAll } from "bun:test";
import { createApp } from "core/test/utils";
import { registries } from "index";
import { StorageLocalAdapter } from "adapter/node/storage/StorageLocalAdapter";
import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
beforeAll(() => disableConsoleLog());
afterAll(enableConsoleLog);
describe("mcp", () => {
it("should have tools", async () => {
registries.media.register("local", StorageLocalAdapter);
const app = createApp({
config: {
auth: {
enabled: true,
},
media: {
enabled: true,
adapter: {
type: "local",
config: {
path: "./",
},
},
},
server: {
mcp: {
enabled: true,
},
},
},
});
await app.build();
// expect mcp to not be loaded yet
expect(app.mcp).toBeNull();
// after first request, mcp should be loaded
await app.getMcpClient().listTools();
expect(app.mcp?.tools.length).toBeGreaterThan(0);
});
});
+347
View File
@@ -0,0 +1,347 @@
import { describe, test, expect, beforeEach, beforeAll, afterAll } from "bun:test";
import { type App, createApp, createMcpToolCaller } from "core/test/utils";
import { getSystemMcp } from "modules/mcp/system-mcp";
import { pickKeys, type McpServer } from "bknd/utils";
import { entity, text } from "bknd";
import { disableConsoleLog, enableConsoleLog } from "core/utils";
beforeAll(disableConsoleLog);
afterAll(enableConsoleLog);
/**
* - [ ] data_sync
* - [x] data_entity_fn_count
* - [x] data_entity_fn_exists
* - [x] data_entity_read_one
* - [x] data_entity_read_many
* - [x] data_entity_insert
* - [x] data_entity_update_many
* - [x] data_entity_update_one
* - [x] data_entity_delete_one
* - [x] data_entity_delete_many
* - [x] data_entity_info
* - [ ] config_data_get
* - [ ] config_data_update
* - [x] config_data_entities_get
* - [x] config_data_entities_add
* - [x] config_data_entities_update
* - [x] config_data_entities_remove
* - [x] config_data_relations_add
* - [x] config_data_relations_get
* - [x] config_data_relations_update
* - [x] config_data_relations_remove
* - [x] config_data_indices_get
* - [x] config_data_indices_add
* - [x] config_data_indices_update
* - [x] config_data_indices_remove
*/
describe("mcp data", async () => {
let app: App;
let server: McpServer;
beforeEach(async () => {
const time = performance.now();
app = createApp({
config: {
server: {
mcp: {
enabled: true,
},
},
},
});
await app.build();
await app.getMcpClient().ping();
server = app.mcp!;
server.setLogLevel("error");
server.onNotification((message) => {
console.dir(message, { depth: null });
});
});
const tool = createMcpToolCaller();
test("config_data_entities_{add,get,update,remove}", async () => {
const result = await tool(server, "config_data_entities_add", {
key: "test",
return_config: true,
value: {},
});
expect(result.success).toBe(true);
expect(result.module).toBe("data");
expect(result.config.test?.type).toEqual("regular");
const entities = Object.keys(app.toJSON().data.entities ?? {});
expect(entities).toContain("test");
{
// get
const result = await tool(server, "config_data_entities_get", {
key: "test",
});
expect(result.module).toBe("data");
expect(result.key).toBe("test");
expect(result.value.type).toEqual("regular");
}
{
// update
const result = await tool(server, "config_data_entities_update", {
key: "test",
return_config: true,
value: {
config: {
name: "Test",
},
},
});
expect(result.success).toBe(true);
expect(result.module).toBe("data");
expect(result.config.test.config?.name).toEqual("Test");
expect(app.toJSON().data.entities?.test?.config?.name).toEqual("Test");
}
{
// remove
const result = await tool(server, "config_data_entities_remove", {
key: "test",
});
expect(result.success).toBe(true);
expect(result.module).toBe("data");
expect(app.toJSON().data.entities?.test).toBeUndefined();
}
});
test("config_data_relations_{add,get,update,remove}", async () => {
// create posts and comments
await tool(server, "config_data_entities_add", {
key: "posts",
value: {},
});
await tool(server, "config_data_entities_add", {
key: "comments",
value: {},
});
expect(Object.keys(app.toJSON().data.entities ?? {})).toEqual(["posts", "comments"]);
// create relation
await tool(server, "config_data_relations_add", {
key: "", // doesn't matter
value: {
type: "n:1",
source: "comments",
target: "posts",
},
});
const config = app.toJSON().data;
expect(
pickKeys((config.relations?.n1_comments_posts as any) ?? {}, ["type", "source", "target"]),
).toEqual({
type: "n:1",
source: "comments",
target: "posts",
});
expect(config.entities?.comments?.fields?.posts_id?.type).toBe("relation");
{
// info
const postsInfo = await tool(server, "data_entity_info", {
entity: "posts",
});
expect(postsInfo.fields).toEqual(["id"]);
expect(postsInfo.relations.all.length).toBe(1);
const commentsInfo = await tool(server, "data_entity_info", {
entity: "comments",
});
expect(commentsInfo.fields).toEqual(["id", "posts_id"]);
expect(commentsInfo.relations.all.length).toBe(1);
}
// update
await tool(server, "config_data_relations_update", {
key: "n1_comments_posts",
value: {
config: {
with_limit: 10,
},
},
});
expect((app.toJSON().data.relations?.n1_comments_posts?.config as any)?.with_limit).toBe(10);
// delete
await tool(server, "config_data_relations_remove", {
key: "n1_comments_posts",
});
expect(app.toJSON().data.relations?.n1_comments_posts).toBeUndefined();
});
test("config_data_indices_update", async () => {
expect(server.tools.map((t) => t.name).includes("config_data_indices_update")).toBe(false);
});
test("config_data_indices_{add,get,remove}", async () => {
// create posts and comments
await tool(server, "config_data_entities_add", {
key: "posts",
value: entity("posts", {
title: text(),
content: text(),
}).toJSON(),
});
// add index on title
await tool(server, "config_data_indices_add", {
key: "", // auto generated
value: {
entity: "posts",
fields: ["title"],
},
});
expect(app.toJSON().data.indices?.idx_posts_title).toEqual({
entity: "posts",
fields: ["title"],
unique: false,
});
// delete
await tool(server, "config_data_indices_remove", {
key: "idx_posts_title",
});
expect(app.toJSON().data.indices?.idx_posts_title).toBeUndefined();
});
test("data_entity_*", async () => {
// create posts and comments
await tool(server, "config_data_entities_add", {
key: "posts",
value: entity("posts", {
title: text(),
content: text(),
}).toJSON(),
});
await tool(server, "config_data_entities_add", {
key: "comments",
value: entity("comments", {
content: text(),
}).toJSON(),
});
// insert a few posts
for (let i = 0; i < 10; i++) {
await tool(server, "data_entity_insert", {
entity: "posts",
json: {
title: `Post ${i}`,
},
});
}
// insert a few comments
for (let i = 0; i < 5; i++) {
await tool(server, "data_entity_insert", {
entity: "comments",
json: {
content: `Comment ${i}`,
},
});
}
const result = await tool(server, "data_entity_read_many", {
entity: "posts",
limit: 5,
});
expect(result.data.length).toBe(5);
expect(result.meta.items).toBe(5);
expect(result.meta.total).toBe(10);
expect(result.data[0].title).toBe("Post 0");
{
// count
const result = await tool(server, "data_entity_fn_count", {
entity: "posts",
});
expect(result.count).toBe(10);
}
{
// exists
const res = await tool(server, "data_entity_fn_exists", {
entity: "posts",
json: {
id: result.data[0].id,
},
});
expect(res.exists).toBe(true);
const res2 = await tool(server, "data_entity_fn_exists", {
entity: "posts",
json: {
id: "123",
},
});
expect(res2.exists).toBe(false);
}
// update
await tool(server, "data_entity_update_one", {
entity: "posts",
id: result.data[0].id,
json: {
title: "Post 0 updated",
},
});
const result2 = await tool(server, "data_entity_read_one", {
entity: "posts",
id: result.data[0].id,
});
expect(result2.data.title).toBe("Post 0 updated");
// delete the second post
await tool(server, "data_entity_delete_one", {
entity: "posts",
id: result.data[1].id,
});
const result3 = await tool(server, "data_entity_read_many", {
entity: "posts",
limit: 2,
});
expect(result3.data.map((p) => p.id)).toEqual([1, 3]);
// update many
await tool(server, "data_entity_update_many", {
entity: "posts",
update: {
title: "Post updated",
},
where: {
title: { $isnull: 0 },
},
});
const result4 = await tool(server, "data_entity_read_many", {
entity: "posts",
limit: 10,
});
expect(result4.data.length).toBe(9);
expect(result4.data.map((p) => p.title)).toEqual(
Array.from({ length: 9 }, () => "Post updated"),
);
// delete many
await tool(server, "data_entity_delete_many", {
entity: "posts",
json: {
title: { $isnull: 0 },
},
});
const result5 = await tool(server, "data_entity_read_many", {
entity: "posts",
limit: 10,
});
expect(result5.data.length).toBe(0);
expect(result5.meta.items).toBe(0);
expect(result5.meta.total).toBe(0);
});
});
+119
View File
@@ -0,0 +1,119 @@
import { describe, test, expect, beforeAll, beforeEach, afterAll } from "bun:test";
import { type App, createApp, createMcpToolCaller } from "core/test/utils";
import { getSystemMcp } from "modules/mcp/system-mcp";
import { registries } from "index";
import { StorageLocalAdapter } from "adapter/node/storage/StorageLocalAdapter";
import { disableConsoleLog, enableConsoleLog } from "core/utils";
import type { McpServer } from "bknd/utils";
beforeAll(disableConsoleLog);
afterAll(enableConsoleLog);
/**
* - [x] config_media_get
* - [x] config_media_update
* - [x] config_media_adapter_get
* - [x] config_media_adapter_update
*/
describe("mcp media", async () => {
let app: App;
let server: McpServer;
beforeEach(async () => {
registries.media.register("local", StorageLocalAdapter);
app = createApp({
config: {
media: {
enabled: true,
adapter: {
type: "local",
config: {
path: "./",
},
},
},
server: {
mcp: {
enabled: true,
},
},
},
});
await app.build();
await app.getMcpClient().ping();
server = app.mcp!;
server.setLogLevel("error");
server.onNotification((message) => {
console.dir(message, { depth: null });
});
});
const tool = createMcpToolCaller();
test("config_media_{get,update}", async () => {
const result = await tool(server, "config_media_get", {});
expect(result).toEqual({
path: "",
secrets: false,
partial: false,
value: app.toJSON().media,
});
// partial
expect((await tool(server, "config_media_get", { path: "adapter" })).value).toEqual({
type: "local",
config: {
path: "./",
},
});
// update
await tool(server, "config_media_update", {
value: {
storage: {
body_max_size: 1024 * 1024 * 10,
},
},
return_config: true,
});
expect(app.toJSON().media.storage.body_max_size).toBe(1024 * 1024 * 10);
});
test("config_media_adapter_{get,update}", async () => {
const result = await tool(server, "config_media_adapter_get", {});
expect(result).toEqual({
secrets: false,
value: app.toJSON().media.adapter,
});
// update
await tool(server, "config_media_adapter_update", {
value: {
type: "local",
config: {
path: "./subdir",
},
},
});
const adapter = app.toJSON().media.adapter as any;
expect(adapter.config.path).toBe("./subdir");
expect(adapter.type).toBe("local");
// set to s3
{
await tool(server, "config_media_adapter_update", {
value: {
type: "s3",
config: {
access_key: "123",
secret_access_key: "456",
url: "https://example.com/what",
},
},
});
const adapter = app.toJSON(true).media.adapter as any;
expect(adapter.type).toBe("s3");
expect(adapter.config.url).toBe("https://example.com/what");
}
});
});
+77
View File
@@ -0,0 +1,77 @@
import { describe, test, expect, beforeAll, afterAll } from "bun:test";
import { type App, createApp, createMcpToolCaller } from "core/test/utils";
import type { McpServer } from "bknd/utils";
import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
beforeAll(() => disableConsoleLog());
afterAll(enableConsoleLog);
/**
* - [x] config_server_get
* - [x] config_server_update
*/
describe("mcp system", async () => {
let app: App;
let server: McpServer;
beforeAll(async () => {
app = createApp({
config: {
server: {
mcp: {
enabled: true,
},
},
},
});
await app.build();
await app.getMcpClient().ping();
server = app.mcp!;
});
const tool = createMcpToolCaller();
test("config_server_get", async () => {
const result = await tool(server, "config_server_get", {});
expect(JSON.parse(JSON.stringify(result))).toEqual({
path: "",
secrets: false,
partial: false,
value: JSON.parse(JSON.stringify(app.toJSON().server)),
});
});
test("config_server_get2", async () => {
const result = await tool(server, "config_server_get", {});
expect(JSON.parse(JSON.stringify(result))).toEqual({
path: "",
secrets: false,
partial: false,
value: JSON.parse(JSON.stringify(app.toJSON().server)),
});
});
test("config_server_update", async () => {
const original = JSON.parse(JSON.stringify(app.toJSON().server));
const result = await tool(server, "config_server_update", {
value: {
cors: {
origin: "http://localhost",
},
},
return_config: true,
});
expect(JSON.parse(JSON.stringify(result))).toEqual({
success: true,
module: "server",
config: {
...original,
cors: {
...original.cors,
origin: "http://localhost",
},
},
});
expect(app.toJSON().server.cors.origin).toBe("http://localhost");
});
});
+61
View File
@@ -0,0 +1,61 @@
import { AppEvents } from "App";
import { describe, test, expect, beforeAll, mock, afterAll } from "bun:test";
import { type App, createApp, createMcpToolCaller } from "core/test/utils";
import type { McpServer } from "bknd/utils";
import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
beforeAll(disableConsoleLog);
afterAll(enableConsoleLog);
/**
* - [x] system_config
* - [x] system_build
* - [x] system_ping
* - [x] system_info
*/
describe("mcp system", async () => {
let app: App;
let server: McpServer;
beforeAll(async () => {
app = createApp({
config: {
server: {
mcp: {
enabled: true,
},
},
},
});
await app.build();
await app.getMcpClient().ping();
server = app.mcp!;
});
const tool = createMcpToolCaller();
test("system_ping", async () => {
const result = await tool(server, "system_ping", {});
expect(result).toEqual({ pong: true });
});
test("system_info", async () => {
const result = await tool(server, "system_info", {});
expect(Object.keys(result).length).toBeGreaterThan(0);
expect(Object.keys(result)).toContainValues(["version", "runtime", "connection"]);
});
test("system_build", async () => {
const called = mock(() => null);
app.emgr.onEvent(AppEvents.AppBuiltEvent, () => void called(), { once: true });
const result = await tool(server, "system_build", {});
expect(called).toHaveBeenCalledTimes(1);
expect(result.success).toBe(true);
});
test("system_config", async () => {
const result = await tool(server, "system_config", {});
expect(result).toEqual(app.toJSON());
});
});
+42
View File
@@ -0,0 +1,42 @@
import { describe, expect, test } from "bun:test";
import { code, hybrid } from "modes";
describe("modes", () => {
describe("code", () => {
test("verify base configuration", async () => {
const c = code({}) as any;
const config = await c.app?.({} as any);
expect(Object.keys(config)).toEqual(["options"]);
expect(config.options.mode).toEqual("code");
expect(config.options.plugins).toEqual([]);
expect(config.options.manager.skipValidation).toEqual(false);
expect(config.options.manager.onModulesBuilt).toBeDefined();
});
test("keeps overrides", async () => {
const c = code({
connection: {
url: ":memory:",
},
}) as any;
const config = await c.app?.({} as any);
expect(config.connection.url).toEqual(":memory:");
});
});
describe("hybrid", () => {
test("fails if no reader is provided", () => {
// @ts-ignore
expect(hybrid({} as any).app?.({} as any)).rejects.toThrow(/reader/);
});
test("verify base configuration", async () => {
const c = hybrid({ reader: async () => ({}) }) as any;
const config = await c.app?.({} as any);
expect(Object.keys(config)).toEqual(["reader", "beforeBuild", "config", "options"]);
expect(config.options.mode).toEqual("db");
expect(config.options.plugins).toEqual([]);
expect(config.options.manager.skipValidation).toEqual(false);
expect(config.options.manager.onModulesBuilt).toBeDefined();
});
});
});
@@ -0,0 +1,80 @@
import { describe, it, expect, mock, beforeAll, afterAll } from "bun:test";
import { createApp } from "core/test/utils";
import { syncConfig } from "plugins/dev/sync-config.plugin";
import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
beforeAll(() => disableConsoleLog());
afterAll(enableConsoleLog);
describe("syncConfig", () => {
it("should only sync if enabled", async () => {
const called = mock(() => null);
const app = createApp();
await app.build();
await syncConfig({
write: () => {
called();
},
enabled: false,
includeFirstBoot: false,
})(app).onBuilt?.();
expect(called).not.toHaveBeenCalled();
await syncConfig({
write: () => {
called();
},
enabled: false,
includeFirstBoot: true,
})(app).onBuilt?.();
expect(called).not.toHaveBeenCalled();
await syncConfig({
write: () => {
called();
},
enabled: true,
includeFirstBoot: true,
})(app).onBuilt?.();
expect(called).toHaveBeenCalledTimes(1);
});
it("should respect secrets", async () => {
const called = mock(() => null);
const app = createApp({
config: {
auth: {
enabled: true,
jwt: {
secret: "test",
},
},
},
});
await app.build();
await syncConfig({
write: async (config) => {
called();
expect(config.auth.jwt.secret).toBe("test");
},
enabled: true,
includeSecrets: true,
includeFirstBoot: true,
})(app).onBuilt?.();
await syncConfig({
write: async (config) => {
called();
// it's an important test, because the `jwt` part is omitted if secrets=false in general app.toJSON()
// but it's required to get the app running
expect(config.auth.jwt.secret).toBe("");
},
enabled: true,
includeSecrets: false,
includeFirstBoot: true,
})(app).onBuilt?.();
expect(called).toHaveBeenCalledTimes(2);
});
});
+47 -12
View File
@@ -1,7 +1,12 @@
import { describe, expect, test } from "bun:test"; import { afterAll, beforeAll, describe, expect, test } from "bun:test";
import { createApp, registries } from "../../src"; import { registries } from "../../src";
import { createApp } from "core/test/utils";
import * as proto from "../../src/data/prototype"; import * as proto from "../../src/data/prototype";
import { StorageLocalAdapter } from "../../src/media/storage/adapters/StorageLocalAdapter"; import { StorageLocalAdapter } from "adapter/node/storage/StorageLocalAdapter";
import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
beforeAll(() => disableConsoleLog());
afterAll(enableConsoleLog);
describe("repros", async () => { describe("repros", async () => {
/** /**
@@ -24,9 +29,9 @@ describe("repros", async () => {
adapter: { adapter: {
type: "local", type: "local",
config: { config: {
path: "./" path: "./",
} },
} },
}); });
expect(config.enabled).toBe(true); expect(config.enabled).toBe(true);
@@ -38,9 +43,9 @@ describe("repros", async () => {
"entities.test", "entities.test",
proto proto
.entity("test", { .entity("test", {
content: proto.text() content: proto.text(),
}) })
.toJSON() .toJSON(),
); );
expect(app.em.entities.map((e) => e.name)).toContain("test"); expect(app.em.entities.map((e) => e.name)).toContain("test");
} }
@@ -54,8 +59,8 @@ describe("repros", async () => {
hidden: false, hidden: false,
mime_types: [], mime_types: [],
virtual: true, virtual: true,
entity: "test" entity: "test",
} },
}); });
expect( expect(
@@ -63,11 +68,41 @@ describe("repros", async () => {
type: "poly", type: "poly",
source: "test", source: "test",
target: "media", target: "media",
config: { mappedBy: "files" } config: { mappedBy: "files" },
}) }),
).resolves.toBeDefined(); ).resolves.toBeDefined();
} }
expect(app.em.entities.map((e) => e.name)).toEqual(["media", "test"]); expect(app.em.entities.map((e) => e.name)).toEqual(["media", "test"]);
}); });
test("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({ config: { 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",
},
]);
});
}); });
+34 -39
View File
@@ -1,46 +1,41 @@
import { Authenticator } from "auth/authenticate/Authenticator";
import { describe, expect, test } from "bun:test"; import { describe, expect, test } from "bun:test";
import { Authenticator, type User, type UserPool } from "../../src/auth";
import { cookieConfig } from "../../src/auth/authenticate/Authenticator";
import { PasswordStrategy } from "../../src/auth/authenticate/strategies/PasswordStrategy";
import * as hash from "../../src/auth/utils/hash";
import { Default, parse } from "../../src/core/utils";
/*class MemoryUserPool implements UserPool {
constructor(private users: User[] = []) {}
async findBy(prop: "id" | "email" | "username", value: string | number) {
return this.users.find((user) => user[prop] === value);
}
async create(user: Pick<User, "email" | "password">) {
const id = this.users.length + 1;
const newUser = { ...user, id, username: user.email };
this.users.push(newUser);
return newUser;
}
}*/
describe("Authenticator", async () => { describe("Authenticator", async () => {
test("cookie options", async () => { test("should return auth cookie headers", async () => {
console.log("parsed", parse(cookieConfig, undefined)); const auth = new Authenticator({}, null as any, {
console.log(Default(cookieConfig, {})); jwt: {
}); secret: "secret",
/*const userpool = new MemoryUserPool([ fields: [],
{ id: 1, email: "d", username: "test", password: await hash.sha256("test") }, },
]); cookie: {
sameSite: "strict",
test("sha256 login", async () => { },
const auth = new Authenticator(userpool, {
password: new PasswordStrategy({
hashing: "sha256",
}),
}); });
const headers = await auth.getAuthCookieHeader("token");
const cookie = headers.get("Set-Cookie");
expect(cookie).toStartWith("auth=");
expect(cookie).toEndWith("HttpOnly; Secure; SameSite=Strict");
const { token } = await auth.login("password", { email: "d", password: "test" }); // now expect it to be removed
expect(token).toBeDefined(); const headers2 = await auth.removeAuthCookieHeader(headers);
const cookie2 = headers2.get("Set-Cookie");
expect(cookie2).toStartWith("auth=; Max-Age=0; Path=/; Expires=");
expect(cookie2).toEndWith("HttpOnly; Secure; SameSite=Strict");
});
const { iat, ...decoded } = decodeJwt<any>(token); test("should return auth cookie string", async () => {
expect(decoded).toEqual({ id: 1, email: "d", username: "test" }); const auth = new Authenticator({}, null as any, {
expect(await auth.verify(token)).toBe(true); jwt: {
});*/ secret: "secret",
fields: [],
},
cookie: {
sameSite: "strict",
},
});
const cookie = await auth.unsafeGetAuthCookie("token");
expect(cookie).toStartWith("auth=");
expect(cookie).toEndWith("HttpOnly; Secure; SameSite=Strict");
});
}); });
+198 -40
View File
@@ -1,89 +1,247 @@
import { describe, expect, test } from "bun:test"; import { describe, expect, test, beforeAll, afterAll } from "bun:test";
import { Guard } from "../../../src/auth"; import { Guard, type GuardConfig } from "auth/authorize/Guard";
import { Permission } from "auth/authorize/Permission";
import { Role, type RoleSchema } from "auth/authorize/Role";
import { objectTransform, s } from "bknd/utils";
import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
beforeAll(disableConsoleLog);
afterAll(enableConsoleLog);
function createGuard(
permissionNames: string[],
roles?: Record<string, Omit<RoleSchema, "name">>,
config?: GuardConfig,
) {
const _roles = roles
? objectTransform(roles, ({ permissions = [], is_default, implicit_allow }, name) => {
return Role.create(name, { permissions, is_default, implicit_allow });
})
: {};
const _permissions = permissionNames.map((name) => new Permission(name));
return new Guard(_permissions, Object.values(_roles), config);
}
describe("authorize", () => { describe("authorize", () => {
const read = new Permission("read", {
filterable: true,
});
const write = new Permission("write");
test("basic", async () => { test("basic", async () => {
const guard = Guard.create( const guard = createGuard(
["read", "write"], ["read", "write"],
{ {
admin: { admin: {
permissions: ["read", "write"] permissions: ["read", "write"],
} },
}, },
{ enabled: true } { enabled: true },
); );
const user = { const user = {
role: "admin" role: "admin",
}; };
guard.setUserContext(user); expect(guard.granted(read, user)).toBeUndefined();
expect(guard.granted(write, user)).toBeUndefined();
expect(guard.granted("read")).toBe(true); expect(() => guard.granted(new Permission("something"), {})).toThrow();
expect(guard.granted("write")).toBe(true);
expect(() => guard.granted("something")).toThrow();
}); });
test("with default", async () => { test("with default", async () => {
const guard = Guard.create( const guard = createGuard(
["read", "write"], ["read", "write"],
{ {
admin: { admin: {
permissions: ["read", "write"] permissions: ["read", "write"],
}, },
guest: { guest: {
permissions: ["read"], permissions: ["read"],
is_default: true is_default: true,
} },
}, },
{ enabled: true } { enabled: true },
); );
expect(guard.granted("read")).toBe(true); expect(guard.granted(read, {})).toBeUndefined();
expect(guard.granted("write")).toBe(false); expect(() => guard.granted(write, {})).toThrow();
const user = { const user = {
role: "admin" role: "admin",
}; };
guard.setUserContext(user); expect(guard.granted(read, user)).toBeUndefined();
expect(guard.granted(write, user)).toBeUndefined();
expect(guard.granted("read")).toBe(true);
expect(guard.granted("write")).toBe(true);
}); });
test("guard implicit allow", async () => { test("guard implicit allow", async () => {
const guard = Guard.create([], {}, { enabled: false }); const guard = createGuard([], {}, { enabled: false });
expect(guard.granted("read")).toBe(true); expect(guard.granted(read, {})).toBeUndefined();
expect(guard.granted("write")).toBe(true); expect(guard.granted(write, {})).toBeUndefined();
}); });
test("role implicit allow", async () => { test("role implicit allow", async () => {
const guard = Guard.create(["read", "write"], { const guard = createGuard(["read", "write"], {
admin: { admin: {
implicit_allow: true implicit_allow: true,
} },
}); });
guard.setUserContext({ const user = {
role: "admin" role: "admin",
}); };
expect(guard.granted("read")).toBe(true); expect(guard.granted(read, user)).toBeUndefined();
expect(guard.granted("write")).toBe(true); expect(guard.granted(write, user)).toBeUndefined();
}); });
test("guard with guest role implicit allow", async () => { test("guard with guest role implicit allow", async () => {
const guard = Guard.create(["read", "write"], { const guard = createGuard(["read", "write"], {
guest: { guest: {
implicit_allow: true, implicit_allow: true,
is_default: true is_default: true,
} },
}); });
expect(guard.getUserRole()?.name).toBe("guest"); expect(guard.getUserRole()?.name).toBe("guest");
expect(guard.granted("read")).toBe(true); expect(guard.granted(read, {})).toBeUndefined();
expect(guard.granted("write")).toBe(true); expect(guard.granted(write, {})).toBeUndefined();
});
describe("cases", () => {
test("guest none, member deny if user.enabled is false", () => {
const guard = createGuard(
["read"],
{
guest: {
is_default: true,
},
member: {
permissions: [
{
permission: "read",
policies: [
{
condition: {},
effect: "filter",
filter: {
type: "member",
},
},
{
condition: {
"user.enabled": false,
},
effect: "deny",
},
],
},
],
},
},
{ enabled: true },
);
expect(() => guard.granted(read, { role: "guest" })).toThrow();
// member is allowed, because default role permission effect is allow
// and no deny policy is met
expect(guard.granted(read, { role: "member" })).toBeUndefined();
// member is allowed, because deny policy is not met
expect(guard.granted(read, { role: "member", enabled: true })).toBeUndefined();
// member is denied, because deny policy is met
expect(() => guard.granted(read, { role: "member", enabled: false })).toThrow();
// get the filter for member role
expect(guard.filters(read, { role: "member" }).filter).toEqual({
type: "member",
});
// get filter for guest
expect(guard.filters(read, {}).filter).toBeUndefined();
});
test("guest should only read posts that are public", () => {
const read = new Permission(
"read",
{
// make this permission filterable
// without this, `filter` policies have no effect
filterable: true,
},
// expect the context to match this schema
// otherwise exit with 500 to ensure proper policy checking
s.object({
entity: s.string(),
}),
);
const guard = createGuard(
["read"],
{
guest: {
// this permission is applied if no (or invalid) role is provided
is_default: true,
permissions: [
{
permission: "read",
// effect deny means only having this permission, doesn't guarantee access
effect: "deny",
policies: [
{
// only if this condition is met
condition: {
entity: {
$in: ["posts"],
},
},
// the effect is allow
effect: "allow",
},
{
condition: {
entity: "posts",
},
effect: "filter",
filter: {
public: true,
},
},
],
},
],
},
// members should be allowed to read all
member: {
permissions: [
{
permission: "read",
},
],
},
},
{ enabled: true },
);
// guest can only read posts
expect(guard.granted(read, {}, { entity: "posts" })).toBeUndefined();
expect(() => guard.granted(read, {}, { entity: "users" })).toThrow();
// and guests can only read public posts
expect(guard.filters(read, {}, { entity: "posts" }).filter).toEqual({
public: true,
});
// member can read posts and users
expect(guard.granted(read, { role: "member" }, { entity: "posts" })).toBeUndefined();
expect(guard.granted(read, { role: "member" }, { entity: "users" })).toBeUndefined();
// member should not have a filter
expect(
guard.filters(read, { role: "member" }, { entity: "posts" }).filter,
).toBeUndefined();
});
}); });
}); });
@@ -0,0 +1,327 @@
import { describe, it, expect, beforeAll, afterAll } from "bun:test";
import { createApp } from "core/test/utils";
import type { CreateAppConfig } from "App";
import * as proto from "data/prototype";
import { mergeObject } from "core/utils/objects";
import type { App, DB } from "bknd";
import type { CreateUserPayload } from "auth/AppAuth";
import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
beforeAll(disableConsoleLog);
afterAll(enableConsoleLog);
async function makeApp(config: Partial<CreateAppConfig["config"]> = {}) {
const app = createApp({
config: mergeObject(
{
data: proto
.em(
{
users: proto.systemEntity("users", {}),
posts: proto.entity("posts", {
title: proto.text(),
content: proto.text(),
}),
comments: proto.entity("comments", {
content: proto.text(),
}),
},
({ relation }, { users, posts, comments }) => {
relation(posts).manyToOne(users);
relation(comments).manyToOne(posts);
},
)
.toJSON(),
auth: {
enabled: true,
jwt: {
secret: "secret",
},
},
},
config,
),
});
await app.build();
return app;
}
async function createUsers(app: App, users: CreateUserPayload[]) {
return Promise.all(
users.map(async (user) => {
return await app.createUser(user);
}),
);
}
async function loadFixtures(app: App, fixtures: Record<string, any[]> = {}) {
const results = {} as any;
for (const [entity, data] of Object.entries(fixtures)) {
results[entity] = await app.em
.mutator(entity as any)
.insertMany(data)
.then((result) => result.data);
}
return results;
}
describe("data permissions", async () => {
const app = await makeApp({
server: {
mcp: {
enabled: true,
},
},
auth: {
guard: {
enabled: true,
},
roles: {
guest: {
is_default: true,
permissions: [
{
permission: "system.access.api",
},
{
permission: "data.entity.read",
policies: [
{
condition: {
entity: "posts",
},
effect: "filter",
filter: {
users_id: { $isnull: 1 },
},
},
],
},
{
permission: "data.entity.create",
policies: [
{
condition: {
entity: "posts",
},
effect: "filter",
filter: {
users_id: { $isnull: 1 },
},
},
],
},
{
permission: "data.entity.update",
policies: [
{
condition: {
entity: "posts",
},
effect: "filter",
filter: {
users_id: { $isnull: 1 },
},
},
],
},
{
permission: "data.entity.delete",
policies: [
{
condition: { entity: "posts" },
},
{
condition: { entity: "posts" },
effect: "filter",
filter: {
users_id: { $isnull: 1 },
},
},
],
},
],
},
},
},
});
const users = [
{ email: "foo@example.com", password: "password" },
{ email: "bar@example.com", password: "password" },
];
const fixtures = {
posts: [
{ content: "post 1", users_id: 1 },
{ content: "post 2", users_id: 2 },
{ content: "post 3", users_id: null },
],
comments: [
{ content: "comment 1", posts_id: 1 },
{ content: "comment 2", posts_id: 2 },
{ content: "comment 3", posts_id: 3 },
],
};
await createUsers(app, users);
const results = await loadFixtures(app, fixtures);
describe("http", async () => {
it("read many", async () => {
// many only includes posts with users_id is null
const res = await app.server.request("/api/data/entity/posts");
const data = await res.json().then((r: any) => r.data);
expect(data).toEqual([results.posts[2]]);
// same with /query
{
const res = await app.server.request("/api/data/entity/posts/query", {
method: "POST",
});
const data = await res.json().then((r: any) => r.data);
expect(data).toEqual([results.posts[2]]);
}
});
it("read one", async () => {
// one only includes posts with users_id is null
{
const res = await app.server.request("/api/data/entity/posts/1");
const data = await res.json().then((r: any) => r.data);
expect(res.status).toBe(404);
expect(data).toBeUndefined();
}
// read one by allowed id
{
const res = await app.server.request("/api/data/entity/posts/3");
const data = await res.json().then((r: any) => r.data);
expect(res.status).toBe(200);
expect(data).toEqual(results.posts[2]);
}
});
it("read many by reference", async () => {
const res = await app.server.request("/api/data/entity/posts/1/comments");
const data = await res.json().then((r: any) => r.data);
expect(res.status).toBe(200);
expect(data).toEqual(results.comments.filter((c: any) => c.posts_id === 1));
});
it("mutation create one", async () => {
// not allowed
{
const res = await app.server.request("/api/data/entity/posts", {
method: "POST",
body: JSON.stringify({ content: "post 4" }),
});
expect(res.status).toBe(403);
}
// allowed
{
const res = await app.server.request("/api/data/entity/posts", {
method: "POST",
body: JSON.stringify({ content: "post 4", users_id: null }),
});
expect(res.status).toBe(201);
}
});
it("mutation update one", async () => {
// update one: not allowed
const res = await app.server.request("/api/data/entity/posts/1", {
method: "PATCH",
body: JSON.stringify({ content: "post 4" }),
});
expect(res.status).toBe(403);
{
// update one: allowed
const res = await app.server.request("/api/data/entity/posts/3", {
method: "PATCH",
body: JSON.stringify({ content: "post 3 (updated)" }),
});
expect(res.status).toBe(200);
expect(await res.json().then((r: any) => r.data.content)).toBe("post 3 (updated)");
}
});
it("mutation update many", async () => {
// update many: not allowed
const res = await app.server.request("/api/data/entity/posts", {
method: "PATCH",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
update: { content: "post 4" },
where: { users_id: { $isnull: 0 } },
}),
});
expect(res.status).toBe(200); // because filtered
const _data = await res.json().then((r: any) => r.data.map((p: any) => p.users_id));
expect(_data.every((u: any) => u === null)).toBe(true);
// verify
const data = await app.em
.repo("posts")
.findMany({ select: ["content", "users_id"] })
.then((r) => r.data);
// expect non null users_id to not have content "post 4"
expect(
data.filter((p: any) => p.users_id !== null).every((p: any) => p.content !== "post 4"),
).toBe(true);
// expect null users_id to have content "post 4"
expect(
data.filter((p: any) => p.users_id === null).every((p: any) => p.content === "post 4"),
).toBe(true);
});
const count = async () => {
const {
data: { count: _count },
} = await app.em.repo("posts").count();
return _count;
};
it("mutation delete one", async () => {
const initial = await count();
// delete one: not allowed
const res = await app.server.request("/api/data/entity/posts/1", {
method: "DELETE",
});
expect(res.status).toBe(403);
expect(await count()).toBe(initial);
{
// delete one: allowed
const res = await app.server.request("/api/data/entity/posts/3", {
method: "DELETE",
});
expect(res.status).toBe(200);
expect(await count()).toBe(initial - 1);
}
});
it("mutation delete many", async () => {
// delete many: not allowed
const res = await app.server.request("/api/data/entity/posts", {
method: "DELETE",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
where: {},
}),
});
expect(res.status).toBe(200);
// only deleted posts with users_id is null
const remaining = await app.em
.repo("posts")
.findMany()
.then((r) => r.data);
expect(remaining.every((p: any) => p.users_id !== null)).toBe(true);
});
});
});
@@ -0,0 +1,40 @@
import { describe, test, expect, beforeAll, afterAll } from "bun:test";
import { createAuthTestApp } from "./shared";
import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
import { em, entity, text } from "data/prototype";
beforeAll(disableConsoleLog);
afterAll(enableConsoleLog);
const schema = em(
{
posts: entity("posts", {
title: text(),
content: text(),
}),
comments: entity("comments", {
content: text(),
}),
},
({ relation }, { posts, comments }) => {
relation(posts).manyToOne(comments);
},
);
describe("DataController (auth)", () => {
test("reading schema.json", async () => {
const { request } = await createAuthTestApp(
{
permission: ["system.access.api", "data.entity.read", "system.schema.read"],
request: new Request("http://localhost/api/data/schema.json"),
},
{
config: { data: schema.toJSON() },
},
);
expect((await request.guest()).status).toBe(403);
expect((await request.member()).status).toBe(403);
expect((await request.authorized()).status).toBe(200);
expect((await request.admin()).status).toBe(200);
});
});
@@ -0,0 +1,41 @@
import { describe, test, expect, beforeAll, afterAll } from "bun:test";
import { createAuthTestApp } from "./shared";
import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
beforeAll(disableConsoleLog);
afterAll(enableConsoleLog);
describe("SystemController (auth)", () => {
test("reading info", async () => {
const { request } = await createAuthTestApp({
permission: ["system.access.api", "system.info"],
request: new Request("http://localhost/api/system/info"),
});
expect((await request.guest()).status).toBe(403);
expect((await request.member()).status).toBe(403);
expect((await request.authorized()).status).toBe(200);
expect((await request.admin()).status).toBe(200);
});
test("reading permissions", async () => {
const { request } = await createAuthTestApp({
permission: ["system.access.api", "system.schema.read"],
request: new Request("http://localhost/api/system/permissions"),
});
expect((await request.guest()).status).toBe(403);
expect((await request.member()).status).toBe(403);
expect((await request.authorized()).status).toBe(200);
expect((await request.admin()).status).toBe(200);
});
test("access openapi", async () => {
const { request } = await createAuthTestApp({
permission: ["system.access.api", "system.openapi"],
request: new Request("http://localhost/api/system/openapi.json"),
});
expect((await request.guest()).status).toBe(403);
expect((await request.member()).status).toBe(403);
expect((await request.authorized()).status).toBe(200);
expect((await request.admin()).status).toBe(200);
});
});
+171
View File
@@ -0,0 +1,171 @@
import { createApp } from "core/test/utils";
import type { CreateAppConfig } from "App";
import type { RoleSchema } from "auth/authorize/Role";
import { isPlainObject } from "core/utils";
export type AuthTestConfig = {
guest?: RoleSchema;
member?: RoleSchema;
authorized?: RoleSchema;
};
export async function createAuthTestApp(
testConfig: {
permission: AuthTestConfig | string | string[];
request: Request;
},
config: Partial<CreateAppConfig> = {},
) {
let member: RoleSchema | undefined;
let authorized: RoleSchema | undefined;
let guest: RoleSchema | undefined;
if (isPlainObject(testConfig.permission)) {
if (testConfig.permission.guest)
guest = {
...testConfig.permission.guest,
is_default: true,
};
if (testConfig.permission.member) member = testConfig.permission.member;
if (testConfig.permission.authorized) authorized = testConfig.permission.authorized;
} else {
member = {
permissions: [],
};
authorized = {
permissions: Array.isArray(testConfig.permission)
? testConfig.permission
: [testConfig.permission],
};
guest = {
permissions: [],
is_default: true,
};
}
console.log("authorized", authorized);
const app = createApp({
...config,
config: {
...config.config,
auth: {
...config.config?.auth,
enabled: true,
guard: {
enabled: true,
...config.config?.auth?.guard,
},
jwt: {
...config.config?.auth?.jwt,
secret: "secret",
},
roles: {
...config.config?.auth?.roles,
guest,
member,
authorized,
admin: {
implicit_allow: true,
},
},
},
},
});
await app.build();
const users = {
guest: null,
member: await app.createUser({
email: "member@test.com",
password: "12345678",
role: "member",
}),
authorized: await app.createUser({
email: "authorized@test.com",
password: "12345678",
role: "authorized",
}),
admin: await app.createUser({
email: "admin@test.com",
password: "12345678",
role: "admin",
}),
} as const;
const tokens = {} as Record<keyof typeof users, string>;
for (const [key, user] of Object.entries(users)) {
if (user) {
tokens[key as keyof typeof users] = await app.module.auth.authenticator.jwt(user);
}
}
async function makeRequest(user: keyof typeof users, input: string, init: RequestInit = {}) {
const headers = new Headers(init.headers ?? {});
if (user in tokens) {
headers.set("Authorization", `Bearer ${tokens[user as keyof typeof tokens]}`);
}
const res = await app.server.request(input, {
...init,
headers,
});
let data: any;
if (res.headers.get("Content-Type")?.startsWith("application/json")) {
data = await res.json();
} else if (res.headers.get("Content-Type")?.startsWith("text/")) {
data = await res.text();
}
return {
status: res.status,
ok: res.ok,
headers: Object.fromEntries(res.headers.entries()),
data,
};
}
const requestFn = new Proxy(
{},
{
get(_, prop: keyof typeof users) {
return async (input: string, init: RequestInit = {}) => {
return makeRequest(prop, input, init);
};
},
},
) as {
[K in keyof typeof users]: (
input: string,
init?: RequestInit,
) => Promise<{
status: number;
ok: boolean;
headers: Record<string, string>;
data: any;
}>;
};
const request = new Proxy(
{},
{
get(_, prop: keyof typeof users) {
return async () => {
return makeRequest(prop, testConfig.request.url, {
headers: testConfig.request.headers,
method: testConfig.request.method,
body: testConfig.request.body,
});
};
},
},
) as {
[K in keyof typeof users]: () => Promise<{
status: number;
ok: boolean;
headers: Record<string, string>;
data: any;
}>;
};
return { app, users, request, requestFn };
}
@@ -0,0 +1,543 @@
import { describe, it, expect } from "bun:test";
import { s } from "bknd/utils";
import { Permission } from "auth/authorize/Permission";
import { Policy } from "auth/authorize/Policy";
import { Hono } from "hono";
import { getPermissionRoutes, permission } from "auth/middlewares/permission.middleware";
import { auth } from "auth/middlewares/auth.middleware";
import { Guard, mergeFilters, type GuardConfig } from "auth/authorize/Guard";
import { Role, RolePermission } from "auth/authorize/Role";
import { Exception } from "bknd";
import { convert } from "core/object/query/object-query";
describe("Permission", () => {
it("works with minimal schema", () => {
expect(() => new Permission("test")).not.toThrow();
});
it("parses context", () => {
const p = new Permission(
"test3",
{
filterable: true,
},
s.object({
a: s.string(),
}),
);
// @ts-expect-error
expect(() => p.parseContext({ a: [] })).toThrow();
expect(p.parseContext({ a: "test" })).toEqual({ a: "test" });
// @ts-expect-error
expect(p.parseContext({ a: 1 })).toEqual({ a: "1" });
});
});
describe("Policy", () => {
it("works with minimal schema", () => {
expect(() => new Policy().toJSON()).not.toThrow();
});
it("checks condition", () => {
const p = new Policy({
condition: {
a: 1,
},
});
expect(p.meetsCondition({ a: 1 })).toBe(true);
expect(p.meetsCondition({ a: 2 })).toBe(false);
expect(p.meetsCondition({ a: 1, b: 1 })).toBe(true);
expect(p.meetsCondition({})).toBe(false);
const p2 = new Policy({
condition: {
a: { $gt: 1 },
$or: {
b: { $lt: 2 },
},
},
});
expect(p2.meetsCondition({ a: 2 })).toBe(true);
expect(p2.meetsCondition({ a: 1 })).toBe(false);
expect(p2.meetsCondition({ a: 1, b: 1 })).toBe(true);
});
it("filters", () => {
const p = new Policy({
filter: {
age: { $gt: 18 },
},
});
const subjects = [{ age: 19 }, { age: 17 }, { age: 12 }];
expect(p.getFiltered(subjects)).toEqual([{ age: 19 }]);
expect(p.meetsFilter({ age: 19 })).toBe(true);
expect(p.meetsFilter({ age: 17 })).toBe(false);
expect(p.meetsFilter({ age: 12 })).toBe(false);
});
it("replaces placeholders", () => {
const p = new Policy({
condition: {
a: "@auth.username",
},
filter: {
a: "@auth.username",
},
});
const vars = { auth: { username: "test" } };
expect(p.meetsCondition({ a: "test" }, vars)).toBe(true);
expect(p.meetsCondition({ a: "test2" }, vars)).toBe(false);
expect(p.meetsCondition({ a: "test2" })).toBe(false);
expect(p.meetsFilter({ a: "test" }, vars)).toBe(true);
expect(p.meetsFilter({ a: "test2" }, vars)).toBe(false);
expect(p.meetsFilter({ a: "test2" })).toBe(false);
});
});
describe("Guard", () => {
it("collects filters", () => {
const p = new Permission(
"test",
{
filterable: true,
},
s.object({
a: s.number(),
}),
);
const r = new Role("test", [
new RolePermission(p, [
new Policy({
condition: { a: { $eq: 1 } },
filter: { foo: "bar" },
effect: "filter",
}),
]),
]);
const guard = new Guard([p], [r], {
enabled: true,
});
expect(guard.filters(p, { role: r.name }, { a: 1 }).filter).toEqual({ foo: "bar" });
expect(guard.filters(p, { role: r.name }, { a: 2 }).filter).toBeUndefined();
// if no user context given, filter cannot be applied
expect(guard.filters(p, {}, { a: 1 }).filter).toBeUndefined();
});
it("collects filters for default role", () => {
const p = new Permission(
"test",
{
filterable: true,
},
s.object({
a: s.number(),
}),
);
const r = new Role(
"test",
[
new RolePermission(p, [
new Policy({
condition: { a: { $eq: 1 } },
filter: { foo: "bar" },
effect: "filter",
}),
]),
],
true,
);
const guard = new Guard([p], [r], {
enabled: true,
});
expect(
guard.filters(
p,
{
role: r.name,
},
{ a: 1 },
).filter,
).toEqual({ foo: "bar" });
expect(
guard.filters(
p,
{
role: r.name,
},
{ a: 2 },
).filter,
).toBeUndefined();
// if no user context given, the default role is applied
// hence it can be found
expect(guard.filters(p, {}, { a: 1 }).filter).toEqual({ foo: "bar" });
});
it("merges filters correctly", () => {
expect(mergeFilters({ foo: "bar" }, { baz: "qux" })).toEqual({
foo: { $eq: "bar" },
baz: { $eq: "qux" },
});
expect(mergeFilters({ foo: "bar" }, { baz: { $eq: "qux" } })).toEqual({
foo: { $eq: "bar" },
baz: { $eq: "qux" },
});
expect(mergeFilters({ foo: "bar" }, { foo: "baz" })).toEqual({ foo: { $eq: "baz" } });
expect(mergeFilters({ foo: "bar" }, { foo: { $lt: 1 } })).toEqual({
foo: { $eq: "bar", $lt: 1 },
});
// overwrite base $or with priority
expect(mergeFilters({ $or: { foo: "one" } }, { foo: "bar" })).toEqual({
$or: {
foo: {
$eq: "bar",
},
},
foo: {
$eq: "bar",
},
});
// ignore base $or if priority has different key
expect(mergeFilters({ $or: { other: "one" } }, { foo: "bar" })).toEqual({
$or: {
other: {
$eq: "one",
},
},
foo: {
$eq: "bar",
},
});
});
});
describe("permission middleware", () => {
const makeApp = (
permissions: Permission<any, any, any, any>[],
roles: Role[] = [],
config: Partial<GuardConfig> = {},
) => {
const app = {
module: {
auth: {
enabled: true,
},
},
modules: {
ctx: () => ({
guard: new Guard(permissions, roles, {
enabled: true,
...config,
}),
}),
},
};
return new Hono()
.use(async (c, next) => {
// @ts-expect-error
c.set("app", app);
await next();
})
.use(auth())
.onError((err, c) => {
if (err instanceof Exception) {
return c.json(err.toJSON(), err.code as any);
}
return c.json({ error: err.message }, "code" in err ? (err.code as any) : 500);
});
};
it("allows if guard is disabled", async () => {
const p = new Permission("test");
const hono = makeApp([p], [], { enabled: false }).get("/test", permission(p, {}), async (c) =>
c.text("test"),
);
const res = await hono.request("/test");
expect(res.status).toBe(200);
expect(await res.text()).toBe("test");
});
it("denies if guard is enabled", async () => {
const p = new Permission("test");
const hono = makeApp([p]).get("/test", permission(p, {}), async (c) => c.text("test"));
const res = await hono.request("/test");
expect(res.status).toBe(403);
});
it("allows if user has (plain) role", async () => {
const p = new Permission("test");
const r = Role.create("test", { permissions: [p.name] });
const hono = makeApp([p], [r])
.use(async (c, next) => {
// @ts-expect-error
c.set("auth", { registered: true, user: { id: 0, role: r.name } });
await next();
})
.get("/test", permission(p, {}), async (c) => c.text("test"));
const res = await hono.request("/test");
expect(res.status).toBe(200);
});
it("allows if user has role with policy", async () => {
const p = new Permission("test");
const r = new Role("test", [
new RolePermission(p, [
new Policy({
condition: {
a: { $gte: 1 },
},
}),
]),
]);
const hono = makeApp([p], [r], {
context: {
a: 1,
},
})
.use(async (c, next) => {
// @ts-expect-error
c.set("auth", { registered: true, user: { id: 0, role: r.name } });
await next();
})
.get("/test", permission(p, {}), async (c) => c.text("test"));
const res = await hono.request("/test");
expect(res.status).toBe(200);
});
it("denies if user with role doesn't meet condition", async () => {
const p = new Permission("test");
const r = new Role("test", [
new RolePermission(
p,
[
new Policy({
condition: {
a: { $lt: 1 },
},
// default effect is allow
}),
],
// change default effect to deny if no condition is met
"deny",
),
]);
const hono = makeApp([p], [r], {
context: {
a: 1,
},
})
.use(async (c, next) => {
// @ts-expect-error
c.set("auth", { registered: true, user: { id: 0, role: r.name } });
await next();
})
.get("/test", permission(p, {}), async (c) => c.text("test"));
const res = await hono.request("/test");
expect(res.status).toBe(403);
});
it("allows if user with role doesn't meet condition (from middleware)", async () => {
const p = new Permission(
"test",
{},
s.object({
a: s.number(),
}),
);
const r = new Role("test", [
new RolePermission(p, [
new Policy({
condition: {
a: { $eq: 1 },
},
}),
]),
]);
const hono = makeApp([p], [r])
.use(async (c, next) => {
// @ts-expect-error
c.set("auth", { registered: true, user: { id: 0, role: r.name } });
await next();
})
.get(
"/test",
permission(p, {
context: (c) => ({
a: 1,
}),
}),
async (c) => c.text("test"),
);
const res = await hono.request("/test");
expect(res.status).toBe(200);
});
it("throws if permission context is invalid", async () => {
const p = new Permission(
"test",
{},
s.object({
a: s.number({ minimum: 2 }),
}),
);
const r = new Role("test", [
new RolePermission(p, [
new Policy({
condition: {
a: { $eq: 1 },
},
}),
]),
]);
const hono = makeApp([p], [r])
.use(async (c, next) => {
// @ts-expect-error
c.set("auth", { registered: true, user: { id: 0, role: r.name } });
await next();
})
.get(
"/test",
permission(p, {
context: (c) => ({
a: 1,
}),
}),
async (c) => c.text("test"),
);
const res = await hono.request("/test");
// expecting 500 because bknd should have handled it correctly
expect(res.status).toBe(500);
});
it("checks context on routes with permissions", async () => {
const make = (user: any) => {
const p = new Permission(
"test",
{},
s.object({
a: s.number(),
}),
);
const r = new Role("test", [
new RolePermission(p, [
new Policy({
condition: {
a: { $eq: 1 },
},
}),
]),
]);
return makeApp([p], [r])
.use(async (c, next) => {
// @ts-expect-error
c.set("auth", { registered: true, user });
await next();
})
.get(
"/valid",
permission(p, {
context: (c) => ({
a: 1,
}),
}),
async (c) => c.text("test"),
)
.get(
"/invalid",
permission(p, {
// @ts-expect-error
context: (c) => ({
b: "1",
}),
}),
async (c) => c.text("test"),
)
.get(
"/invalid2",
permission(p, {
// @ts-expect-error
context: (c) => ({}),
}),
async (c) => c.text("test"),
)
.get(
"/invalid3",
// @ts-expect-error
permission(p),
async (c) => c.text("test"),
);
};
const hono = make({ id: 0, role: "test" });
const valid = await hono.request("/valid");
expect(valid.status).toBe(200);
const invalid = await hono.request("/invalid");
expect(invalid.status).toBe(500);
const invalid2 = await hono.request("/invalid2");
expect(invalid2.status).toBe(500);
const invalid3 = await hono.request("/invalid3");
expect(invalid3.status).toBe(500);
{
const hono = make(null);
const valid = await hono.request("/valid");
expect(valid.status).toBe(403);
const invalid = await hono.request("/invalid");
expect(invalid.status).toBe(500);
const invalid2 = await hono.request("/invalid2");
expect(invalid2.status).toBe(500);
const invalid3 = await hono.request("/invalid3");
expect(invalid3.status).toBe(500);
}
});
});
describe("Role", () => {
it("serializes and deserializes", () => {
const p = new Permission(
"test",
{
filterable: true,
},
s.object({
a: s.number({ minimum: 2 }),
}),
);
const r = new Role(
"test",
[
new RolePermission(p, [
new Policy({
condition: {
a: { $eq: 1 },
},
effect: "deny",
filter: {
b: { $lt: 1 },
},
}),
]),
],
true,
);
const json = JSON.parse(JSON.stringify(r.toJSON()));
const r2 = Role.create(p.name, json);
expect(r2.toJSON()).toEqual(r.toJSON());
});
});
@@ -3,14 +3,16 @@ import { OAuthStrategy } from "../../../src/auth/authenticate/strategies";
const ALL_TESTS = !!process.env.ALL_TESTS; const ALL_TESTS = !!process.env.ALL_TESTS;
// @todo: add mock response
describe("OAuthStrategy", async () => { describe("OAuthStrategy", async () => {
const strategy = new OAuthStrategy({ return;
/*const strategy = new OAuthStrategy({
type: "oidc", type: "oidc",
client: { client: {
client_id: process.env.OAUTH_CLIENT_ID, client_id: process.env.OAUTH_CLIENT_ID!,
client_secret: process.env.OAUTH_CLIENT_SECRET client_secret: process.env.OAUTH_CLIENT_SECRET!,
}, },
name: "google" name: "google",
}); });
const state = "---"; const state = "---";
const redirect_uri = "http://localhost:3000/auth/google/callback"; const redirect_uri = "http://localhost:3000/auth/google/callback";
@@ -19,28 +21,29 @@ describe("OAuthStrategy", async () => {
const config = await strategy.getConfig(); const config = await strategy.getConfig();
console.log("config", JSON.stringify(config, null, 2)); console.log("config", JSON.stringify(config, null, 2));
const request = await strategy.request({
redirect_uri,
state
});
const server = Bun.serve({ const server = Bun.serve({
fetch: async (req) => { fetch: async (req) => {
console.log("req", req.method, req.url);
const url = new URL(req.url); const url = new URL(req.url);
if (url.pathname === "/auth/google/callback") { if (url.pathname === "/auth/google/callback") {
console.log("req", req); console.log("req", req);
const user = await strategy.callback(url, { const user = await strategy.callback(url, {
redirect_uri, redirect_uri,
state state,
}); });
console.log("---user", user); console.log("---user", user);
} }
return new Response("Bun!"); return new Response("Bun!");
} },
});
const request = await strategy.request({
redirect_uri,
state,
}); });
console.log("request", request); console.log("request", request);
await new Promise((resolve) => setTimeout(resolve, 100000)); await new Promise((resolve) => setTimeout(resolve, 100000));
}); });*/
}); });
@@ -0,0 +1,13 @@
import { PasswordStrategy } from "auth/authenticate/strategies/PasswordStrategy";
import { describe, expect, it } from "bun:test";
describe("PasswordStrategy", () => {
it("should enforce provided minimum length", async () => {
const strategy = new PasswordStrategy({ minLength: 8, hashing: "plain" });
expect(strategy.verify("password")({} as any)).rejects.toThrow();
expect(
strategy.verify("password1234")({ strategy_value: "password1234" } as any),
).resolves.toBeUndefined();
});
});
+15 -12
View File
@@ -1,6 +1,6 @@
import { afterAll, beforeAll, describe, expect, mock, test } from "bun:test"; import { afterAll, beforeAll, describe, expect, mock, test } from "bun:test";
import { Event, EventManager, InvalidEventReturn, NoParamEvent } from "../../src/core/events"; import { Event, EventManager, InvalidEventReturn, NoParamEvent } from "../../src/core/events";
import { disableConsoleLog, enableConsoleLog } from "../helper"; import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
beforeAll(disableConsoleLog); beforeAll(disableConsoleLog);
afterAll(enableConsoleLog); afterAll(enableConsoleLog);
@@ -26,7 +26,7 @@ class ReturnEvent extends Event<{ foo: string }, string> {
} }
return this.clone({ return this.clone({
foo: [this.params.foo, value].join("-") foo: [this.params.foo, value].join("-"),
}); });
} }
} }
@@ -52,7 +52,7 @@ describe("EventManager", async () => {
await new Promise((resolve) => setTimeout(resolve, 50)); await new Promise((resolve) => setTimeout(resolve, 50));
delayed(); delayed();
}, },
"sync" "sync",
); );
// don't allow unknown // don't allow unknown
@@ -70,6 +70,9 @@ describe("EventManager", async () => {
new SpecialEvent({ foo: "bar" }); new SpecialEvent({ foo: "bar" });
new InformationalEvent(); new InformationalEvent();
// execute asyncs
await emgr.executeAsyncs();
expect(call).toHaveBeenCalledTimes(2); expect(call).toHaveBeenCalledTimes(2);
expect(delayed).toHaveBeenCalled(); expect(delayed).toHaveBeenCalled();
}); });
@@ -80,15 +83,11 @@ describe("EventManager", async () => {
call(); call();
return Promise.all(p); return Promise.all(p);
}; };
const emgr = new EventManager( const emgr = new EventManager({ InformationalEvent });
{ InformationalEvent },
{
asyncExecutor
}
);
emgr.onEvent(InformationalEvent, async () => {}); emgr.onEvent(InformationalEvent, async () => {});
await emgr.emit(new InformationalEvent()); await emgr.emit(new InformationalEvent());
await emgr.executeAsyncs(asyncExecutor);
expect(call).toHaveBeenCalled(); expect(call).toHaveBeenCalled();
}); });
@@ -98,8 +97,8 @@ describe("EventManager", async () => {
const emgr = new EventManager( const emgr = new EventManager(
{ ReturnEvent, InformationalEvent }, { ReturnEvent, InformationalEvent },
{ {
onInvalidReturn onInvalidReturn,
} },
); );
// @ts-expect-error InformationalEvent has no return value // @ts-expect-error InformationalEvent has no return value
@@ -111,6 +110,7 @@ describe("EventManager", async () => {
emgr.onEvent(ReturnEvent, async () => "1", "sync"); emgr.onEvent(ReturnEvent, async () => "1", "sync");
emgr.onEvent(ReturnEvent, async () => "0", "sync"); emgr.onEvent(ReturnEvent, async () => "0", "sync");
// @todo: fix this
// @ts-expect-error must be string // @ts-expect-error must be string
emgr.onEvent(ReturnEvent, async () => 0, "sync"); emgr.onEvent(ReturnEvent, async () => 0, "sync");
@@ -124,6 +124,9 @@ describe("EventManager", async () => {
const e2 = await emgr.emit(new ReturnEvent({ foo: "bar" })); const e2 = await emgr.emit(new ReturnEvent({ foo: "bar" }));
expect(e2.returned).toBe(true); expect(e2.returned).toBe(true);
expect(e2.params.foo).toBe("bar-1-0"); expect(e2.params.foo).toBe("bar-1-0");
await emgr.executeAsyncs();
expect(onInvalidReturn).toHaveBeenCalled(); expect(onInvalidReturn).toHaveBeenCalled();
expect(asyncEventCallback).toHaveBeenCalled(); expect(asyncEventCallback).toHaveBeenCalled();
}); });
@@ -139,7 +142,7 @@ describe("EventManager", async () => {
expect(slug).toBe("informational-event"); expect(slug).toBe("informational-event");
call(); call();
}, },
{ mode: "sync", once: true } { mode: "sync", once: true },
); );
expect(emgr.getListeners().length).toBe(1); expect(emgr.getListeners().length).toBe(1);
+17 -16
View File
@@ -1,7 +1,6 @@
import { describe, expect, test } from "bun:test"; import { describe, expect, test } from "bun:test";
import type { TObject, TString } from "@sinclair/typebox"; import { Registry } from "core/registry/Registry";
import { Registry } from "../../src/core/registry/Registry"; import { s } from "core/utils/schema";
import { type TSchema, Type } from "../../src/core/utils";
type Constructor<T> = new (...args: any[]) => T; type Constructor<T> = new (...args: any[]) => T;
@@ -12,7 +11,7 @@ class What {
return null; return null;
} }
getType() { getType() {
return Type.Object({ type: Type.String() }); return s.object({ type: s.string() });
} }
} }
class What2 extends What {} class What2 extends What {}
@@ -20,7 +19,7 @@ class NotAllowed {}
type Test1 = { type Test1 = {
cls: new (...args: any[]) => What; cls: new (...args: any[]) => What;
schema: TObject<{ type: TString }>; schema: s.ObjectSchema<{ type: s.StringSchema }>;
enabled: boolean; enabled: boolean;
}; };
@@ -29,40 +28,40 @@ describe("Registry", () => {
const registry = new Registry<Test1>().set({ const registry = new Registry<Test1>().set({
first: { first: {
cls: What, cls: What,
schema: Type.Object({ type: Type.String(), what: Type.String() }), schema: s.object({ type: s.string(), what: s.string() }),
enabled: true enabled: true,
} },
} satisfies Record<string, Test1>); } satisfies Record<string, Test1>);
const item = registry.get("first"); const item = registry.get("first");
expect(item).toBeDefined(); expect(item).toBeDefined();
expect(item?.cls).toBe(What); expect(item?.cls).toBe(What);
const second = Type.Object({ type: Type.String(), what: Type.String() }); const second = s.object({ type: s.string(), what: s.string() });
registry.add("second", { registry.add("second", {
cls: What2, cls: What2,
schema: second, schema: second,
enabled: true enabled: true,
}); });
// @ts-ignore // @ts-ignore
expect(registry.get("second").schema).toEqual(second); expect(registry.get("second").schema).toEqual(second);
const third = Type.Object({ type: Type.String({ default: "1" }), what22: Type.String() }); const third = s.object({ type: s.string({ default: "1" }), what22: s.string() });
registry.add("third", { registry.add("third", {
// @ts-expect-error // @ts-expect-error
cls: NotAllowed, cls: NotAllowed,
schema: third, schema: third,
enabled: true enabled: true,
}); });
// @ts-ignore // @ts-ignore
expect(registry.get("third").schema).toEqual(third); expect(registry.get("third").schema).toEqual(third);
const fourth = Type.Object({ type: Type.Number(), what22: Type.String() }); const fourth = s.object({ type: s.number(), what22: s.string() });
registry.add("fourth", { registry.add("fourth", {
cls: What, cls: What,
// @ts-expect-error // @ts-expect-error
schema: fourth, schema: fourth,
enabled: true enabled: true,
}); });
// @ts-ignore // @ts-ignore
expect(registry.get("fourth").schema).toEqual(fourth); expect(registry.get("fourth").schema).toEqual(fourth);
@@ -75,13 +74,15 @@ describe("Registry", () => {
return { return {
cls: a, cls: a,
schema: a.prototype.getType(), schema: a.prototype.getType(),
enabled: true enabled: true,
}; };
}); });
registry.register("what2", What2); registry.register("what2", What2);
expect(registry.get("what2")).toBeDefined(); expect(registry.get("what2")).toBeDefined();
expect(registry.get("what2").cls).toBe(What2); expect(registry.get("what2").cls).toBe(What2);
expect(registry.get("what2").schema).toEqual(What2.prototype.getType()); expect(JSON.stringify(registry.get("what2").schema)).toEqual(
JSON.stringify(What2.prototype.getType()),
);
}); });
}); });
@@ -1,57 +0,0 @@
import * as assert from "node:assert/strict";
import { createWriteStream } from "node:fs";
import { after, beforeEach, describe, test } from "node:test";
import { Miniflare } from "miniflare";
import {
CloudflareKVCacheItem,
CloudflareKVCachePool
} from "../../../src/core/cache/adapters/CloudflareKvCache";
import { runTests } from "./cache-test-suite";
// https://github.com/nodejs/node/issues/44372#issuecomment-1736530480
console.log = async (message: any) => {
const tty = createWriteStream("/dev/tty");
const msg = typeof message === "string" ? message : JSON.stringify(message, null, 2);
return tty.write(`${msg}\n`);
};
describe("CloudflareKv", async () => {
let mf: Miniflare;
runTests({
createCache: async () => {
if (mf) {
await mf.dispose();
}
mf = new Miniflare({
modules: true,
script: "export default { async fetch() { return new Response(null); } }",
kvNamespaces: ["TEST"]
});
const kv = await mf.getKVNamespace("TEST");
return new CloudflareKVCachePool(kv as any);
},
createItem: (key, value) => new CloudflareKVCacheItem(key, value),
tester: {
test,
beforeEach,
expect: (actual?: any) => {
return {
toBe(expected: any) {
assert.equal(actual, expected);
},
toEqual(expected: any) {
assert.deepEqual(actual, expected);
},
toBeUndefined() {
assert.equal(actual, undefined);
}
};
}
}
});
after(async () => {
await mf?.dispose();
});
});
-15
View File
@@ -1,15 +0,0 @@
import { beforeEach, describe, expect, test } from "bun:test";
import { MemoryCache, MemoryCacheItem } from "../../../src/core/cache/adapters/MemoryCache";
import { runTests } from "./cache-test-suite";
describe("MemoryCache", () => {
runTests({
createCache: async () => new MemoryCache(),
createItem: (key, value) => new MemoryCacheItem(key, value),
tester: {
test,
beforeEach,
expect
}
});
});
-84
View File
@@ -1,84 +0,0 @@
//import { beforeEach as bunBeforeEach, expect as bunExpect, test as bunTest } from "bun:test";
import type { ICacheItem, ICachePool } from "../../../src/core/cache/cache-interface";
export type TestOptions = {
createCache: () => Promise<ICachePool>;
createItem: (key: string, value: any) => ICacheItem;
tester: {
test: (name: string, fn: () => Promise<void>) => void;
beforeEach: (fn: () => Promise<void>) => void;
expect: (actual?: any) => {
toBe(expected: any): void;
toEqual(expected: any): void;
toBeUndefined(): void;
};
};
};
export function runTests({ createCache, createItem, tester }: TestOptions) {
let cache: ICachePool<string>;
const { test, beforeEach, expect } = tester;
beforeEach(async () => {
cache = await createCache();
});
test("getItem returns correct item", async () => {
const item = createItem("key1", "value1");
await cache.save(item);
const retrievedItem = await cache.get("key1");
expect(retrievedItem.value()).toEqual(item.value());
});
test("getItem returns new item when key does not exist", async () => {
const retrievedItem = await cache.get("key1");
expect(retrievedItem.key()).toEqual("key1");
expect(retrievedItem.value()).toBeUndefined();
});
test("getItems returns correct items", async () => {
const item1 = createItem("key1", "value1");
const item2 = createItem("key2", "value2");
await cache.save(item1);
await cache.save(item2);
const retrievedItems = await cache.getMany(["key1", "key2"]);
expect(retrievedItems.get("key1")?.value()).toEqual(item1.value());
expect(retrievedItems.get("key2")?.value()).toEqual(item2.value());
});
test("hasItem returns true when item exists and is a hit", async () => {
const item = createItem("key1", "value1");
await cache.save(item);
expect(await cache.has("key1")).toBe(true);
});
test("clear and deleteItem correctly clear the cache and delete items", async () => {
const item = createItem("key1", "value1");
await cache.save(item);
if (cache.supports().clear) {
await cache.clear();
} else {
await cache.delete("key1");
}
expect(await cache.has("key1")).toBe(false);
});
test("save correctly saves items to the cache", async () => {
const item = createItem("key1", "value1");
await cache.save(item);
expect(await cache.has("key1")).toBe(true);
});
test("putItem correctly puts items in the cache ", async () => {
await cache.put("key1", "value1", { ttl: 60 });
const item = await cache.get("key1");
expect(item.value()).toEqual("value1");
expect(item.hit()).toBe(true);
});
/*test("commit returns true", async () => {
expect(await cache.commit()).toBe(true);
});*/
}
+1 -1
View File
@@ -4,7 +4,7 @@ import { checksum, hash } from "../../src/core/utils";
describe("crypto", async () => { describe("crypto", async () => {
test("sha256", async () => { test("sha256", async () => {
expect(await hash.sha256("test")).toBe( expect(await hash.sha256("test")).toBe(
"9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08" "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08",
); );
}); });
test("sha1", async () => { test("sha1", async () => {
+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();
});
});
+121 -107
View File
@@ -1,15 +1,15 @@
import { describe, expect, test } from "bun:test"; import { describe, expect, test } from "bun:test";
import { SchemaObject } from "../../../src/core"; import { s } from "core/utils/schema";
import { Type } from "../../../src/core/utils"; import { SchemaObject } from "core/object/SchemaObject";
describe("SchemaObject", async () => { describe("SchemaObject", async () => {
test("basic", async () => { test("basic", async () => {
const m = new SchemaObject( const m = new SchemaObject(
Type.Object({ a: Type.String({ default: "b" }) }), s.strictObject({ a: s.string({ default: "b" }) }),
{ a: "test" }, { a: "test" },
{ {
forceParse: true forceParse: true,
} },
); );
expect(m.get()).toEqual({ a: "test" }); expect(m.get()).toEqual({ a: "test" });
@@ -23,28 +23,28 @@ describe("SchemaObject", async () => {
test("patch", async () => { test("patch", async () => {
const m = new SchemaObject( const m = new SchemaObject(
Type.Object({ s.strictObject({
s: Type.Object( s: s.strictObject(
{ {
a: Type.String({ default: "b" }), a: s.string({ default: "b" }),
b: Type.Object( b: s.strictObject(
{ {
c: Type.String({ default: "d" }), c: s.string({ default: "d" }),
e: Type.String({ default: "f" }) e: s.string({ default: "f" }),
}, },
{ default: {} } { default: {} },
) ),
}, },
{ default: {}, additionalProperties: false } { default: {} },
) ),
}) }),
); );
expect(m.get()).toEqual({ s: { a: "b", b: { c: "d", e: "f" } } }); expect(m.get()).toEqual({ s: { a: "b", b: { c: "d", e: "f" } } });
await m.patch("s.a", "c"); await m.patch("s.a", "c");
// non-existing path on no additional properties // non-existing path on no additional properties
expect(() => m.patch("s.s.s", "c")).toThrow(); expect(m.patch("s.s.s", "c")).rejects.toThrow();
// wrong type // wrong type
expect(() => m.patch("s.a", 1)).toThrow(); expect(() => m.patch("s.a", 1)).toThrow();
@@ -58,9 +58,9 @@ describe("SchemaObject", async () => {
test("patch array", async () => { test("patch array", async () => {
const m = new SchemaObject( const m = new SchemaObject(
Type.Object({ s.strictObject({
methods: Type.Array(Type.String(), { default: ["GET", "PATCH"] }) methods: s.array(s.string(), { default: ["GET", "PATCH"] }),
}) }),
); );
expect(m.get()).toEqual({ methods: ["GET", "PATCH"] }); expect(m.get()).toEqual({ methods: ["GET", "PATCH"] });
@@ -75,20 +75,20 @@ describe("SchemaObject", async () => {
test("remove", async () => { test("remove", async () => {
const m = new SchemaObject( const m = new SchemaObject(
Type.Object({ s.object({
s: Type.Object( s: s.object(
{ {
a: Type.String({ default: "b" }), a: s.string({ default: "b" }),
b: Type.Object( b: s.object(
{ {
c: Type.String({ default: "d" }) c: s.string({ default: "d" }),
}, },
{ default: {} } { default: {} },
) ),
}, },
{ default: {} } { default: {} },
) ),
}) }),
); );
expect(m.get()).toEqual({ s: { a: "b", b: { c: "d" } } }); expect(m.get()).toEqual({ s: { a: "b", b: { c: "d" } } });
@@ -107,9 +107,9 @@ describe("SchemaObject", async () => {
test("set", async () => { test("set", async () => {
const m = new SchemaObject( const m = new SchemaObject(
Type.Object({ s.strictObject({
methods: Type.Array(Type.String(), { default: ["GET", "PATCH"] }) methods: s.array(s.string(), { default: ["GET", "PATCH"] }),
}) }),
); );
expect(m.get()).toEqual({ methods: ["GET", "PATCH"] }); expect(m.get()).toEqual({ methods: ["GET", "PATCH"] });
@@ -124,8 +124,8 @@ describe("SchemaObject", async () => {
let called = false; let called = false;
let result: any; let result: any;
const m = new SchemaObject( const m = new SchemaObject(
Type.Object({ s.strictObject({
methods: Type.Array(Type.String(), { default: ["GET", "PATCH"] }) methods: s.array(s.string(), { default: ["GET", "PATCH"] }),
}), }),
undefined, undefined,
{ {
@@ -133,8 +133,8 @@ describe("SchemaObject", async () => {
await new Promise((r) => setTimeout(r, 10)); await new Promise((r) => setTimeout(r, 10));
called = true; called = true;
result = config; result = config;
} },
} },
); );
await m.set({ methods: ["GET", "POST"] }); await m.set({ methods: ["GET", "POST"] });
@@ -145,8 +145,8 @@ describe("SchemaObject", async () => {
test("listener: onBeforeUpdate", async () => { test("listener: onBeforeUpdate", async () => {
let called = false; let called = false;
const m = new SchemaObject( const m = new SchemaObject(
Type.Object({ s.strictObject({
methods: Type.Array(Type.String(), { default: ["GET", "PATCH"] }) methods: s.array(s.string(), { default: ["GET", "PATCH"] }),
}), }),
undefined, undefined,
{ {
@@ -155,8 +155,8 @@ describe("SchemaObject", async () => {
called = true; called = true;
to.methods.push("OPTIONS"); to.methods.push("OPTIONS");
return to; return to;
} },
} },
); );
const result = await m.set({ methods: ["GET", "POST"] }); const result = await m.set({ methods: ["GET", "POST"] });
@@ -167,8 +167,8 @@ describe("SchemaObject", async () => {
}); });
test("throwIfRestricted", async () => { test("throwIfRestricted", async () => {
const m = new SchemaObject(Type.Object({}), undefined, { const m = new SchemaObject(s.strictObject({}), undefined, {
restrictPaths: ["a.b"] restrictPaths: ["a.b"],
}); });
expect(() => m.throwIfRestricted("a.b")).toThrow(); expect(() => m.throwIfRestricted("a.b")).toThrow();
@@ -179,24 +179,24 @@ describe("SchemaObject", async () => {
test("restriction bypass", async () => { test("restriction bypass", async () => {
const m = new SchemaObject( const m = new SchemaObject(
Type.Object({ s.strictObject({
s: Type.Object( s: s.strictObject(
{ {
a: Type.String({ default: "b" }), a: s.string({ default: "b" }),
b: Type.Object( b: s.strictObject(
{ {
c: Type.String({ default: "d" }) c: s.string({ default: "d" }),
}, },
{ default: {} } { default: {} },
) ),
}, },
{ default: {} } { default: {} },
) ),
}), }),
undefined, undefined,
{ {
restrictPaths: ["s.b"] restrictPaths: ["s.b"],
} },
); );
expect(m.patch("s.b.c", "e")).rejects.toThrow(); expect(m.patch("s.b.c", "e")).rejects.toThrow();
@@ -205,7 +205,21 @@ describe("SchemaObject", async () => {
expect(m.get()).toEqual({ s: { a: "b", b: { c: "e" } } }); expect(m.get()).toEqual({ s: { a: "b", b: { c: "e" } } });
}); });
const dataEntitiesSchema = Type.Object( const dataEntitiesSchema = s.strictObject({
entities: s.record(
s.object({
fields: s.record(
s.object({
type: s.string(),
config: s.object({}).optional(),
}),
),
config: s.record(s.string()).optional(),
}),
),
});
/* const dataEntitiesSchema = Type.Object(
{ {
entities: Type.Object( entities: Type.Object(
{}, {},
@@ -217,33 +231,33 @@ describe("SchemaObject", async () => {
additionalProperties: Type.Object({ additionalProperties: Type.Object({
type: Type.String(), type: Type.String(),
config: Type.Optional( 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 () => { test("patch safe object, overwrite", async () => {
const data = { const data = {
entities: { entities: {
some: { some: {
fields: { fields: {
a: { type: "string", config: { some: "thing" } } a: { type: "string", config: { some: "thing" } },
} },
} },
} },
}; };
const m = new SchemaObject(dataEntitiesSchema, data, { const m = new SchemaObject(dataEntitiesSchema, data, {
forceParse: true, forceParse: true,
overwritePaths: [/^entities\..*\.fields\..*\.config/] overwritePaths: [/^entities\..*\.fields\..*\.config/],
}); });
await m.patch("entities.some.fields.a", { type: "string", config: { another: "one" } }); await m.patch("entities.some.fields.a", { type: "string", config: { another: "one" } });
@@ -252,10 +266,10 @@ describe("SchemaObject", async () => {
entities: { entities: {
some: { some: {
fields: { fields: {
a: { type: "string", config: { another: "one" } } a: { type: "string", config: { another: "one" } },
} },
} },
} },
}); });
}); });
@@ -265,22 +279,22 @@ describe("SchemaObject", async () => {
users: { users: {
fields: { fields: {
email: { type: "string" }, email: { type: "string" },
password: { type: "string" } password: { type: "string" },
} },
} },
} },
}; };
const m = new SchemaObject(dataEntitiesSchema, data, { const m = new SchemaObject(dataEntitiesSchema, data, {
forceParse: true, forceParse: true,
overwritePaths: [/^entities\..*\.fields\..*\.config\.html_config$/] overwritePaths: [/^entities\..*\.fields\..*\.config\.html_config$/],
}); });
await m.patch("entities.test", { await m.patch("entities.test", {
fields: { fields: {
content: { content: {
type: "text" type: "text",
} },
} },
}); });
expect(m.get()).toEqual({ expect(m.get()).toEqual({
@@ -288,17 +302,17 @@ describe("SchemaObject", async () => {
users: { users: {
fields: { fields: {
email: { type: "string" }, email: { type: "string" },
password: { type: "string" } password: { type: "string" },
} },
}, },
test: { test: {
fields: { fields: {
content: { content: {
type: "text" type: "text",
} },
} },
} },
} },
}); });
}); });
@@ -308,14 +322,14 @@ describe("SchemaObject", async () => {
users: { users: {
fields: { fields: {
email: { type: "string" }, email: { type: "string" },
password: { type: "string" } password: { type: "string" },
} },
} },
} },
}; };
const m = new SchemaObject(dataEntitiesSchema, data, { const m = new SchemaObject(dataEntitiesSchema, data, {
forceParse: true, 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(); expect(m.patch("desc", "entities.users.config.sort_dir")).rejects.toThrow();
@@ -323,13 +337,13 @@ describe("SchemaObject", async () => {
await m.patch("entities.test", { await m.patch("entities.test", {
fields: { fields: {
content: { content: {
type: "text" type: "text",
} },
} },
}); });
await m.patch("entities.users.config", { await m.patch("entities.users.config", {
sort_dir: "desc" sort_dir: "desc",
}); });
expect(m.get()).toEqual({ expect(m.get()).toEqual({
@@ -337,20 +351,20 @@ describe("SchemaObject", async () => {
users: { users: {
fields: { fields: {
email: { type: "string" }, email: { type: "string" },
password: { type: "string" } password: { type: "string" },
}, },
config: { config: {
sort_dir: "desc" sort_dir: "desc",
} },
}, },
test: { test: {
fields: { fields: {
content: { content: {
type: "text" type: "text",
} },
} },
} },
} },
}); });
}); });
}); });
+44 -44
View File
@@ -13,8 +13,8 @@ describe("diff", () => {
t: "a", t: "a",
p: ["b"], p: ["b"],
o: undefined, o: undefined,
n: 2 n: 2,
} },
]); ]);
const appliedObj = apply(oldObj, diffs); const appliedObj = apply(oldObj, diffs);
@@ -35,8 +35,8 @@ describe("diff", () => {
t: "r", t: "r",
p: ["b"], p: ["b"],
o: 2, o: 2,
n: undefined n: undefined,
} },
]); ]);
const appliedObj = apply(oldObj, diffs); const appliedObj = apply(oldObj, diffs);
@@ -57,8 +57,8 @@ describe("diff", () => {
t: "e", t: "e",
p: ["a"], p: ["a"],
o: 1, o: 1,
n: 2 n: 2,
} },
]); ]);
const appliedObj = apply(oldObj, diffs); const appliedObj = apply(oldObj, diffs);
@@ -79,8 +79,8 @@ describe("diff", () => {
t: "e", t: "e",
p: ["a", "b"], p: ["a", "b"],
o: 1, o: 1,
n: 2 n: 2,
} },
]); ]);
const appliedObj = apply(oldObj, diffs); const appliedObj = apply(oldObj, diffs);
@@ -101,14 +101,14 @@ describe("diff", () => {
t: "e", t: "e",
p: ["a", 1], p: ["a", 1],
o: 2, o: 2,
n: 4 n: 4,
}, },
{ {
t: "a", t: "a",
p: ["a", 3], p: ["a", 3],
o: undefined, o: undefined,
n: 5 n: 5,
} },
]); ]);
const appliedObj = apply(oldObj, diffs); const appliedObj = apply(oldObj, diffs);
@@ -129,20 +129,20 @@ describe("diff", () => {
t: "a", t: "a",
p: ["a", 0], p: ["a", 0],
o: undefined, o: undefined,
n: 1 n: 1,
}, },
{ {
t: "a", t: "a",
p: ["a", 1], p: ["a", 1],
o: undefined, o: undefined,
n: 2 n: 2,
}, },
{ {
t: "a", t: "a",
p: ["a", 2], p: ["a", 2],
o: undefined, o: undefined,
n: 3 n: 3,
} },
]); ]);
const appliedObj = apply(oldObj, diffs); const appliedObj = apply(oldObj, diffs);
@@ -163,14 +163,14 @@ describe("diff", () => {
t: "e", t: "e",
p: ["a", 1], p: ["a", 1],
o: 2, o: 2,
n: 3 n: 3,
}, },
{ {
t: "r", t: "r",
p: ["a", 2], p: ["a", 2],
o: 3, o: 3,
n: undefined n: undefined,
} },
]); ]);
const appliedObj = apply(oldObj, diffs); const appliedObj = apply(oldObj, diffs);
@@ -183,14 +183,14 @@ describe("diff", () => {
it("should handle complex nested changes", () => { it("should handle complex nested changes", () => {
const oldObj = { const oldObj = {
a: { a: {
b: [1, 2, { c: 3 }] b: [1, 2, { c: 3 }],
} },
}; };
const newObj = { const newObj = {
a: { a: {
b: [1, 2, { c: 4 }, 5] b: [1, 2, { c: 4 }, 5],
} },
}; };
const diffs = diff(oldObj, newObj); const diffs = diff(oldObj, newObj);
@@ -200,14 +200,14 @@ describe("diff", () => {
t: "e", t: "e",
p: ["a", "b", 2, "c"], p: ["a", "b", 2, "c"],
o: 3, o: 3,
n: 4 n: 4,
}, },
{ {
t: "a", t: "a",
p: ["a", "b", 3], p: ["a", "b", 3],
o: undefined, o: undefined,
n: 5 n: 5,
} },
]); ]);
const appliedObj = apply(oldObj, diffs); const appliedObj = apply(oldObj, diffs);
@@ -228,14 +228,14 @@ describe("diff", () => {
t: "e", t: "e",
p: ["a"], p: ["a"],
o: undefined, o: undefined,
n: null n: null,
}, },
{ {
t: "e", t: "e",
p: ["b"], p: ["b"],
o: null, o: null,
n: undefined n: undefined,
} },
]); ]);
const appliedObj = apply(oldObj, diffs); const appliedObj = apply(oldObj, diffs);
@@ -256,8 +256,8 @@ describe("diff", () => {
t: "e", t: "e",
p: ["a"], p: ["a"],
o: 1, o: 1,
n: "1" n: "1",
} },
]); ]);
const appliedObj = apply(oldObj, diffs); const appliedObj = apply(oldObj, diffs);
@@ -278,14 +278,14 @@ describe("diff", () => {
t: "r", t: "r",
p: ["b"], p: ["b"],
o: 2, o: 2,
n: undefined n: undefined,
}, },
{ {
t: "a", t: "a",
p: ["c"], p: ["c"],
o: undefined, o: undefined,
n: 3 n: 3,
} },
]); ]);
const appliedObj = apply(oldObj, diffs); const appliedObj = apply(oldObj, diffs);
@@ -306,8 +306,8 @@ describe("diff", () => {
t: "e", t: "e",
p: ["a"], p: ["a"],
o: [1, 2, 3], o: [1, 2, 3],
n: { b: 4 } n: { b: 4 },
} },
]); ]);
const appliedObj = apply(oldObj, diffs); const appliedObj = apply(oldObj, diffs);
@@ -328,8 +328,8 @@ describe("diff", () => {
t: "e", t: "e",
p: ["a"], p: ["a"],
o: { b: 1 }, o: { b: 1 },
n: 2 n: 2,
} },
]); ]);
const appliedObj = apply(oldObj, diffs); const appliedObj = apply(oldObj, diffs);
@@ -350,14 +350,14 @@ describe("diff", () => {
t: "r", t: "r",
p: ["a"], p: ["a"],
o: 1, o: 1,
n: undefined n: undefined,
}, },
{ {
t: "a", t: "a",
p: ["b"], p: ["b"],
o: undefined, o: undefined,
n: 2 n: 2,
} },
]); ]);
const appliedObj = apply(oldObj, diffs); const appliedObj = apply(oldObj, diffs);
@@ -408,8 +408,8 @@ describe("diff", () => {
t: "a", t: "a",
p: ["a"], p: ["a"],
o: undefined, o: undefined,
n: 1 n: 1,
} },
]); ]);
const appliedObj = apply(oldObj, diffs); const appliedObj = apply(oldObj, diffs);
@@ -430,8 +430,8 @@ describe("diff", () => {
t: "r", t: "r",
p: ["a"], p: ["a"],
o: 1, o: 1,
n: undefined n: undefined,
} },
]); ]);
const appliedObj = apply(oldObj, diffs); const appliedObj = apply(oldObj, diffs);
+15 -5
View File
@@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test"; import { describe, expect, test } from "bun:test";
import { type ObjectQuery, convert, validate } from "../../../src/core/object/query/object-query"; import { type ObjectQuery, convert, validate } from "core/object/query/object-query";
describe("object-query", () => { describe("object-query", () => {
const q: ObjectQuery = { name: "Michael" }; const q: ObjectQuery = { name: "Michael" };
@@ -9,7 +9,7 @@ describe("object-query", () => {
test("validates", async () => { test("validates", async () => {
const converted = convert({ const converted = convert({
name: { $eq: "ch" } name: { $eq: "ch" },
}); });
validate(converted, { name: "Michael" }); validate(converted, { name: "Michael" });
}); });
@@ -31,7 +31,7 @@ describe("object-query", () => {
[{ val: { $notnull: 1 } }, { val: null }, false], [{ val: { $notnull: 1 } }, { val: null }, false],
[{ val: { $regex: ".*" } }, { val: "test" }, true], [{ val: { $regex: ".*" } }, { val: "test" }, true],
[{ val: { $regex: /^t.*/ } }, { 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) { for (const [query, object, expected] of tests) {
@@ -55,10 +55,10 @@ describe("object-query", () => {
[ [
{ $or: { val1: { $eq: "foo" }, val2: { $eq: "bar" } } }, { $or: { val1: { $eq: "foo" }, val2: { $eq: "bar" } } },
{ val1: "foo", val2: "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: 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) { for (const [query, object, expected] of tests) {
@@ -66,4 +66,14 @@ describe("object-query", () => {
expect(result).toBe(expected); expect(result).toBe(expected);
} }
}); });
test("paths", () => {
const result = validate({ "user.age": { $lt: 18 } }, { user: { age: 17 } });
expect(result).toBe(true);
});
test("empty filters", () => {
const result = validate({}, { user: { age: 17 } });
expect(result).toBe(true);
});
}); });
+120
View File
@@ -0,0 +1,120 @@
import { describe, expect, test } from "bun:test";
import {
makeValidator,
exp,
Expression,
isPrimitive,
type Primitive,
} from "../../../src/core/object/query/query";
describe("query", () => {
test("isPrimitive", () => {
expect(isPrimitive(1)).toBe(true);
expect(isPrimitive("1")).toBe(true);
expect(isPrimitive(true)).toBe(true);
expect(isPrimitive(false)).toBe(true);
// not primitives
expect(isPrimitive(null)).toBe(false);
expect(isPrimitive(undefined)).toBe(false);
expect(isPrimitive([])).toBe(false);
expect(isPrimitive({})).toBe(false);
expect(isPrimitive(Symbol("test"))).toBe(false);
expect(isPrimitive(new Date())).toBe(false);
expect(isPrimitive(new Error())).toBe(false);
expect(isPrimitive(new Set())).toBe(false);
expect(isPrimitive(new Map())).toBe(false);
});
test("strict expression creation", () => {
// @ts-expect-error
expect(() => exp()).toThrow();
// @ts-expect-error
expect(() => exp("")).toThrow();
// @ts-expect-error
expect(() => exp("invalid")).toThrow();
// @ts-expect-error
expect(() => exp("$eq")).toThrow();
// @ts-expect-error
expect(() => exp("$eq", 1)).toThrow();
// @ts-expect-error
expect(() => exp("$eq", () => null)).toThrow();
// @ts-expect-error
expect(() => exp("$eq", () => null, 1)).toThrow();
expect(
exp(
"$eq",
() => true,
() => null,
),
).toBeInstanceOf(Expression);
});
test("$eq is required", () => {
expect(() => makeValidator([])).toThrow();
expect(() =>
makeValidator([
exp(
"$valid",
() => true,
() => null,
),
]),
).toThrow();
expect(
makeValidator([
exp(
"$eq",
() => true,
() => null,
),
]),
).toBeDefined();
});
test("validates filter structure", () => {
const validator = makeValidator([
exp(
"$eq",
(v: Primitive) => isPrimitive(v),
(e, a) => e === a,
),
exp(
"$like",
(v: string) => typeof v === "string",
(e, a) => e === a,
),
]);
// @ts-expect-error intentionally typed as union of given expression keys
expect(validator.expressionKeys).toEqual(["$eq", "$like"]);
// @ts-expect-error "$and" is not allowed
expect(() => validator.convert({ $and: {} })).toThrow();
// @ts-expect-error "$or" must be an object
expect(() => validator.convert({ $or: [] })).toThrow();
// @ts-expect-error "invalid" is not a valid expression key
expect(() => validator.convert({ foo: { invalid: "bar" } })).toThrow();
// @ts-expect-error "invalid" is not a valid expression key
expect(() => validator.convert({ foo: { $invalid: "bar" } })).toThrow();
// @ts-expect-error "null" is not a valid value
expect(() => validator.convert({ foo: null })).toThrow();
// @ts-expect-error only primitives are allowed for $eq
expect(() => validator.convert({ foo: { $eq: [] } })).toThrow();
// @ts-expect-error only strings are allowed for $like
expect(() => validator.convert({ foo: { $like: 1 } })).toThrow();
// undefined values are ignored
expect(validator.convert({ foo: undefined })).toEqual({});
expect(validator.convert({ foo: "bar" })).toEqual({ foo: { $eq: "bar" } });
expect(validator.convert({ foo: { $eq: "bar" } })).toEqual({ foo: { $eq: "bar" } });
expect(validator.convert({ foo: { $like: "bar" } })).toEqual({ foo: { $like: "bar" } });
});
});
+387 -15
View File
@@ -1,7 +1,7 @@
import { describe, expect, test } from "bun:test"; import { describe, expect, test } from "bun:test";
import { Perf } from "../../src/core/utils"; import { Perf, ucFirst } from "../../src/core/utils";
import * as reqres from "../../src/core/utils/reqres"; import * as utils from "../../src/core/utils";
import * as strings from "../../src/core/utils/strings"; import { assetsPath } from "../helper";
async function wait(ms: number) { async function wait(ms: number) {
return new Promise((resolve) => { return new Promise((resolve) => {
@@ -13,33 +13,33 @@ describe("Core Utils", async () => {
describe("[core] strings", async () => { describe("[core] strings", async () => {
test("objectToKeyValueArray", async () => { test("objectToKeyValueArray", async () => {
const obj = { a: 1, b: 2, c: 3 }; const obj = { a: 1, b: 2, c: 3 };
const result = strings.objectToKeyValueArray(obj); const result = utils.objectToKeyValueArray(obj);
expect(result).toEqual([ expect(result).toEqual([
{ key: "a", value: 1 }, { key: "a", value: 1 },
{ key: "b", value: 2 }, { key: "b", value: 2 },
{ key: "c", value: 3 } { key: "c", value: 3 },
]); ]);
}); });
test("snakeToPascalWithSpaces", async () => { test("snakeToPascalWithSpaces", async () => {
const result = strings.snakeToPascalWithSpaces("snake_to_pascal"); const result = utils.snakeToPascalWithSpaces("snake_to_pascal");
expect(result).toBe("Snake To Pascal"); expect(result).toBe("Snake To Pascal");
}); });
test("randomString", async () => { test("randomString", async () => {
const result = strings.randomString(10); const result = utils.randomString(10);
expect(result).toHaveLength(10); expect(result).toHaveLength(10);
}); });
test("pascalToKebab", async () => { test("pascalToKebab", async () => {
const result = strings.pascalToKebab("PascalCase"); const result = utils.pascalToKebab("PascalCase");
expect(result).toBe("pascal-case"); expect(result).toBe("pascal-case");
}); });
test("replaceSimplePlaceholders", async () => { test("replaceSimplePlaceholders", async () => {
const str = "Hello, {$name}!"; const str = "Hello, {$name}!";
const vars = { name: "John" }; const vars = { name: "John" };
const result = strings.replaceSimplePlaceholders(str, vars); const result = utils.replaceSimplePlaceholders(str, vars);
expect(result).toBe("Hello, John!"); expect(result).toBe("Hello, John!");
}); });
}); });
@@ -49,31 +49,31 @@ describe("Core Utils", async () => {
const headers = new Headers(); const headers = new Headers();
headers.append("Content-Type", "application/json"); headers.append("Content-Type", "application/json");
headers.append("Authorization", "Bearer 123"); headers.append("Authorization", "Bearer 123");
const obj = reqres.headersToObject(headers); const obj = utils.headersToObject(headers);
expect(obj).toEqual({ expect(obj).toEqual({
"content-type": "application/json", "content-type": "application/json",
authorization: "Bearer 123" authorization: "Bearer 123",
}); });
}); });
test("replaceUrlParam", () => { test("replaceUrlParam", () => {
const url = "/api/:id/:name"; const url = "/api/:id/:name";
const params = { id: "123", name: "test" }; const params = { id: "123", name: "test" };
const result = reqres.replaceUrlParam(url, params); const result = utils.replaceUrlParam(url, params);
expect(result).toBe("/api/123/test"); expect(result).toBe("/api/123/test");
}); });
test("encode", () => { test("encode", () => {
const obj = { id: "123", name: "test" }; const obj = { id: "123", name: "test" };
const result = reqres.encodeSearch(obj); const result = utils.encodeSearch(obj);
expect(result).toBe("id=123&name=test"); expect(result).toBe("id=123&name=test");
const obj2 = { id: "123", name: ["test1", "test2"] }; 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"); expect(result2).toBe("id=123&name=test1&name=test2");
const obj3 = { id: "123", name: { test: "test" } }; 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"); expect(result3).toBe("id=123&name=%7B%22test%22%3A%22test%22%7D");
}); });
}); });
@@ -108,4 +108,376 @@ describe("Core Utils", async () => {
expect(count).toBe(2); 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);
}
});
test("recursivelyReplacePlaceholders", () => {
// test basic replacement with simple pattern
const obj1 = { a: "Hello, {$name}!", b: { c: "Hello, {$name}!" } };
const variables1 = { name: "John" };
const result1 = utils.recursivelyReplacePlaceholders(obj1, /\{\$(\w+)\}/g, variables1);
expect(result1).toEqual({ a: "Hello, John!", b: { c: "Hello, John!" } });
// test the specific example from the user request
const obj2 = { some: "value", here: "@auth.user" };
const variables2 = { auth: { user: "what" } };
const result2 = utils.recursivelyReplacePlaceholders(obj2, /^@([a-z\.]+)$/, variables2);
expect(result2).toEqual({ some: "value", here: "what" });
// test with arrays
const obj3 = { items: ["@config.name", "static", "@config.version"] };
const variables3 = { config: { name: "MyApp", version: "1.0.0" } };
const result3 = utils.recursivelyReplacePlaceholders(obj3, /^@([a-z\.]+)$/, variables3);
expect(result3).toEqual({ items: ["MyApp", "static", "1.0.0"] });
// test with nested objects and deep paths
const obj4 = {
user: "@auth.user.name",
settings: {
theme: "@ui.theme",
nested: {
value: "@deep.nested.value",
},
},
};
const variables4 = {
auth: { user: { name: "Alice" } },
ui: { theme: "dark" },
deep: { nested: { value: "found" } },
};
const result4 = utils.recursivelyReplacePlaceholders(obj4, /^@([a-z\.]+)$/, variables4);
expect(result4).toEqual({
user: "Alice",
settings: {
theme: "dark",
nested: {
value: "found",
},
},
});
// test with missing paths (should return original match)
const obj5 = { value: "@missing.path" };
const variables5 = { existing: "value" };
const result5 = utils.recursivelyReplacePlaceholders(obj5, /^@([a-z\.]+)$/, variables5);
expect(result5).toEqual({ value: "@missing.path" });
// test with non-matching strings (should remain unchanged)
const obj6 = { value: "normal string", other: "not@matching" };
const variables6 = { some: "value" };
const result6 = utils.recursivelyReplacePlaceholders(obj6, /^@([a-z\.]+)$/, variables6);
expect(result6).toEqual({ value: "normal string", other: "not@matching" });
// test with primitive values (should handle gracefully)
expect(
utils.recursivelyReplacePlaceholders("@test.value", /^@([a-z\.]+)$/, {
test: { value: "replaced" },
}),
).toBe("replaced");
expect(utils.recursivelyReplacePlaceholders(123, /^@([a-z\.]+)$/, {})).toBe(123);
expect(utils.recursivelyReplacePlaceholders(null, /^@([a-z\.]+)$/, {})).toBe(null);
// test type preservation for full string matches
const variables7 = { test: { value: 123, flag: true, data: null, arr: [1, 2, 3] } };
const result7 = utils.recursivelyReplacePlaceholders(
{
number: "@test.value",
boolean: "@test.flag",
nullValue: "@test.data",
array: "@test.arr",
},
/^@([a-z\.]+)$/,
variables7,
null,
);
expect(result7).toEqual({
number: 123,
boolean: true,
nullValue: null,
array: [1, 2, 3],
});
// test partial string replacement (should convert to string)
const result8 = utils.recursivelyReplacePlaceholders(
{ message: "The value is @test.value!" },
/@([a-z\.]+)/g,
variables7,
);
expect(result8).toEqual({ message: "The value is 123!" });
// test with fallback parameter
const obj9 = { user: "@user.id", config: "@config.theme" };
const variables9 = {}; // empty context
const result9 = utils.recursivelyReplacePlaceholders(
obj9,
/^@([a-z\.]+)$/,
variables9,
null,
);
expect(result9).toEqual({ user: null, config: null });
// test with fallback for partial matches
const obj10 = { message: "Hello @user.name, welcome!" };
const variables10 = {}; // empty context
const result10 = utils.recursivelyReplacePlaceholders(
obj10,
/@([a-z\.]+)/g,
variables10,
"Guest",
);
expect(result10).toEqual({ message: "Hello Guest, welcome!" });
// test with different fallback types
const obj11 = {
stringFallback: "@missing.string",
numberFallback: "@missing.number",
booleanFallback: "@missing.boolean",
objectFallback: "@missing.object",
};
const variables11 = {};
const result11 = utils.recursivelyReplacePlaceholders(
obj11,
/^@([a-z\.]+)$/,
variables11,
"default",
);
expect(result11).toEqual({
stringFallback: "default",
numberFallback: "default",
booleanFallback: "default",
objectFallback: "default",
});
// test fallback with arrays
const obj12 = { items: ["@item1", "@item2", "static"] };
const variables12 = { item1: "found" }; // item2 is missing
const result12 = utils.recursivelyReplacePlaceholders(
obj12,
/^@([a-zA-Z0-9\.]+)$/,
variables12,
"missing",
);
expect(result12).toEqual({ items: ["found", "missing", "static"] });
// test fallback with nested objects
const obj13 = {
user: "@user.id",
settings: {
theme: "@theme.name",
nested: {
value: "@deep.value",
},
},
};
const variables13 = {}; // empty context
const result13 = utils.recursivelyReplacePlaceholders(
obj13,
/^@([a-z\.]+)$/,
variables13,
null,
);
expect(result13).toEqual({
user: null,
settings: {
theme: null,
nested: {
value: null,
},
},
});
});
});
describe("file", async () => {
describe("type 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);
});
test("detectImageDimensions", async () => {
// wrong
// @ts-expect-error
expect(utils.detectImageDimensions(new ArrayBuffer(), "text/plain")).rejects.toThrow();
// successful ones
const getFile = (name: string): File => Bun.file(`${assetsPath}/${name}`) as any;
expect(await utils.detectImageDimensions(getFile("image.png"))).toEqual({
width: 362,
height: 387,
});
expect(await utils.detectImageDimensions(getFile("image.jpg"))).toEqual({
width: 453,
height: 512,
});
});
test("isFileAccepted", () => {
const file = new File([""], "file.txt", {
type: "text/plain",
});
expect(utils.isFileAccepted(file, "text/plain")).toBe(true);
expect(utils.isFileAccepted(file, "text/plain,text/html")).toBe(true);
expect(utils.isFileAccepted(file, "text/html")).toBe(false);
{
const file = new File([""], "file.jpg", {
type: "image/jpeg",
});
expect(utils.isFileAccepted(file, "image/jpeg")).toBe(true);
expect(utils.isFileAccepted(file, "image/jpeg,image/png")).toBe(true);
expect(utils.isFileAccepted(file, "image/png")).toBe(false);
expect(utils.isFileAccepted(file, "image/*")).toBe(true);
expect(utils.isFileAccepted(file, ".jpg")).toBe(true);
expect(utils.isFileAccepted(file, ".jpg,.png")).toBe(true);
expect(utils.isFileAccepted(file, ".png")).toBe(false);
}
{
const file = new File([""], "file.png");
expect(utils.isFileAccepted(file, undefined as any)).toBe(true);
}
expect(() => utils.isFileAccepted(null as any, "text/plain")).toThrow();
});
});
describe("dates", () => {
test("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); */
});
});
}); });
+58 -109
View File
@@ -1,80 +1,32 @@
import { afterAll, beforeAll, describe, expect, test } from "bun:test"; import { afterAll, beforeAll, describe, expect, test } from "bun:test";
import { Guard } from "../../src/auth"; import { Guard } from "../../src/auth/authorize/Guard";
import { parse } from "../../src/core/utils"; import { parse } from "core/utils/schema";
import {
Entity,
type EntityData,
EntityManager,
ManyToOneRelation,
type MutatorResponse,
type RepositoryResponse,
TextField
} from "../../src/data";
import { DataController } from "../../src/data/api/DataController"; import { DataController } from "../../src/data/api/DataController";
import { dataConfigSchema } from "../../src/data/data-schema"; import { dataConfigSchema } from "../../src/data/data-schema";
import { disableConsoleLog, enableConsoleLog, getDummyConnection } from "../helper"; import { getDummyConnection } from "../helper";
import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
import type { RepositoryResultJSON } from "data/entities/query/RepositoryResult";
import type { MutatorResultJSON } from "data/entities/mutation/MutatorResult";
import { Entity, EntityManager, type EntityData } from "data/entities";
import { TextField } from "data/fields";
import { ManyToOneRelation } from "data/relations";
const { dummyConnection, afterAllCleanup } = getDummyConnection(); const { dummyConnection, afterAllCleanup } = getDummyConnection();
beforeAll(() => disableConsoleLog(["log", "warn"])); beforeAll(() => disableConsoleLog());
afterAll(async () => (await afterAllCleanup()) && enableConsoleLog()); afterAll(async () => (await afterAllCleanup()) && enableConsoleLog());
const dataConfig = parse(dataConfigSchema, {}); const dataConfig = parse(dataConfigSchema, {});
describe("[data] DataController", async () => { describe("[data] DataController", async () => {
test("repoResult", async () => {
const em = new EntityManager<any>([], dummyConnection);
const ctx: any = { em, guard: new Guard() };
const controller = new DataController(ctx, dataConfig);
const res = controller.repoResult({
entity: null as any,
data: [] as any,
sql: "",
parameters: [] as any,
result: [] as any,
meta: {
total: 0,
count: 0,
items: 0
}
});
expect(res).toEqual({
meta: {
total: 0,
count: 0,
items: 0
},
data: []
});
});
test("mutatorResult", async () => {
const em = new EntityManager([], dummyConnection);
const ctx: any = { em, guard: new Guard() };
const controller = new DataController(ctx, dataConfig);
const res = controller.mutatorResult({
entity: null as any,
data: [] as any,
sql: "",
parameters: [] as any,
result: [] as any
});
expect(res).toEqual({
data: []
});
});
describe("getController", async () => { describe("getController", async () => {
const users = new Entity("users", [ const users = new Entity("users", [
new TextField("name", { required: true }), new TextField("name", { required: true }),
new TextField("bio") new TextField("bio"),
]); ]);
const posts = new Entity("posts", [new TextField("content")]); const posts = new Entity("posts", [new TextField("content")]);
const em = new EntityManager([users, posts], dummyConnection, [ const em = new EntityManager([users, posts], dummyConnection, [
new ManyToOneRelation(posts, users) new ManyToOneRelation(posts, users),
]); ]);
await em.schema().sync({ force: true }); await em.schema().sync({ force: true });
@@ -83,12 +35,12 @@ describe("[data] DataController", async () => {
users: [ users: [
{ name: "foo", bio: "bar" }, { name: "foo", bio: "bar" },
{ name: "bar", bio: null }, { name: "bar", bio: null },
{ name: "baz", bio: "!!!" } { name: "baz", bio: "!!!" },
], ],
posts: [ posts: [
{ content: "post 1", users_id: 1 }, { 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() }; const ctx: any = { em, guard: new Guard() };
@@ -116,12 +68,11 @@ describe("[data] DataController", async () => {
//console.log("app.routes", app.routes); //console.log("app.routes", app.routes);
// create users // create users
for await (const _user of fixtures.users) { for await (const _user of fixtures.users) {
const res = await app.request("/users", { const res = await app.request("/entity/users", {
method: "POST", method: "POST",
body: JSON.stringify(_user) body: JSON.stringify(_user),
}); });
//console.log("res", { _user }, res); const result = (await res.json()) as MutatorResultJSON;
const result = (await res.json()) as MutatorResponse;
const { id, ...data } = result.data as any; const { id, ...data } = result.data as any;
expect(res.status).toBe(201); expect(res.status).toBe(201);
@@ -131,11 +82,11 @@ describe("[data] DataController", async () => {
// create posts // create posts
for await (const _post of fixtures.posts) { for await (const _post of fixtures.posts) {
const res = await app.request("/posts", { const res = await app.request("/entity/posts", {
method: "POST", method: "POST",
body: JSON.stringify(_post) body: JSON.stringify(_post),
}); });
const result = (await res.json()) as MutatorResponse; const result = (await res.json()) as MutatorResultJSON;
const { id, ...data } = result.data as any; const { id, ...data } = result.data as any;
expect(res.status).toBe(201); expect(res.status).toBe(201);
@@ -145,91 +96,89 @@ describe("[data] DataController", async () => {
}); });
test("/:entity (read many)", 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; const data = (await res.json()) as RepositoryResultJSON;
expect(data.meta.total).toBe(3); //expect(data.meta.total).toBe(3);
expect(data.meta.count).toBe(3); //expect(data.meta.count).toBe(3);
expect(data.meta.items).toBe(3); expect(data.meta.items).toBe(3);
expect(data.data.length).toBe(3); expect(data.data.length).toBe(3);
expect(data.data[0].name).toBe("foo"); expect(data.data[0]?.name).toBe("foo");
}); });
test("/:entity/query (func query)", async () => { test("/:entity/query (func query)", async () => {
const res = await app.request("/users/query", { const res = await app.request("/entity/users/query", {
method: "POST", method: "POST",
headers: { headers: {
"Content-Type": "application/json" "Content-Type": "application/json",
}, },
body: JSON.stringify({ body: JSON.stringify({
where: { bio: { $isnull: 1 } } where: { bio: { $isnull: 1 } },
}) }),
}); });
const data = (await res.json()) as RepositoryResponse; const data = (await res.json()) as RepositoryResultJSON;
expect(data.meta.total).toBe(3); //expect(data.meta.total).toBe(3);
expect(data.meta.count).toBe(1); //expect(data.meta.count).toBe(1);
expect(data.meta.items).toBe(1); expect(data.meta.items).toBe(1);
expect(data.data.length).toBe(1); expect(data.data.length).toBe(1);
expect(data.data[0].name).toBe("bar"); expect(data.data[0]?.name).toBe("bar");
}); });
test("/:entity (read many, paginated)", 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; const data = (await res.json()) as RepositoryResultJSON;
expect(data.meta.total).toBe(3); //expect(data.meta.total).toBe(3);
expect(data.meta.count).toBe(3); //expect(data.meta.count).toBe(3);
expect(data.meta.items).toBe(1); expect(data.meta.items).toBe(1);
expect(data.data.length).toBe(1); expect(data.data.length).toBe(1);
expect(data.data[0].name).toBe("baz"); expect(data.data[0]?.name).toBe("baz");
}); });
test("/:entity/:id (read one)", 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>; const data = (await res.json()) as RepositoryResultJSON<EntityData>;
console.log("data", data);
expect(data.meta.total).toBe(3); //expect(data.meta.total).toBe(3);
expect(data.meta.count).toBe(1); //expect(data.meta.count).toBe(1);
expect(data.meta.items).toBe(1); expect(data.meta.items).toBe(1);
expect(data.data).toEqual({ id: 3, ...fixtures.users[2] }); expect(data.data).toEqual({ id: 3, ...fixtures.users[2] });
}); });
test("/:entity (update one)", async () => { test("/:entity (update one)", async () => {
const res = await app.request("/users/3", { const res = await app.request("/entity/users/3", {
method: "PATCH", method: "PATCH",
body: JSON.stringify({ name: "new name" }) body: JSON.stringify({ name: "new name" }),
}); });
const { data } = (await res.json()) as MutatorResponse; const { data } = (await res.json()) as MutatorResultJSON;
expect(res.ok).toBe(true); expect(res.ok).toBe(true);
expect(data as any).toEqual({ id: 3, ...fixtures.users[2], name: "new name" }); expect(data as any).toEqual({ id: 3, ...fixtures.users[2], name: "new name" });
}); });
test("/:entity/:id/:reference (read references)", 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; const data = (await res.json()) as RepositoryResultJSON;
console.log("data", data);
expect(data.meta.total).toBe(2); //expect(data.meta.total).toBe(2);
expect(data.meta.count).toBe(1); //expect(data.meta.count).toBe(1);
expect(data.meta.items).toBe(1); expect(data.meta.items).toBe(1);
expect(data.data.length).toBe(1); expect(data.data.length).toBe(1);
expect(data.data[0].content).toBe("post 1"); expect(data.data[0]?.content).toBe("post 1");
}); });
test("/:entity/:id (delete one)", async () => { test("/:entity/:id (delete one)", async () => {
const res = await app.request("/posts/2", { const res = await app.request("/entity/posts/2", {
method: "DELETE" method: "DELETE",
}); });
const { data } = (await res.json()) as RepositoryResponse<EntityData>; const { data } = (await res.json()) as RepositoryResultJSON<EntityData>;
expect(data).toEqual({ id: 2, ...fixtures.posts[1] }); expect(data).toEqual({ id: 2, ...fixtures.posts[1] });
// verify // verify
const res2 = await app.request("/posts"); const res2 = await app.request("/entity/posts");
const data2 = (await res2.json()) as RepositoryResponse; const data2 = (await res2.json()) as RepositoryResultJSON;
expect(data2.meta.total).toBe(1); //expect(data2.meta.total).toBe(1);
}); });
}); });
}); });
+28 -27
View File
@@ -1,12 +1,6 @@
import { afterAll, describe, expect, test } from "bun:test"; import { afterAll, describe, expect, test } from "bun:test";
import { import { Entity, EntityManager } from "data/entities";
Entity, import { TextField, PrimaryField, NumberField } from "data/fields";
EntityManager,
NumberField,
PrimaryField,
Repository,
TextField
} from "../../src/data";
import { getDummyConnection } from "./helper"; import { getDummyConnection } from "./helper";
const { dummyConnection, afterAllCleanup } = getDummyConnection(); const { dummyConnection, afterAllCleanup } = getDummyConnection();
@@ -18,14 +12,14 @@ describe("some tests", async () => {
const users = new Entity("users", [ const users = new Entity("users", [
new TextField("username", { required: true, default_value: "nobody" }), new TextField("username", { required: true, default_value: "nobody" }),
new TextField("email", { maxLength: 3 }) new TextField("email", { maxLength: 3 }),
]); ]);
const posts = new Entity("posts", [ const posts = new Entity("posts", [
new TextField("title"), new TextField("title"),
new TextField("content"), new TextField("content"),
new TextField("created_at"), new TextField("created_at"),
new NumberField("likes", { default_value: 0 }) new NumberField("likes", { default_value: 0 }),
]); ]);
const em = new EntityManager([users, posts], connection); const em = new EntityManager([users, posts], connection);
@@ -34,39 +28,32 @@ describe("some tests", async () => {
test("findId", async () => { test("findId", async () => {
const query = await em.repository(users).findId(1); const query = await em.repository(users).findId(1);
/*const { result, total, count, time } = query;
console.log("query", query.result, {
result,
total,
count,
time,
});*/
expect(query.sql).toBe( 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" = ? order by "users"."id" asc limit ? offset ?',
); );
expect(query.parameters).toEqual([1, 1]); expect(query.parameters).toEqual([1, 1, 0]);
expect(query.result).toEqual([]); expect(query.data).toBeUndefined();
}); });
test("findMany", async () => { test("findMany", async () => {
const query = await em.repository(users).findMany(); const query = await em.repository(users).findMany();
expect(query.sql).toBe( 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.parameters).toEqual([10, 0]);
expect(query.result).toEqual([]); expect(query.data).toEqual([]);
}); });
test("findMany with number", async () => { test("findMany with number", async () => {
const query = await em.repository(posts).findMany(); const query = await em.repository(posts).findMany();
expect(query.sql).toBe( 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.parameters).toEqual([10, 0]);
expect(query.result).toEqual([]); expect(query.data).toEqual([]);
}); });
test("try adding an existing field name", async () => { test("try adding an existing field name", async () => {
@@ -74,7 +61,7 @@ describe("some tests", async () => {
new Entity("users", [ new Entity("users", [
new TextField("username"), new TextField("username"),
new TextField("email"), new TextField("email"),
new TextField("email") // not throwing, it's just being ignored new TextField("email"), // not throwing, it's just being ignored
]); ]);
}).toBeDefined(); }).toBeDefined();
@@ -83,7 +70,7 @@ describe("some tests", async () => {
new TextField("username"), new TextField("username"),
new TextField("email"), new TextField("email"),
// field config differs, will throw // field config differs, will throw
new TextField("email", { required: true }) new TextField("email", { required: true }),
]); ]);
}).toThrow(); }).toThrow();
@@ -91,7 +78,7 @@ describe("some tests", async () => {
new Entity("users", [ new Entity("users", [
new PrimaryField(), new PrimaryField(),
new TextField("username"), new TextField("username"),
new TextField("email") new TextField("email"),
]); ]);
}).toBeDefined(); }).toBeDefined();
}); });
@@ -110,4 +97,18 @@ describe("some tests", async () => {
new EntityManager([entity, entity2], connection); new EntityManager([entity, entity2], connection);
}).toThrow(); }).toThrow();
}); });
test("primary uuid", async () => {
const entity = new Entity("users", [
new PrimaryField("id", { format: "uuid" }),
new TextField("username"),
]);
const em = new EntityManager([entity], getDummyConnection().dummyConnection);
await em.schema().sync({ force: true });
const mutator = em.mutator(entity);
const data = await mutator.insertOne({ username: "test" });
expect(data.data.id).toBeDefined();
expect(data.data.id).toBeString();
});
}); });
+3 -3
View File
@@ -2,7 +2,7 @@ import { unlink } from "node:fs/promises";
import type { SqliteDatabase } from "kysely"; import type { SqliteDatabase } from "kysely";
// @ts-ignore // @ts-ignore
import Database from "libsql"; import Database from "libsql";
import { SqliteLocalConnection } from "../../src/data"; import { SqliteLocalConnection } from "data/connection/sqlite/SqliteLocalConnection";
export function getDummyDatabase(memory: boolean = true): { export function getDummyDatabase(memory: boolean = true): {
dummyDb: SqliteDatabase; dummyDb: SqliteDatabase;
@@ -16,7 +16,7 @@ export function getDummyDatabase(memory: boolean = true): {
afterAllCleanup: async () => { afterAllCleanup: async () => {
if (!memory) await unlink(DB_NAME); if (!memory) await unlink(DB_NAME);
return true; return true;
} },
}; };
} }
@@ -26,7 +26,7 @@ export function getDummyConnection(memory: boolean = true) {
return { return {
dummyConnection, dummyConnection,
afterAllCleanup afterAllCleanup,
}; };
} }
+7 -10
View File
@@ -1,13 +1,10 @@
// eslint-disable-next-line import/no-unresolved // eslint-disable-next-line import/no-unresolved
import { afterAll, describe, expect, test } from "bun:test"; import { afterAll, describe, expect, test } from "bun:test";
import { import { Entity } from "data/entities";
Entity, import { EntityManager } from "data/entities/EntityManager";
EntityManager, import { ManyToOneRelation } from "data/relations";
ManyToOneRelation, import { NumberField, TextField } from "data/fields";
NumberField, import { SchemaManager } from "data/schema/SchemaManager";
SchemaManager,
TextField
} from "../../src/data";
import { getDummyConnection } from "./helper"; import { getDummyConnection } from "./helper";
const { dummyConnection, afterAllCleanup } = getDummyConnection(); const { dummyConnection, afterAllCleanup } = getDummyConnection();
@@ -21,7 +18,7 @@ describe("Mutator relation", async () => {
const posts = new Entity("posts", [ const posts = new Entity("posts", [
new TextField("title"), new TextField("title"),
new TextField("content", { default_value: "..." }), 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")]); const users = new Entity("users", [new TextField("username")]);
@@ -44,7 +41,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: 10 })).rejects.toThrow();
expect( expect(
em.mutator(posts).insertOne({ title: "post2", users_id: data.id }) em.mutator(posts).insertOne({ title: "post2", users_id: data.id }),
).resolves.toBeDefined(); ).resolves.toBeDefined();
}); });
}); });
+29 -28
View File
@@ -1,7 +1,8 @@
// eslint-disable-next-line import/no-unresolved // eslint-disable-next-line import/no-unresolved
import { afterAll, describe, expect, test } from "bun:test"; import { afterAll, describe, expect, test } from "bun:test";
import { Entity, EntityManager, Mutator, NumberField, TextField } from "../../src/data"; import { Entity, EntityManager } from "data/entities";
import { TransformPersistFailedException } from "../../src/data/errors"; import { NumberField, TextField } from "data/fields";
import { TransformPersistFailedException } from "data/errors";
import { getDummyConnection } from "./helper"; import { getDummyConnection } from "./helper";
const { dummyConnection, afterAllCleanup } = getDummyConnection(); const { dummyConnection, afterAllCleanup } = getDummyConnection();
@@ -14,7 +15,7 @@ describe("Mutator simple", async () => {
const items = new Entity("items", [ const items = new Entity("items", [
new TextField("label", { required: true, minLength: 1 }), new TextField("label", { required: true, minLength: 1 }),
new NumberField("count", { default_value: 0 }) new NumberField("count", { default_value: 0 }),
]); ]);
const em = new EntityManager<any>([items], connection); const em = new EntityManager<any>([items], connection);
@@ -29,11 +30,11 @@ describe("Mutator simple", async () => {
test("insert single row", async () => { test("insert single row", async () => {
const mutation = await em.mutator(items).insertOne({ const mutation = await em.mutator(items).insertOne({
label: "test", label: "test",
count: 1 count: 1,
}); });
expect(mutation.sql).toBe( 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 }); expect(mutation.data).toEqual({ id: 1, label: "test", count: 1 });
@@ -41,11 +42,11 @@ describe("Mutator simple", async () => {
limit: 1, limit: 1,
sort: { sort: {
by: "id", by: "id",
dir: "desc" dir: "desc",
} },
}); });
expect(query.result).toEqual([{ id: 1, label: "test", count: 1 }]); expect(query.data).toEqual([{ id: 1, label: "test", count: 1 }]);
}); });
test("update inserted row", async () => { test("update inserted row", async () => {
@@ -53,18 +54,18 @@ describe("Mutator simple", async () => {
limit: 1, limit: 1,
sort: { sort: {
by: "id", by: "id",
dir: "desc" dir: "desc",
} },
}); });
const id = query.data![0].id as number; const id = query.data![0].id as number;
const mutation = await em.mutator(items).updateOne(id, { const mutation = await em.mutator(items).updateOne(id, {
label: "new label", label: "new label",
count: 100 count: 100,
}); });
expect(mutation.sql).toBe( 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 }); expect(mutation.data).toEqual({ id, label: "new label", count: 100 });
}); });
@@ -74,27 +75,27 @@ describe("Mutator simple", async () => {
limit: 1, limit: 1,
sort: { sort: {
by: "id", by: "id",
dir: "desc" dir: "desc",
} },
}); });
const id = query.data![0].id as number; const id = query.data![0].id as number;
const mutation = await em.mutator(items).deleteOne(id); const mutation = await em.mutator(items).deleteOne(id);
expect(mutation.sql).toBe( 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 }); expect(mutation.data).toEqual({ id, label: "new label", count: 100 });
const query2 = await em.repository(items).findId(id); const query2 = await em.repository(items).findId(id);
expect(query2.result.length).toBe(0); expect(query2.data).toBeUndefined();
}); });
test("validation: insert incomplete row", async () => { test("validation: insert incomplete row", async () => {
const incompleteCreate = async () => const incompleteCreate = async () =>
await em.mutator(items).insertOne({ await em.mutator(items).insertOne({
//label: "test", //label: "test",
count: 1 count: 1,
}); });
expect(incompleteCreate()).rejects.toThrow(); expect(incompleteCreate()).rejects.toThrow();
@@ -104,7 +105,7 @@ describe("Mutator simple", async () => {
const invalidCreate1 = async () => const invalidCreate1 = async () =>
await em.mutator(items).insertOne({ await em.mutator(items).insertOne({
label: 111, // this should work label: 111, // this should work
count: "1" // this should fail count: "1", // this should fail
}); });
expect(invalidCreate1()).rejects.toThrow(TransformPersistFailedException); expect(invalidCreate1()).rejects.toThrow(TransformPersistFailedException);
@@ -112,7 +113,7 @@ describe("Mutator simple", async () => {
const invalidCreate2 = async () => const invalidCreate2 = async () =>
await em.mutator(items).insertOne({ await em.mutator(items).insertOne({
label: "", // this should fail label: "", // this should fail
count: 1 count: 1,
}); });
expect(invalidCreate2()).rejects.toThrow(TransformPersistFailedException); expect(invalidCreate2()).rejects.toThrow(TransformPersistFailedException);
@@ -137,7 +138,7 @@ describe("Mutator simple", async () => {
expect((await em.repository(items).findMany()).data.length).toBe(data.length - 2); expect((await em.repository(items).findMany()).data.length).toBe(data.length - 2);
//console.log((await em.repository(items).findMany()).data); //console.log((await em.repository(items).findMany()).data);
await em.mutator(items).deleteWhere(); await em.mutator(items).deleteWhere({ id: { $isnull: 0 } });
expect((await em.repository(items).findMany()).data.length).toBe(0); expect((await em.repository(items).findMany()).data.length).toBe(0);
//expect(res.data.count).toBe(0); //expect(res.data.count).toBe(0);
@@ -152,38 +153,38 @@ describe("Mutator simple", async () => {
await em.mutator(items).updateWhere( await em.mutator(items).updateWhere(
{ count: 2 }, { count: 2 },
{ {
count: 10 count: 10,
} },
); );
expect((await em.repository(items).findMany()).data).toEqual([ expect((await em.repository(items).findMany()).data).toEqual([
{ id: 6, label: "update", count: 1 }, { id: 6, label: "update", count: 1 },
{ id: 7, label: "update too", count: 1 }, { id: 7, label: "update too", count: 1 },
{ id: 8, label: "keep", count: 0 } { id: 8, label: "keep", count: 0 },
]); ]);
// expect 2 to be updated // expect 2 to be updated
await em.mutator(items).updateWhere( await em.mutator(items).updateWhere(
{ count: 2 }, { count: 2 },
{ {
count: 1 count: 1,
} },
); );
expect((await em.repository(items).findMany()).data).toEqual([ expect((await em.repository(items).findMany()).data).toEqual([
{ id: 6, label: "update", count: 2 }, { id: 6, label: "update", count: 2 },
{ id: 7, label: "update too", count: 2 }, { id: 7, label: "update too", count: 2 },
{ id: 8, label: "keep", count: 0 } { id: 8, label: "keep", count: 0 },
]); ]);
}); });
test("insertMany", async () => { test("insertMany", async () => {
const oldCount = (await em.repo(items).count()).count; const oldCount = (await em.repo(items).count()).data.count;
const inserts = [{ label: "insert 1" }, { label: "insert 2" }]; const inserts = [{ label: "insert 1" }, { label: "insert 2" }];
const { data } = await em.mutator(items).insertMany(inserts); const { data } = await em.mutator(items).insertMany(inserts);
expect(data.length).toBe(2); expect(data.length).toBe(2);
expect(data.map((d) => ({ label: d.label }))).toEqual(inserts); expect(data.map((d) => ({ label: d.label }))).toEqual(inserts);
const newCount = (await em.repo(items).count()).count; const newCount = (await em.repo(items).count()).data.count;
expect(newCount).toBe(oldCount + inserts.length); expect(newCount).toBe(oldCount + inserts.length);
const { data: data2 } = await em.repo(items).findMany({ offset: oldCount }); const { data: data2 } = await em.repo(items).findMany({ offset: oldCount });
+15 -13
View File
@@ -1,6 +1,8 @@
import { afterAll, expect as bunExpect, describe, test } from "bun:test"; import { afterAll, expect as bunExpect, describe, test } from "bun:test";
import { stripMark } from "../../src/core/utils"; import { stripMark } from "core/utils/schema";
import { Entity, EntityManager, PolymorphicRelation, TextField } from "../../src/data"; import { Entity, EntityManager } from "data/entities";
import { TextField } from "data/fields";
import { PolymorphicRelation } from "data/relations";
import { getDummyConnection } from "./helper"; import { getDummyConnection } from "./helper";
const { dummyConnection, afterAllCleanup } = getDummyConnection(); const { dummyConnection, afterAllCleanup } = getDummyConnection();
@@ -23,23 +25,23 @@ describe("Polymorphic", async () => {
source: "categories", source: "categories",
target: "media", target: "media",
config: { config: {
mappedBy: "image" mappedBy: "image",
} },
}); });
// media should not see categories // media should not see categories
expect(em.relationsOf(media.name).map((r) => r.toJSON())).toEqual([]); expect(em.relationsOf(media.name).map((r) => r.toJSON())).toEqual([]);
// it's important that media cannot access categories // it's important that media cannot access categories
expect(em.relations.targetRelationsOf(categories).map((r) => r.source.entity.name)).toEqual( 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.targetRelationsOf(media).map((r) => r.source.entity.name)).toEqual([]);
expect(em.relations.sourceRelationsOf(categories).map((r) => r.target.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([ 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([]); expect(em.relations.sourceRelationsOf(media).map((r) => r.target.entity.name)).toEqual([]);
@@ -48,7 +50,7 @@ describe("Polymorphic", async () => {
"id", "id",
"path", "path",
"reference", "reference",
"entity_id" "entity_id",
]); ]);
expect(media.getSelect()).toEqual(["id", "path"]); expect(media.getSelect()).toEqual(["id", "path"]);
}); });
@@ -60,7 +62,7 @@ describe("Polymorphic", async () => {
const entities = [media, categories]; const entities = [media, categories];
const single = new PolymorphicRelation(categories, media, { const single = new PolymorphicRelation(categories, media, {
mappedBy: "single", mappedBy: "single",
targetCardinality: 1 targetCardinality: 1,
}); });
const multiple = new PolymorphicRelation(categories, media, { mappedBy: "multiple" }); const multiple = new PolymorphicRelation(categories, media, { mappedBy: "multiple" });
@@ -71,17 +73,17 @@ describe("Polymorphic", async () => {
// it's important that media cannot access categories // it's important that media cannot access categories
expect(em.relations.targetRelationsOf(categories).map((r) => r.source.entity.name)).toEqual( 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.targetRelationsOf(media).map((r) => r.source.entity.name)).toEqual([]);
expect(em.relations.sourceRelationsOf(categories).map((r) => r.target.entity.name)).toEqual([ expect(em.relations.sourceRelationsOf(categories).map((r) => r.target.entity.name)).toEqual([
"media", "media",
"media" "media",
]); ]);
expect(em.relations.sourceRelationsOf(categories).map((r) => r.target.reference)).toEqual([ expect(em.relations.sourceRelationsOf(categories).map((r) => r.target.reference)).toEqual([
"single", "single",
"multiple" "multiple",
]); ]);
expect(em.relations.sourceRelationsOf(media).map((r) => r.target.entity.name)).toEqual([]); expect(em.relations.sourceRelationsOf(media).map((r) => r.target.entity.name)).toEqual([]);
@@ -90,7 +92,7 @@ describe("Polymorphic", async () => {
"id", "id",
"path", "path",
"reference", "reference",
"entity_id" "entity_id",
]); ]);
}); });
}); });
+85
View File
@@ -0,0 +1,85 @@
import { describe, beforeAll, afterAll, test } from "bun:test";
import type { PostgresConnection } from "data/connection/postgres/PostgresConnection";
import { pg, postgresJs } from "bknd";
import { Pool } from "pg";
import postgres from "postgres";
import { disableConsoleLog, enableConsoleLog, $waitUntil } from "bknd/utils";
import { $ } from "bun";
import { connectionTestSuite } from "data/connection/connection-test-suite";
import { bunTestRunner } from "adapter/bun/test";
const credentials = {
host: "localhost",
port: 5430,
user: "postgres",
password: "postgres",
database: "bknd",
};
async function cleanDatabase(connection: InstanceType<typeof PostgresConnection>) {
const kysely = connection.kysely;
// drop all tables+indexes & create new schema
await kysely.schema.dropSchema("public").ifExists().cascade().execute();
await kysely.schema.dropIndex("public").ifExists().cascade().execute();
await kysely.schema.createSchema("public").execute();
}
async function isPostgresRunning() {
try {
// Try to actually connect to PostgreSQL
const conn = pg({ pool: new Pool(credentials) });
await conn.ping();
await conn.close();
return true;
} catch (e) {
return false;
}
}
describe("postgres", () => {
beforeAll(async () => {
if (!(await isPostgresRunning())) {
await $`docker run --rm --name bknd-test-postgres -d -e POSTGRES_PASSWORD=${credentials.password} -e POSTGRES_USER=${credentials.user} -e POSTGRES_DB=${credentials.database} -p ${credentials.port}:5432 postgres:17`;
await $waitUntil("Postgres is running", isPostgresRunning);
await new Promise((resolve) => setTimeout(resolve, 500));
}
disableConsoleLog();
});
afterAll(async () => {
if (await isPostgresRunning()) {
try {
await $`docker stop bknd-test-postgres`;
} catch (e) {}
}
enableConsoleLog();
});
describe.serial.each([
["pg", () => pg({ pool: new Pool(credentials) })],
["postgresjs", () => postgresJs({ postgres: postgres(credentials) })],
])("%s", (name, createConnection) => {
connectionTestSuite(
{
...bunTestRunner,
test: test.serial,
},
{
makeConnection: () => {
const connection = createConnection();
return {
connection,
dispose: async () => {
await cleanDatabase(connection);
await connection.close();
},
};
},
rawDialectDetails: [],
disableConsoleLog: false,
},
);
});
});
+49 -47
View File
@@ -2,19 +2,20 @@ import { describe, expect, test } from "bun:test";
import { import {
BooleanField, BooleanField,
DateField, DateField,
Entity,
EntityIndex,
EntityManager,
EnumField, EnumField,
JsonField, JsonField,
NumberField,
TextField,
EntityIndex,
} from "data/fields";
import { Entity, EntityManager } from "data/entities";
import {
ManyToManyRelation, ManyToManyRelation,
ManyToOneRelation, ManyToOneRelation,
NumberField,
OneToOneRelation, OneToOneRelation,
PolymorphicRelation, PolymorphicRelation,
TextField } from "data/relations";
} from "../../src/data"; import { DummyConnection } from "data/connection/DummyConnection";
import { DummyConnection } from "../../src/data/connection/DummyConnection";
import { import {
FieldPrototype, FieldPrototype,
type FieldSchema, type FieldSchema,
@@ -31,9 +32,9 @@ import {
medium, medium,
number, number,
relation, relation,
text text,
} from "../../src/data/prototype"; } from "data/prototype";
import { MediaField } from "../../src/media/MediaField"; import { MediaField } from "media/MediaField";
describe("prototype", () => { describe("prototype", () => {
test("...", () => { test("...", () => {
@@ -54,7 +55,7 @@ describe("prototype", () => {
name: text(), name: text(),
bio: text(), bio: text(),
age: number(), age: number(),
some: number() some: number(),
}); });
type db = { type db = {
users: Schema<typeof users>; users: Schema<typeof users>;
@@ -70,7 +71,7 @@ describe("prototype", () => {
name: text({ default_value: "hello" }).required(), name: text({ default_value: "hello" }).required(),
bio: text(), bio: text(),
age: number(), age: number(),
some: number().required() some: number().required(),
}); });
const obj: InsertSchema<typeof user> = { name: "yo", some: 1 }; const obj: InsertSchema<typeof user> = { name: "yo", some: 1 };
@@ -83,12 +84,12 @@ describe("prototype", () => {
new TextField("title", { required: true }), new TextField("title", { required: true }),
new TextField("content"), new TextField("content"),
new DateField("created_at", { new DateField("created_at", {
type: "datetime" type: "datetime",
}), }),
// @ts-ignore // @ts-ignore
new MediaField("images", { entity: "posts" }), new MediaField("images", { entity: "posts" }),
// @ts-ignore // @ts-ignore
new MediaField("cover", { entity: "posts", max_items: 1 }) new MediaField("cover", { entity: "posts", max_items: 1 }),
]); ]);
const posts2 = entity("posts", { const posts2 = entity("posts", {
@@ -96,12 +97,13 @@ describe("prototype", () => {
content: text(), content: text(),
created_at: datetime(), created_at: datetime(),
images: media(), images: media(),
cover: medium() cover: medium(),
}); });
type Posts = Schema<typeof posts2>; type Posts = Schema<typeof posts2>;
expect(posts1.toJSON()).toEqual(posts2.toJSON()); // @todo: check
//expect(posts1.toJSON()).toEqual(posts2.toJSON());
}); });
test("test example", async () => { test("test example", async () => {
@@ -117,11 +119,11 @@ describe("prototype", () => {
type: "objects", type: "objects",
values: [ values: [
{ value: "active", label: "Active" }, { value: "active", label: "Active" },
{ value: "inactive", label: "Not active" } { value: "inactive", label: "Not active" },
] ],
} },
}), }),
new JsonField("json") new JsonField("json"),
]); ]);
const test2 = entity("test", { const test2 = entity("test", {
@@ -134,10 +136,10 @@ describe("prototype", () => {
status: enumm<"active" | "inactive">({ status: enumm<"active" | "inactive">({
enum: [ enum: [
{ value: "active", label: "Active" }, { 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()); expect(test.toJSON()).toEqual(test2.toJSON());
@@ -161,12 +163,12 @@ describe("prototype", () => {
// category has single image // category has single image
new PolymorphicRelation(categories, _media, { new PolymorphicRelation(categories, _media, {
mappedBy: "image", mappedBy: "image",
targetCardinality: 1 targetCardinality: 1,
}), }),
// post has multiple images // post has multiple images
new PolymorphicRelation(posts, _media, { mappedBy: "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 = [ const relations2 = [
@@ -180,7 +182,7 @@ describe("prototype", () => {
relation(categories).polyToOne(_media, { mappedBy: "image" }), relation(categories).polyToOne(_media, { mappedBy: "image" }),
relation(posts).polyToMany(_media, { mappedBy: "images" }), 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())); expect(relations.map((r) => r.toJSON())).toEqual(relations2.map((r) => r.toJSON()));
@@ -194,21 +196,21 @@ describe("prototype", () => {
posts, posts,
categories, categories,
{ {
connectionTableMappedName: "custom" connectionTableMappedName: "custom",
}, },
[new TextField("description")] [new TextField("description")],
); );
const fields = { const fields = {
description: text() description: text(),
}; };
let o: FieldSchema<typeof fields>; let o: FieldSchema<typeof fields>;
const rel2 = relation(posts).manyToMany( const rel2 = relation(posts).manyToMany(
categories, categories,
{ {
connectionTableMappedName: "custom" connectionTableMappedName: "custom",
}, },
fields fields,
); );
expect(rel.toJSON()).toEqual(rel2.toJSON()); expect(rel.toJSON()).toEqual(rel2.toJSON());
@@ -216,11 +218,11 @@ describe("prototype", () => {
test("devexample", async () => { test("devexample", async () => {
const users = entity("users", { const users = entity("users", {
username: text() username: text(),
}); });
const comments = entity("comments", { const comments = entity("comments", {
content: text() content: text(),
}); });
const posts = entity("posts", { const posts = entity("posts", {
@@ -228,17 +230,17 @@ describe("prototype", () => {
content: text(), content: text(),
created_at: datetime(), created_at: datetime(),
images: media(), images: media(),
cover: medium() cover: medium(),
}); });
const categories = entity("categories", { const categories = entity("categories", {
name: text(), name: text(),
description: text(), description: text(),
image: medium() image: medium(),
}); });
const settings = entity("settings", { const settings = entity("settings", {
theme: text() theme: text(),
}); });
const test = entity("test", { const test = entity("test", {
@@ -251,10 +253,10 @@ describe("prototype", () => {
status: enumm<"active" | "inactive">({ status: enumm<"active" | "inactive">({
enum: [ enum: [
{ value: "active", label: "Active" }, { 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", {}); const _media = entity("media", {});
@@ -270,7 +272,7 @@ describe("prototype", () => {
relation(users).oneToOne(settings), relation(users).oneToOne(settings),
relation(comments).manyToOne(users, { required: true }), 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; const obj: Schema<typeof test> = {} as any;
@@ -281,12 +283,12 @@ describe("prototype", () => {
{ {
posts: entity("posts", { name: text(), slug: text().required() }), posts: entity("posts", { name: text(), slug: text().required() }),
comments: entity("comments", { some: text() }), comments: entity("comments", { some: text() }),
users: entity("users", { email: text() }) users: entity("users", { email: text() }),
}, },
({ relation, index }, { posts, comments, users }) => { ({ relation, index }, { posts, comments, users }) => {
relation(posts).manyToOne(comments).manyToOne(users); relation(posts).manyToOne(comments).manyToOne(users);
index(posts).on(["name"]).on(["slug"], true); index(posts).on(["name"]).on(["slug"], true);
} },
); );
type LocalDb = (typeof _em)["DB"]; type LocalDb = (typeof _em)["DB"];
@@ -294,16 +296,16 @@ describe("prototype", () => {
const es = [ const es = [
new Entity("posts", [new TextField("name"), new TextField("slug", { required: true })]), new Entity("posts", [new TextField("name"), new TextField("slug", { required: true })]),
new Entity("comments", [new TextField("some")]), new Entity("comments", [new TextField("some")]),
new Entity("users", [new TextField("email")]) new Entity("users", [new TextField("email")]),
]; ] as const;
const _em2 = new EntityManager( const _em2 = new EntityManager(
es, [...es],
new DummyConnection(), new DummyConnection(),
[new ManyToOneRelation(es[0], es[1]), new ManyToOneRelation(es[0], es[2])], [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("name")!]),
new EntityIndex(es[0], [es[0].field("slug")!], true) new EntityIndex(es[0], [es[0].field("slug")!], true),
] ],
); );
// @ts-ignore // @ts-ignore
+33 -32
View File
@@ -1,13 +1,14 @@
// eslint-disable-next-line import/no-unresolved // eslint-disable-next-line import/no-unresolved
import { afterAll, describe, expect, test } from "bun:test"; import { afterAll, describe, expect, test } from "bun:test";
import { Entity, EntityManager, TextField } from "../../src/data"; import { Entity, EntityManager } from "data/entities";
import { TextField } from "data/fields";
import { import {
ManyToManyRelation, ManyToManyRelation,
ManyToOneRelation, ManyToOneRelation,
OneToOneRelation, OneToOneRelation,
PolymorphicRelation, PolymorphicRelation,
RelationField RelationField,
} from "../../src/data/relations"; } from "data/relations";
import { getDummyConnection } from "./helper"; import { getDummyConnection } from "./helper";
const { dummyConnection, afterAllCleanup } = getDummyConnection(); const { dummyConnection, afterAllCleanup } = getDummyConnection();
@@ -22,32 +23,32 @@ describe("Relations", async () => {
const r1 = new RelationField("users_id", { const r1 = new RelationField("users_id", {
reference: "users", reference: "users",
target: "users", target: "users",
target_field: "id" target_field: "id",
}); });
const sql1 = schema const sql1 = schema
.createTable("posts") .createTable("posts")
.addColumn(...r1.schema()!) .addColumn(...em.connection.getFieldSchema(r1.schema())!)
.compile().sql; .compile().sql;
expect(sql1).toBe( 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(new Entity("users"), "author");
const r2 = new RelationField("author_id", { const r2 = new RelationField("author_id", {
reference: "author", reference: "author",
target: "users", target: "users",
target_field: "id" target_field: "id",
}); });
const sql2 = schema const sql2 = schema
.createTable("posts") .createTable("posts")
.addColumn(...r2.schema()!) .addColumn(...em.connection.getFieldSchema(r2.schema())!)
.compile().sql; .compile().sql;
expect(sql2).toBe( 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 +58,7 @@ describe("Relations", async () => {
reference: "users", reference: "users",
target: "users", target: "users",
target_field: "id", target_field: "id",
required: true required: true,
}); });
expect(r1.isRequired()).toBeTrue(); expect(r1.isRequired()).toBeTrue();
}); });
@@ -66,8 +67,8 @@ describe("Relations", async () => {
const users = new Entity("users", [new TextField("username")]); const users = new Entity("users", [new TextField("username")]);
const posts = new Entity("posts", [ const posts = new Entity("posts", [
new TextField("title", { new TextField("title", {
maxLength: 2 maxLength: 2,
}) }),
]); ]);
const entities = [users, posts]; const entities = [users, posts];
@@ -77,7 +78,7 @@ describe("Relations", async () => {
const em = new EntityManager(entities, dummyConnection, relations); const em = new EntityManager(entities, dummyConnection, relations);
// verify naming // verify naming
const rel = em.relations.all[0]; const rel = em.relations.all[0]!;
expect(rel.source.entity.name).toBe(posts.name); expect(rel.source.entity.name).toBe(posts.name);
expect(rel.source.reference).toBe(posts.name); expect(rel.source.reference).toBe(posts.name);
expect(rel.target.entity.name).toBe(users.name); expect(rel.target.entity.name).toBe(users.name);
@@ -89,11 +90,11 @@ describe("Relations", async () => {
// verify low level relation // verify low level relation
expect(em.relationsOf(users.name).length).toBe(1); expect(em.relationsOf(users.name).length).toBe(1);
expect(em.relationsOf(users.name).length).toBe(1); expect(em.relationsOf(users.name).length).toBe(1);
expect(em.relationsOf(users.name)[0].source.entity).toBe(posts); expect(em.relationsOf(users.name)[0]!.source.entity).toBe(posts);
expect(posts.field("author_id")).toBeInstanceOf(RelationField); expect(posts.field("author_id")).toBeInstanceOf(RelationField);
expect(em.relationsOf(users.name).length).toBe(1); expect(em.relationsOf(users.name).length).toBe(1);
expect(em.relationsOf(users.name).length).toBe(1); expect(em.relationsOf(users.name).length).toBe(1);
expect(em.relationsOf(users.name)[0].source.entity).toBe(posts); expect(em.relationsOf(users.name)[0]!.source.entity).toBe(posts);
// verify high level relation (from users) // verify high level relation (from users)
const userPostsRel = em.relationOf(users.name, "posts"); const userPostsRel = em.relationOf(users.name, "posts");
@@ -122,7 +123,7 @@ describe("Relations", async () => {
.selectFrom(users.name) .selectFrom(users.name)
.select((eb) => postAuthorRel.buildWith(users, "posts")(eb).as("posts")); .select((eb) => postAuthorRel.buildWith(users, "posts")(eb).as("posts"));
expect(selectPostsFromUsers.compile().sql).toBe( expect(selectPostsFromUsers.compile().sql).toBe(
'select (select from "posts" as "posts" where "posts"."author_id" = "users"."id") 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); expect(postAuthorRel!.getField()).toBeInstanceOf(RelationField);
const userObj = { id: 1, username: "test" }; const userObj = { id: 1, username: "test" };
@@ -142,7 +143,7 @@ describe("Relations", async () => {
.select((eb) => postAuthorRel.buildWith(posts, "author")(eb).as("author")); .select((eb) => postAuthorRel.buildWith(posts, "author")(eb).as("author"));
expect(selectUsersFromPosts.compile().sql).toBe( expect(selectUsersFromPosts.compile().sql).toBe(
'select (select 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); expect(postAuthorRel.getField()).toBeInstanceOf(RelationField);
const postObj = { id: 1, title: "test" }; const postObj = { id: 1, title: "test" };
@@ -158,7 +159,7 @@ describe("Relations", async () => {
$detach: false, $detach: false,
primary: undefined, primary: undefined,
cardinality: undefined, cardinality: undefined,
relation_type: "n:1" relation_type: "n:1",
}); });
expect(postAuthorRel!.helper(posts.name)!.getMutationInfo()).toEqual({ expect(postAuthorRel!.helper(posts.name)!.getMutationInfo()).toEqual({
@@ -170,7 +171,7 @@ describe("Relations", async () => {
$detach: false, $detach: false,
primary: "id", primary: "id",
cardinality: 1, cardinality: 1,
relation_type: "n:1" relation_type: "n:1",
}); });
/*console.log("ManyToOne (source=posts, target=users)"); /*console.log("ManyToOne (source=posts, target=users)");
@@ -191,7 +192,7 @@ describe("Relations", async () => {
const em = new EntityManager(entities, dummyConnection, relations); const em = new EntityManager(entities, dummyConnection, relations);
// verify naming // verify naming
const rel = em.relations.all[0]; const rel = em.relations.all[0]!;
expect(rel.source.entity.name).toBe(users.name); expect(rel.source.entity.name).toBe(users.name);
expect(rel.source.reference).toBe(users.name); expect(rel.source.reference).toBe(users.name);
expect(rel.target.entity.name).toBe(settings.name); expect(rel.target.entity.name).toBe(settings.name);
@@ -202,8 +203,8 @@ describe("Relations", async () => {
expect(em.relationsOf(users.name).length).toBe(1); expect(em.relationsOf(users.name).length).toBe(1);
expect(em.relationsOf(users.name).length).toBe(1); expect(em.relationsOf(users.name).length).toBe(1);
expect(em.relationsOf(users.name)[0].source.entity).toBe(users); expect(em.relationsOf(users.name)[0]!.source.entity).toBe(users);
expect(em.relationsOf(users.name)[0].target.entity).toBe(settings); expect(em.relationsOf(users.name)[0]!.target.entity).toBe(settings);
// verify high level relation (from users) // verify high level relation (from users)
const userSettingRel = em.relationOf(users.name, settings.name); const userSettingRel = em.relationOf(users.name, settings.name);
@@ -225,7 +226,7 @@ describe("Relations", async () => {
$detach: false, $detach: false,
primary: "id", primary: "id",
cardinality: 1, cardinality: 1,
relation_type: "1:1" relation_type: "1:1",
}); });
expect(userSettingRel!.helper(settings.name)!.getMutationInfo()).toEqual({ expect(userSettingRel!.helper(settings.name)!.getMutationInfo()).toEqual({
reference: "users", reference: "users",
@@ -236,7 +237,7 @@ describe("Relations", async () => {
$detach: false, $detach: false,
primary: undefined, primary: undefined,
cardinality: 1, cardinality: 1,
relation_type: "1:1" relation_type: "1:1",
}); });
/*console.log(""); /*console.log("");
@@ -310,20 +311,20 @@ describe("Relations", async () => {
*/ */
const selectCategoriesFromPosts = kysely const selectCategoriesFromPosts = kysely
.selectFrom(posts.name) .selectFrom(posts.name)
.select((eb) => postCategoriesRel.buildWith(posts)(eb).as("categories")); .select((eb) => postCategoriesRel.buildWith(posts)(eb).select("id").as("categories"));
expect(selectCategoriesFromPosts.compile().sql).toBe( 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 = kysely const selectPostsFromCategories = kysely
.selectFrom(categories.name) .selectFrom(categories.name)
.select((eb) => postCategoriesRel.buildWith(categories)(eb).as("posts")); .select((eb) => postCategoriesRel.buildWith(categories)(eb).select("id").as("posts"));
expect(selectPostsFromCategories.compile().sql).toBe( 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 // mutation info
expect(relations[0].helper(posts.name)!.getMutationInfo()).toEqual({ expect(relations[0]!.helper(posts.name)!.getMutationInfo()).toEqual({
reference: "categories", reference: "categories",
local_field: undefined, local_field: undefined,
$set: false, $set: false,
@@ -332,9 +333,9 @@ describe("Relations", async () => {
$detach: true, $detach: true,
primary: "id", primary: "id",
cardinality: undefined, cardinality: undefined,
relation_type: "m:n" relation_type: "m:n",
}); });
expect(relations[0].helper(categories.name)!.getMutationInfo()).toEqual({ expect(relations[0]!.helper(categories.name)!.getMutationInfo()).toEqual({
reference: "posts", reference: "posts",
local_field: undefined, local_field: undefined,
$set: false, $set: false,
@@ -343,7 +344,7 @@ describe("Relations", async () => {
$detach: false, $detach: false,
primary: undefined, primary: undefined,
cardinality: undefined, cardinality: undefined,
relation_type: "m:n" relation_type: "m:n",
}); });
/*console.log(""); /*console.log("");
+5 -15
View File
@@ -1,12 +1,13 @@
import { describe, expect, test } from "bun:test"; import { describe, expect, test } from "bun:test";
import { Entity, NumberField, TextField } from "../../../src/data"; import { Entity } from "data/entities";
import { NumberField, TextField } from "data/fields";
describe("[data] Entity", async () => { describe("[data] Entity", async () => {
const entity = new Entity("test", [ const entity = new Entity("test", [
new TextField("name", { required: true }), new TextField("name", { required: true }),
new TextField("description"), new TextField("description"),
new NumberField("age", { fillable: false, default_value: 18 }), 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 () => { test("getSelect", async () => {
@@ -17,7 +18,7 @@ describe("[data] Entity", async () => {
expect(entity.getFillableFields().map((f) => f.name)).toEqual([ expect(entity.getFillableFields().map((f) => f.name)).toEqual([
"name", "name",
"description", "description",
"hidden" "hidden",
]); ]);
}); });
@@ -28,7 +29,7 @@ describe("[data] Entity", async () => {
test("getDefaultObject", async () => { test("getDefaultObject", async () => {
expect(entity.getDefaultObject()).toEqual({ expect(entity.getDefaultObject()).toEqual({
age: 18, age: 18,
hidden: "secret" hidden: "secret",
}); });
}); });
@@ -46,15 +47,4 @@ describe("[data] Entity", async () => {
entity.addField(field); entity.addField(field);
expect(entity.getField("new_field")).toBe(field); expect(entity.getField("new_field")).toBe(field);
}); });
// @todo: move this to ClientApp
/*test("serialize and deserialize", async () => {
const json = entity.toJSON();
//sconsole.log("json", json.fields);
const newEntity = Entity.deserialize(json);
//console.log("newEntity", newEntity.toJSON().fields);
expect(newEntity).toBeInstanceOf(Entity);
expect(json).toEqual(newEntity.toJSON());
expect(json.fields).toEqual(newEntity.toJSON().fields);
});*/
}); });
+8 -12
View File
@@ -1,12 +1,8 @@
import { afterAll, describe, expect, test } from "bun:test"; import { afterAll, describe, expect, test } from "bun:test";
import { import { Entity, EntityManager } from "data/entities";
Entity, import { ManyToManyRelation, ManyToOneRelation } from "data/relations";
EntityManager, import { SchemaManager } from "data/schema/SchemaManager";
ManyToManyRelation, import { UnableToConnectException } from "data/errors";
ManyToOneRelation,
SchemaManager
} from "../../../src/data";
import { UnableToConnectException } from "../../../src/data/errors";
import { getDummyConnection } from "../helper"; import { getDummyConnection } from "../helper";
const { dummyConnection, afterAllCleanup } = getDummyConnection(); const { dummyConnection, afterAllCleanup } = getDummyConnection();
@@ -25,7 +21,7 @@ describe("[data] EntityManager", async () => {
expect(await em.ping()).toBe(true); expect(await em.ping()).toBe(true);
expect(() => em.entity("...")).toThrow(); expect(() => em.entity("...")).toThrow();
expect(() => expect(() =>
em.addRelation(new ManyToOneRelation(new Entity("1"), new Entity("2"))) em.addRelation(new ManyToOneRelation(new Entity("1"), new Entity("2"))),
).toThrow(); ).toThrow();
expect(em.schema()).toBeInstanceOf(SchemaManager); expect(em.schema()).toBeInstanceOf(SchemaManager);
@@ -47,8 +43,8 @@ describe("[data] EntityManager", async () => {
em.addRelation(new ManyToOneRelation(posts, users)); em.addRelation(new ManyToOneRelation(posts, users));
expect(em.relations.all.length).toBe(1); expect(em.relations.all.length).toBe(1);
expect(em.relations.all[0]).toBeInstanceOf(ManyToOneRelation); expect(em.relations.all[0]).toBeInstanceOf(ManyToOneRelation);
expect(em.relationsOf("users")).toEqual([em.relations.all[0]]); expect(em.relationsOf("users")).toEqual([em.relations.all[0]!]);
expect(em.relationsOf("posts")).toEqual([em.relations.all[0]]); expect(em.relationsOf("posts")).toEqual([em.relations.all[0]!]);
expect(em.hasRelations("users")).toBe(true); expect(em.hasRelations("users")).toBe(true);
expect(em.hasRelations("posts")).toBe(true); expect(em.hasRelations("posts")).toBe(true);
expect(em.relatedEntitiesOf("users")).toEqual([posts]); expect(em.relatedEntitiesOf("users")).toEqual([posts]);
@@ -98,7 +94,7 @@ describe("[data] EntityManager", async () => {
expect(userTargetRel.map((r) => r.other(users).entity.name)).toEqual(["posts", "comments"]); expect(userTargetRel.map((r) => r.other(users).entity.name)).toEqual(["posts", "comments"]);
expect(postTargetRel.map((r) => r.other(posts).entity.name)).toEqual([ expect(postTargetRel.map((r) => r.other(posts).entity.name)).toEqual([
"comments", "comments",
"categories" "categories",
]); ]);
expect(commentTargetRel.map((r) => r.other(comments).entity.name)).toEqual([]); expect(commentTargetRel.map((r) => r.other(comments).entity.name)).toEqual([]);
expect(categoriesTargetRel.map((r) => r.other(categories).entity.name)).toEqual(["posts"]); expect(categoriesTargetRel.map((r) => r.other(categories).entity.name)).toEqual(["posts"]);
+12 -7
View File
@@ -1,10 +1,15 @@
import { afterAll, describe, expect, test } from "bun:test"; import { afterAll, beforeAll, describe, expect, test } from "bun:test";
import { Entity, EntityManager, ManyToOneRelation, TextField } from "../../../src/data"; import { Entity, EntityManager } from "data/entities";
import { JoinBuilder } from "../../../src/data/entities/query/JoinBuilder"; import { ManyToOneRelation } from "data/relations";
import { TextField } from "data/fields";
import { JoinBuilder } from "data/entities/query/JoinBuilder";
import { getDummyConnection } from "../helper"; import { getDummyConnection } from "../helper";
import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
beforeAll(() => disableConsoleLog());
const { dummyConnection, afterAllCleanup } = getDummyConnection(); const { dummyConnection, afterAllCleanup } = getDummyConnection();
afterAll(afterAllCleanup); afterAll(async () => (await afterAllCleanup()) && enableConsoleLog());
describe("[data] JoinBuilder", async () => { describe("[data] JoinBuilder", async () => {
test("missing relation", async () => { test("missing relation", async () => {
@@ -12,7 +17,7 @@ describe("[data] JoinBuilder", async () => {
const em = new EntityManager([users], dummyConnection); const em = new EntityManager([users], dummyConnection);
expect(() => 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'); ).toThrow('Relation "posts" not found');
}); });
@@ -23,7 +28,7 @@ describe("[data] JoinBuilder", async () => {
const em = new EntityManager([users, posts], dummyConnection, relations); const em = new EntityManager([users, posts], dummyConnection, relations);
const qb = JoinBuilder.addClause(em, em.connection.kysely.selectFrom("users"), users, [ const qb = JoinBuilder.addClause(em, em.connection.kysely.selectFrom("users"), users, [
"posts" "posts",
]); ]);
const res = qb.compile(); const res = qb.compile();
@@ -34,7 +39,7 @@ describe("[data] JoinBuilder", async () => {
);*/ );*/
const qb2 = JoinBuilder.addClause(em, em.connection.kysely.selectFrom("posts"), posts, [ const qb2 = JoinBuilder.addClause(em, em.connection.kysely.selectFrom("posts"), posts, [
"author" "author",
]); ]);
const res2 = qb2.compile(); const res2 = qb2.compile();
+47 -42
View File
@@ -1,28 +1,30 @@
import { afterAll, describe, expect, test } from "bun:test"; import { afterAll, beforeAll, describe, expect, test } from "bun:test";
import type { EventManager } from "../../../src/core/events"; import type { EventManager } from "../../../src/core/events";
import { Entity, EntityManager } from "data/entities";
import { import {
Entity,
EntityManager,
ManyToOneRelation, ManyToOneRelation,
MutatorEvents,
NumberField,
OneToOneRelation, OneToOneRelation,
type RelationField, RelationField,
RelationMutator, RelationMutator,
TextField } from "data/relations";
} from "../../../src/data"; import { NumberField, TextField } from "data/fields";
import * as proto from "../../../src/data/prototype"; import * as proto from "data/prototype";
import { getDummyConnection } from "../helper"; import { getDummyConnection } from "../../helper";
import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
import { MutatorEvents } from "data/events";
const { dummyConnection, afterAllCleanup } = getDummyConnection(); const { dummyConnection, afterAllCleanup } = getDummyConnection();
afterAll(afterAllCleanup); afterAll(afterAllCleanup);
beforeAll(() => disableConsoleLog());
afterAll(async () => (await afterAllCleanup()) && enableConsoleLog());
describe("[data] Mutator (base)", async () => { describe("[data] Mutator (base)", async () => {
const entity = new Entity("items", [ const entity = new Entity("items", [
new TextField("label", { required: true }), new TextField("label", { required: true }),
new NumberField("count"), new NumberField("count"),
new TextField("hidden", { hidden: true }), new TextField("hidden", { hidden: true }),
new TextField("not_fillable", { fillable: false }) new TextField("not_fillable", { fillable: false }),
]); ]);
const em = new EntityManager<any>([entity], dummyConnection); const em = new EntityManager<any>([entity], dummyConnection);
await em.schema().sync({ force: true }); await em.schema().sync({ force: true });
@@ -44,7 +46,7 @@ describe("[data] Mutator (base)", async () => {
test("updateOne", async () => { test("updateOne", async () => {
const { data } = await em.mutator(entity).insertOne(payload); const { data } = await em.mutator(entity).insertOne(payload);
const updated = await em.mutator(entity).updateOne(data.id, { const updated = await em.mutator(entity).updateOne(data.id, {
count: 2 count: 2,
}); });
expect(updated.parameters).toEqual([2, data.id]); expect(updated.parameters).toEqual([2, data.id]);
@@ -77,7 +79,7 @@ describe("[data] Mutator (ManyToOne)", async () => {
// persisting relational field should just return key value to be added // persisting relational field should just return key value to be added
expect( expect(
postRelMutator.persistRelationField(postRelField, "users_id", userData.data.id) postRelMutator.persistRelationField(postRelField, "users_id", userData.data.id),
).resolves.toEqual(["users_id", userData.data.id]); ).resolves.toEqual(["users_id", userData.data.id]);
// persisting invalid value should throw // persisting invalid value should throw
@@ -86,8 +88,8 @@ describe("[data] Mutator (ManyToOne)", async () => {
// persisting reference should ... // persisting reference should ...
expect( expect(
postRelMutator.persistReference(relations[0]!, "users", { postRelMutator.persistReference(relations[0]!, "users", {
$set: { id: userData.data.id } $set: { id: userData.data.id },
}) }),
).resolves.toEqual(["users_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 // @todo: add what methods are allowed to relation, like $create should not be allowed for post<>users
@@ -99,8 +101,8 @@ describe("[data] Mutator (ManyToOne)", async () => {
expect( expect(
em.mutator(posts).insertOne({ em.mutator(posts).insertOne({
title: "post1", title: "post1",
users_id: 100 // user does not exist yet users_id: 100, // user does not exist yet
}) }),
).rejects.toThrow(); ).rejects.toThrow();
}); });
@@ -111,7 +113,7 @@ describe("[data] Mutator (ManyToOne)", async () => {
const em = new EntityManager([items, cats], dummyConnection, relations); const em = new EntityManager([items, cats], dummyConnection, relations);
expect(em.mutator(items).insertOne({ label: "test" })).rejects.toThrow( expect(em.mutator(items).insertOne({ label: "test" })).rejects.toThrow(
'Field "cats_id" is required' 'Field "cats_id" is required',
); );
}); });
@@ -119,14 +121,14 @@ describe("[data] Mutator (ManyToOne)", async () => {
const { data } = await em.mutator(users).insertOne({ username: "user1" }); const { data } = await em.mutator(users).insertOne({ username: "user1" });
const res = await em.mutator(posts).insertOne({ const res = await em.mutator(posts).insertOne({
title: "post1", title: "post1",
users_id: data.id users_id: data.id,
}); });
expect(res.data.users_id).toBe(data.id); expect(res.data.users_id).toBe(data.id);
// setting "null" should be allowed // setting "null" should be allowed
const res2 = await em.mutator(posts).insertOne({ const res2 = await em.mutator(posts).insertOne({
title: "post1", title: "post1",
users_id: null users_id: null,
}); });
expect(res2.data.users_id).toBe(null); expect(res2.data.users_id).toBe(null);
}); });
@@ -135,14 +137,14 @@ describe("[data] Mutator (ManyToOne)", async () => {
const { data } = await em.mutator(users).insertOne({ username: "user1" }); const { data } = await em.mutator(users).insertOne({ username: "user1" });
const res = await em.mutator(posts).insertOne({ const res = await em.mutator(posts).insertOne({
title: "post1", title: "post1",
users: { $set: { id: data.id } } users: { $set: { id: data.id } },
}); });
expect(res.data.users_id).toBe(data.id); expect(res.data.users_id).toBe(data.id);
// setting "null" should be allowed // setting "null" should be allowed
const res2 = await em.mutator(posts).insertOne({ const res2 = await em.mutator(posts).insertOne({
title: "post1", title: "post1",
users: { $set: { id: null } } users: { $set: { id: null } },
}); });
expect(res2.data.users_id).toBe(null); expect(res2.data.users_id).toBe(null);
}); });
@@ -151,8 +153,8 @@ describe("[data] Mutator (ManyToOne)", async () => {
expect( expect(
em.mutator(posts).insertOne({ em.mutator(posts).insertOne({
title: "test", title: "test",
users: { $create: { username: "test" } } users: { $create: { username: "test" } },
}) }),
).rejects.toThrow(); ).rejects.toThrow();
}); });
@@ -162,27 +164,27 @@ describe("[data] Mutator (ManyToOne)", async () => {
const res2 = await em.mutator(posts).insertOne({ title: "post1" }); const res2 = await em.mutator(posts).insertOne({ title: "post1" });
const up1 = await em.mutator(posts).updateOne(res2.data.id, { 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); expect(up1.data.users_id).toBe(res1.data.id);
const up2 = await em.mutator(posts).updateOne(res2.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); expect(up2.data.users_id).toBe(res1_1.data.id);
const up3_1 = await em.mutator(posts).updateOne(res2.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); expect(up3_1.data.users_id).toBe(res1.data.id);
const up3_2 = await em.mutator(posts).updateOne(res2.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); expect(up3_2.data.users_id).toBe(res1_1.data.id);
const up4 = await em.mutator(posts).updateOne(res2.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); expect(up4.data.users_id).toBe(null);
}); });
@@ -199,8 +201,8 @@ describe("[data] Mutator (OneToOne)", async () => {
expect( expect(
em.mutator(users).insertOne({ em.mutator(users).insertOne({
username: "test", 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(); ).rejects.toThrow();
}); });
@@ -210,15 +212,15 @@ describe("[data] Mutator (OneToOne)", async () => {
expect( expect(
em.mutator(users).insertOne({ em.mutator(users).insertOne({
username: "test", username: "test",
settings: { $set: { id: data.id } } settings: { $set: { id: data.id } },
}) }),
).rejects.toThrow(); ).rejects.toThrow();
}); });
test("insertOne: using $create", async () => { test("insertOne: using $create", async () => {
const res = await em.mutator(users).insertOne({ const res = await em.mutator(users).insertOne({
username: "test", username: "test",
settings: { $create: { theme: "dark" } } settings: { $create: { theme: "dark" } },
}); });
expect(res.data.settings_id).toBeDefined(); expect(res.data.settings_id).toBeDefined();
}); });
@@ -288,14 +290,17 @@ describe("[data] Mutator (Events)", async () => {
test("events were fired", async () => { test("events were fired", async () => {
const { data } = await mutator.insertOne({ label: "test" }); const { data } = await mutator.insertOne({ label: "test" });
await mutator.emgr.executeAsyncs();
expect(events.has(MutatorEvents.MutatorInsertBefore.slug)).toBeTrue(); expect(events.has(MutatorEvents.MutatorInsertBefore.slug)).toBeTrue();
expect(events.has(MutatorEvents.MutatorInsertAfter.slug)).toBeTrue(); expect(events.has(MutatorEvents.MutatorInsertAfter.slug)).toBeTrue();
await mutator.updateOne(data.id, { label: "test2" }); await mutator.updateOne(data.id, { label: "test2" });
await mutator.emgr.executeAsyncs();
expect(events.has(MutatorEvents.MutatorUpdateBefore.slug)).toBeTrue(); expect(events.has(MutatorEvents.MutatorUpdateBefore.slug)).toBeTrue();
expect(events.has(MutatorEvents.MutatorUpdateAfter.slug)).toBeTrue(); expect(events.has(MutatorEvents.MutatorUpdateAfter.slug)).toBeTrue();
await mutator.deleteOne(data.id); await mutator.deleteOne(data.id);
await mutator.emgr.executeAsyncs();
expect(events.has(MutatorEvents.MutatorDeleteBefore.slug)).toBeTrue(); expect(events.has(MutatorEvents.MutatorDeleteBefore.slug)).toBeTrue();
expect(events.has(MutatorEvents.MutatorDeleteAfter.slug)).toBeTrue(); expect(events.has(MutatorEvents.MutatorDeleteAfter.slug)).toBeTrue();
}); });
@@ -303,7 +308,7 @@ describe("[data] Mutator (Events)", async () => {
test("insertOne event return is respected", async () => { test("insertOne event return is respected", async () => {
const posts = proto.entity("posts", { const posts = proto.entity("posts", {
title: proto.text(), title: proto.text(),
views: proto.number() views: proto.number(),
}); });
const conn = getDummyConnection(); const conn = getDummyConnection();
@@ -318,10 +323,10 @@ describe("[data] Mutator (Events)", async () => {
async (event) => { async (event) => {
return { return {
...event.params.data, ...event.params.data,
views: 2 views: 2,
}; };
}, },
"sync" "sync",
); );
const mutator = em.mutator("posts"); const mutator = em.mutator("posts");
@@ -329,14 +334,14 @@ describe("[data] Mutator (Events)", async () => {
expect(result.data).toEqual({ expect(result.data).toEqual({
id: 1, id: 1,
title: "test", title: "test",
views: 2 views: 2,
}); });
}); });
test("updateOne event return is respected", async () => { test("updateOne event return is respected", async () => {
const posts = proto.entity("posts", { const posts = proto.entity("posts", {
title: proto.text(), title: proto.text(),
views: proto.number() views: proto.number(),
}); });
const conn = getDummyConnection(); const conn = getDummyConnection();
@@ -351,10 +356,10 @@ describe("[data] Mutator (Events)", async () => {
async (event) => { async (event) => {
return { return {
...event.params.data, ...event.params.data,
views: event.params.data.views + 1 views: event.params.data.views + 1,
}; };
}, },
"sync" "sync",
); );
const mutator = em.mutator("posts"); const mutator = em.mutator("posts");
@@ -363,7 +368,7 @@ describe("[data] Mutator (Events)", async () => {
expect(result.data).toEqual({ expect(result.data).toEqual({
id: 1, id: 1,
title: "test", title: "test",
views: 3 views: 3,
}); });
}); });
}); });
+170 -136
View File
@@ -1,20 +1,18 @@
import { afterAll, describe, expect, test } from "bun:test"; import { afterAll, beforeAll, describe, expect, test } from "bun:test";
import type { Kysely, Transaction } from "kysely"; import type { Kysely, Transaction } from "kysely";
import { Perf } from "../../../src/core/utils"; import { TextField } from "data/fields";
import { import { em as $em, entity as $entity, text as $text } from "data/prototype";
Entity, import { Entity, EntityManager } from "data/entities";
EntityManager, import { ManyToOneRelation } from "data/relations";
LibsqlConnection, import { RepositoryEvents } from "data/events";
ManyToOneRelation,
RepositoryEvents,
TextField
} from "../../../src/data";
import { getDummyConnection } from "../helper"; import { getDummyConnection } from "../helper";
import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
type E = Kysely<any> | Transaction<any>; type E = Kysely<any> | Transaction<any>;
const { dummyConnection, afterAllCleanup } = getDummyConnection(); const { dummyConnection, afterAllCleanup } = getDummyConnection();
afterAll(afterAllCleanup); beforeAll(() => disableConsoleLog());
afterAll(async () => (await afterAllCleanup()) && enableConsoleLog());
async function sleep(ms: number) { async function sleep(ms: number) {
return new Promise((resolve) => { return new Promise((resolve) => {
@@ -23,120 +21,6 @@ async function sleep(ms: number) {
} }
describe("[Repository]", async () => { describe("[Repository]", async () => {
test.skip("bulk", async () => {
//const connection = dummyConnection;
//const connection = getLocalLibsqlConnection();
const credentials = null as any; // @todo: determine what to do here
const connection = new LibsqlConnection(credentials);
const em = new EntityManager([], connection);
/*const emLibsql = new EntityManager([], {
url: connection.url.replace("https", "libsql"),
authToken: connection.authToken,
});*/
const table = "posts";
const client = connection.getClient();
if (!client) {
console.log("Cannot perform test without libsql connection");
return;
}
const conn = em.connection.kysely;
const selectQ = (e: E) => e.selectFrom(table).selectAll().limit(2);
const countQ = (e: E) => e.selectFrom(table).select(e.fn.count("*").as("count"));
async function executeTransaction(em: EntityManager<any>) {
return await em.connection.kysely.transaction().execute(async (e) => {
const res = await selectQ(e).execute();
const count = await countQ(e).execute();
return [res, count];
});
}
async function executeBatch(em: EntityManager<any>) {
const queries = [selectQ(conn), countQ(conn)];
return await em.connection.batchQuery(queries);
}
async function executeSingleKysely(em: EntityManager<any>) {
const res = await selectQ(conn).execute();
const count = await countQ(conn).execute();
return [res, count];
}
async function executeSingleClient(em: EntityManager<any>) {
const q1 = selectQ(conn).compile();
const res = await client.execute({
sql: q1.sql,
args: q1.parameters as any
});
const q2 = countQ(conn).compile();
const count = await client.execute({
sql: q2.sql,
args: q2.parameters as any
});
return [res, count];
}
const transaction = await executeTransaction(em);
const batch = await executeBatch(em);
expect(batch).toEqual(transaction as any);
const testperf = false;
if (testperf) {
const times = 5;
const exec = async (
name: string,
fn: (em: EntityManager<any>) => Promise<any>,
em: EntityManager<any>
) => {
const res = await Perf.execute(() => fn(em), times);
await sleep(1000);
const info = {
name,
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)
};
console.log(info.name, info, res.marks);
return info;
};
const data: any[] = [];
data.push(await exec("transaction.http", executeTransaction, em));
data.push(await exec("bulk.http", executeBatch, em));
data.push(await exec("singleKy.http", executeSingleKysely, em));
data.push(await exec("singleCl.http", executeSingleClient, em));
/*data.push(await exec("transaction.libsql", executeTransaction, emLibsql));
data.push(await exec("bulk.libsql", executeBatch, emLibsql));
data.push(await exec("singleKy.libsql", executeSingleKysely, emLibsql));
data.push(await exec("singleCl.libsql", executeSingleClient, emLibsql));*/
console.table(data);
/**
*
* name total avg first last
*
* 0 transaction.http 681.29 136.26 136.46 396.09
* 1 bulk.http 164.82 32.96 32.95 99.91
* 2 singleKy.http 330.01 66.00 65.86 195.41
* 3 singleCl.http 326.17 65.23 61.32 198.08
* 4 transaction.libsql 856.79 171.36 132.31 595.24
* 5 bulk.libsql 180.63 36.13 35.39 107.71
* 6 singleKy.libsql 347.11 69.42 65.00 207.14
* 7 singleCl.libsql 328.60 65.72 62.19 195.04
*
*/
}
});
test("count & exists", async () => { test("count & exists", async () => {
const items = new Entity("items", [new TextField("label")]); const items = new Entity("items", [new TextField("label")]);
const em = new EntityManager([items], dummyConnection); const em = new EntityManager([items], dummyConnection);
@@ -157,25 +41,163 @@ describe("[Repository]", async () => {
// count all // count all
const res = await em.repository(items).count(); const res = await em.repository(items).count();
expect(res.sql).toBe('select count(*) as "count" from "items"'); expect(res.sql).toBe('select count(*) as "count" from "items"');
expect(res.count).toBe(3); expect(res.data.count).toBe(3);
//
{
const res = await em.repository(items).findMany();
expect(res.count).toBe(3);
}
{
const res = await em
.repository(items, {
includeCounts: true,
})
.findMany();
expect(res.count).toBe(3);
}
// count filtered // count filtered
const res2 = await em.repository(items).count({ label: { $in: ["a", "b"] } }); const res2 = await em
.repository(items, {
includeCounts: true,
})
.count({ label: { $in: ["a", "b"] } });
expect(res2.sql).toBe('select count(*) as "count" from "items" where "label" in (?, ?)'); expect(res2.sql).toBe('select count(*) as "count" from "items" where "label" in (?, ?)');
expect(res2.parameters).toEqual(["a", "b"]); expect(res2.parameters).toEqual(["a", "b"]);
expect(res2.count).toBe(2); expect(res2.data.count).toBe(2);
// check exists // check exists
const res3 = await em.repository(items).exists({ label: "a" }); const res3 = await em.repository(items).exists({ label: "a" });
expect(res3.exists).toBe(true); expect(res3.data.exists).toBe(true);
const res4 = await em.repository(items).exists({ label: "d" }); const res4 = await em.repository(items).exists({ label: "d" });
expect(res4.exists).toBe(false); expect(res4.data.exists).toBe(false);
// for now, allow empty filter // for now, allow empty filter
const res5 = await em.repository(items).exists({}); const res5 = await em.repository(items).exists({});
expect(res5.exists).toBe(true); expect(res5.data.exists).toBe(true);
});
test("option: silent", async () => {
const em = $em({
items: $entity("items", {
label: $text(),
}),
}).proto.withConnection(getDummyConnection().dummyConnection);
// should throw because table doesn't exist
expect(em.repo("items").findMany({})).rejects.toThrow(/no such table/);
// should silently return empty result
em.repo("items", { silent: true })
.findMany({})
.then((r) => r.data);
expect(
em
.repo("items", { silent: true })
.findMany({})
.then((r) => r.data),
).resolves.toEqual([]);
});
test("option: includeCounts", async () => {
const em = $em({
items: $entity("items", {
label: $text(),
}),
}).proto.withConnection(getDummyConnection().dummyConnection);
await em.schema().sync({ force: true });
expect(
em
.repo("items", { includeCounts: true })
.findMany({})
.then((r) => [r.count, r.total]),
).resolves.toEqual([0, 0]);
expect(
em
.repo("items", { includeCounts: false })
.findMany({})
.then((r) => [r.count, r.total]),
).resolves.toEqual([undefined, undefined]);
});
test("auto join", async () => {
const schema = $em(
{
posts: $entity("posts", {
title: $text(),
content: $text(),
}),
comments: $entity("comments", {
content: $text(),
}),
another: $entity("another", {
title: $text(),
}),
},
({ relation }, { posts, comments }) => {
relation(comments).manyToOne(posts);
},
);
const em = schema.proto.withConnection(getDummyConnection().dummyConnection);
await em.schema().sync({ force: true });
await em.mutator("posts").insertOne({ title: "post1", content: "content1" });
await em
.mutator("comments")
.insertMany([{ content: "comment1", posts_id: 1 }, { content: "comment2" }] as any);
const res = await em.repo("comments").findMany({
where: {
"posts.title": "post1",
},
});
expect(res.data as any).toEqual([
{
id: 1,
content: "comment1",
posts_id: 1,
},
]);
{
// manual join should still work
const res = await em.repo("comments").findMany({
join: ["posts"],
where: {
"posts.title": "post1",
},
});
expect(res.data as any).toEqual([
{
id: 1,
content: "comment1",
posts_id: 1,
},
]);
}
// inexistent should be detected and thrown
expect(
em.repo("comments").findMany({
where: {
"random.title": "post1",
},
}),
).rejects.toThrow(/Invalid where field/);
// existing alias, but not a relation should throw
expect(
em.repo("comments").findMany({
where: {
"another.title": "post1",
},
}),
).rejects.toThrow(/Invalid where field/);
}); });
}); });
@@ -183,7 +205,7 @@ describe("[data] Repository (Events)", async () => {
const items = new Entity("items", [new TextField("label")]); const items = new Entity("items", [new TextField("label")]);
const categories = new Entity("categories", [new TextField("label")]); const categories = new Entity("categories", [new TextField("label")]);
const em = new EntityManager([items, categories], dummyConnection, [ const em = new EntityManager([items, categories], dummyConnection, [
new ManyToOneRelation(categories, items) new ManyToOneRelation(categories, items),
]); ]);
await em.schema().sync({ force: true }); await em.schema().sync({ force: true });
const events = new Map<string, any>(); const events = new Map<string, any>();
@@ -198,24 +220,36 @@ describe("[data] Repository (Events)", async () => {
}); });
test("events were fired", async () => { test("events were fired", async () => {
await em.repository(items).findId(1); const repo = em.repository(items);
await repo.findId(1);
await repo.emgr.executeAsyncs();
expect(events.has(RepositoryEvents.RepositoryFindOneBefore.slug)).toBeTrue(); expect(events.has(RepositoryEvents.RepositoryFindOneBefore.slug)).toBeTrue();
expect(events.has(RepositoryEvents.RepositoryFindOneAfter.slug)).toBeTrue(); expect(events.has(RepositoryEvents.RepositoryFindOneAfter.slug)).toBeTrue();
events.clear(); events.clear();
await em.repository(items).findOne({ id: 1 }); await repo.findOne({ id: 1 });
await repo.emgr.executeAsyncs();
expect(events.has(RepositoryEvents.RepositoryFindOneBefore.slug)).toBeTrue(); expect(events.has(RepositoryEvents.RepositoryFindOneBefore.slug)).toBeTrue();
expect(events.has(RepositoryEvents.RepositoryFindOneAfter.slug)).toBeTrue(); expect(events.has(RepositoryEvents.RepositoryFindOneAfter.slug)).toBeTrue();
events.clear(); events.clear();
await em.repository(items).findMany({ where: { id: 1 } }); await repo.findMany({ where: { id: 1 } });
await repo.emgr.executeAsyncs();
expect(events.has(RepositoryEvents.RepositoryFindManyBefore.slug)).toBeTrue(); expect(events.has(RepositoryEvents.RepositoryFindManyBefore.slug)).toBeTrue();
expect(events.has(RepositoryEvents.RepositoryFindManyAfter.slug)).toBeTrue(); expect(events.has(RepositoryEvents.RepositoryFindManyAfter.slug)).toBeTrue();
events.clear(); events.clear();
await em.repository(items).findManyByReference(1, "categories"); await repo.findManyByReference(1, "categories");
await repo.emgr.executeAsyncs();
expect(events.has(RepositoryEvents.RepositoryFindManyBefore.slug)).toBeTrue(); expect(events.has(RepositoryEvents.RepositoryFindManyBefore.slug)).toBeTrue();
expect(events.has(RepositoryEvents.RepositoryFindManyAfter.slug)).toBeTrue(); expect(events.has(RepositoryEvents.RepositoryFindManyAfter.slug)).toBeTrue();
events.clear(); events.clear();
// check find one on findMany with limit 1
await repo.findMany({ where: { id: 1 }, limit: 1 });
await repo.emgr.executeAsyncs();
expect(events.has(RepositoryEvents.RepositoryFindOneBefore.slug)).toBeTrue();
expect(events.has(RepositoryEvents.RepositoryFindOneAfter.slug)).toBeTrue();
events.clear();
}); });
}); });
+73 -36
View File
@@ -1,7 +1,9 @@
// eslint-disable-next-line import/no-unresolved // eslint-disable-next-line import/no-unresolved
import { afterAll, describe, expect, test } from "bun:test"; import { afterAll, describe, expect, spyOn, test } from "bun:test";
import { randomString } from "../../../src/core/utils"; import { randomString } from "core/utils";
import { Entity, EntityIndex, EntityManager, SchemaManager, TextField } from "../../../src/data"; import { Entity, EntityManager } from "data/entities";
import { TextField, EntityIndex } from "data/fields";
import { SchemaManager } from "data/schema/SchemaManager";
import { getDummyConnection } from "../helper"; import { getDummyConnection } from "../helper";
const { dummyConnection, afterAllCleanup } = getDummyConnection(); const { dummyConnection, afterAllCleanup } = getDummyConnection();
@@ -15,7 +17,7 @@ describe("SchemaManager tests", async () => {
const em = new EntityManager([entity], dummyConnection, [], [index]); const em = new EntityManager([entity], dummyConnection, [], [index]);
const schema = new SchemaManager(em); const schema = new SchemaManager(em);
const introspection = schema.getIntrospectionFromEntity(em.entities[0]); const introspection = schema.getIntrospectionFromEntity(em.entities[0]!);
expect(introspection).toEqual({ expect(introspection).toEqual({
name: "test", name: "test",
isView: false, isView: false,
@@ -26,7 +28,7 @@ describe("SchemaManager tests", async () => {
isNullable: true, isNullable: true,
isAutoIncrementing: true, isAutoIncrementing: true,
hasDefaultValue: false, hasDefaultValue: false,
comment: undefined comment: undefined,
}, },
{ {
name: "username", name: "username",
@@ -34,7 +36,7 @@ describe("SchemaManager tests", async () => {
isNullable: true, isNullable: true,
isAutoIncrementing: false, isAutoIncrementing: false,
hasDefaultValue: false, hasDefaultValue: false,
comment: undefined comment: undefined,
}, },
{ {
name: "email", name: "email",
@@ -42,7 +44,7 @@ describe("SchemaManager tests", async () => {
isNullable: true, isNullable: true,
isAutoIncrementing: false, isAutoIncrementing: false,
hasDefaultValue: false, hasDefaultValue: false,
comment: undefined comment: undefined,
}, },
{ {
name: "bio", name: "bio",
@@ -50,8 +52,8 @@ describe("SchemaManager tests", async () => {
isNullable: true, isNullable: true,
isAutoIncrementing: false, isAutoIncrementing: false,
hasDefaultValue: false, hasDefaultValue: false,
comment: undefined comment: undefined,
} },
], ],
indices: [ indices: [
{ {
@@ -61,11 +63,11 @@ describe("SchemaManager tests", async () => {
columns: [ columns: [
{ {
name: "email", name: "email",
order: 0 order: 0,
} },
] ],
} },
] ],
}); });
}); });
@@ -77,10 +79,10 @@ describe("SchemaManager tests", async () => {
new Entity(table, [ new Entity(table, [
new TextField("username"), new TextField("username"),
new TextField("email"), new TextField("email"),
new TextField("bio") new TextField("bio"),
]) ]),
], ],
dummyConnection dummyConnection,
); );
const kysely = em.connection.kysely; const kysely = em.connection.kysely;
@@ -101,15 +103,15 @@ describe("SchemaManager tests", async () => {
name: table, name: table,
isNew: false, isNew: false,
columns: { add: ["bio"], drop: [], change: [] }, columns: { add: ["bio"], drop: [], change: [] },
indices: { add: [], drop: [index] } indices: { add: [], drop: [index] },
} },
]); ]);
// now sync // now sync
await schema.sync({ force: true, drop: true }); await schema.sync({ force: true, drop: true });
const diffAfter = await schema.getDiff(); const diffAfter = await schema.getDiff();
console.log("diffAfter", diffAfter); //console.log("diffAfter", diffAfter);
expect(diffAfter.length).toBe(0); expect(diffAfter.length).toBe(0);
await kysely.schema.dropTable(table).execute(); await kysely.schema.dropTable(table).execute();
@@ -119,7 +121,7 @@ describe("SchemaManager tests", async () => {
const table = "drop_column"; const table = "drop_column";
const em = new EntityManager( const em = new EntityManager(
[new Entity(table, [new TextField("username")])], [new Entity(table, [new TextField("username")])],
dummyConnection dummyConnection,
); );
const kysely = em.connection.kysely; const kysely = em.connection.kysely;
@@ -141,10 +143,10 @@ describe("SchemaManager tests", async () => {
columns: { columns: {
add: [], add: [],
drop: ["email"], drop: ["email"],
change: [] change: [],
}, },
indices: { add: [], drop: [] } indices: { add: [], drop: [] },
} },
]); ]);
// now sync // now sync
@@ -165,15 +167,15 @@ describe("SchemaManager tests", async () => {
new Entity(usersTable, [ new Entity(usersTable, [
new TextField("username"), new TextField("username"),
new TextField("email"), new TextField("email"),
new TextField("bio") new TextField("bio"),
]), ]),
new Entity(postsTable, [ new Entity(postsTable, [
new TextField("title"), new TextField("title"),
new TextField("content"), new TextField("content"),
new TextField("created_at") new TextField("created_at"),
]) ]),
], ],
dummyConnection dummyConnection,
); );
const kysely = em.connection.kysely; const kysely = em.connection.kysely;
@@ -192,7 +194,7 @@ describe("SchemaManager tests", async () => {
name: usersTable, name: usersTable,
isNew: false, isNew: false,
columns: { add: ["bio"], drop: [], change: [] }, columns: { add: ["bio"], drop: [], change: [] },
indices: { add: [], drop: [] } indices: { add: [], drop: [] },
}, },
{ {
name: postsTable, name: postsTable,
@@ -200,10 +202,10 @@ describe("SchemaManager tests", async () => {
columns: { columns: {
add: ["id", "title", "content", "created_at"], add: ["id", "title", "content", "created_at"],
drop: [], drop: [],
change: [] change: [],
}, },
indices: { add: [], drop: [] } indices: { add: [], drop: [] },
} },
]); ]);
// now sync // now sync
@@ -228,8 +230,8 @@ describe("SchemaManager tests", async () => {
name: entity.name, name: entity.name,
isNew: true, isNew: true,
columns: { add: ["id", "email"], drop: [], change: [] }, columns: { add: ["id", "email"], drop: [], change: [] },
indices: { add: [index.name!], drop: [] } indices: { add: [index.name!], drop: [] },
} },
]); ]);
// sync and then check again // sync and then check again
@@ -256,8 +258,8 @@ describe("SchemaManager tests", async () => {
name: entity.name, name: entity.name,
isNew: false, isNew: false,
columns: { add: [], drop: [], change: [] }, columns: { add: [], drop: [], change: [] },
indices: { add: [index.name!], drop: [] } indices: { add: [index.name!], drop: [] },
} },
]); ]);
// sync and then check again // sync and then check again
@@ -266,4 +268,39 @@ describe("SchemaManager tests", async () => {
const diffAfter = await em.schema().getDiff(); const diffAfter = await em.schema().getDiff();
expect(diffAfter.length).toBe(0); expect(diffAfter.length).toBe(0);
}); });
test("returns statements", async () => {
const amount = 5;
const entities = new Array(amount)
.fill(0)
.map(() => new Entity(randomString(16), [new TextField("text")]));
const em = new EntityManager(entities, dummyConnection);
const statements = await em.schema().sync({ force: true });
expect(statements.length).toBe(amount);
expect(statements.every((stmt) => Object.keys(stmt).join(",") === "sql,parameters")).toBe(
true,
);
});
test("batches statements", async () => {
const { dummyConnection } = getDummyConnection();
const entities = new Array(20)
.fill(0)
.map(() => new Entity(randomString(16), [new TextField("text")]));
const em = new EntityManager(entities, dummyConnection);
const spy = spyOn(em.connection, "executeQueries");
const statements = await em.schema().sync();
expect(statements.length).toBe(entities.length);
expect(statements.every((stmt) => Object.keys(stmt).join(",") === "sql,parameters")).toBe(
true,
);
await em.schema().sync({ force: true });
expect(spy).toHaveBeenCalledTimes(1);
const tables = await em.connection.kysely
.selectFrom("sqlite_master")
.where("type", "=", "table")
.selectAll()
.execute();
expect(tables.length).toBe(entities.length + 1); /* 1+ for sqlite_sequence */
});
}); });
@@ -1,25 +1,18 @@
import { describe, expect, test } from "bun:test"; import { describe, test, expect } from "bun:test";
import { Value, _jsonp } from "../../src/core/utils"; import { getDummyConnection } from "../helper";
import { type RepoQuery, WhereBuilder, type WhereQuery, querySchema } from "../../src/data"; import { WhereBuilder, type WhereQuery } from "data/entities/query/WhereBuilder";
import type { RepoQueryIn } from "../../src/data/server/data-query-impl";
import { getDummyConnection } from "./helper";
const decode = (input: RepoQueryIn, expected: RepoQuery) => { function qb() {
const result = Value.Decode(querySchema, input); const c = getDummyConnection();
expect(result).toEqual(expected); const kysely = c.dummyConnection.kysely;
}; return kysely.selectFrom("t").selectAll();
}
describe("data-query-impl", () => { function compile(q: WhereQuery) {
function qb() { const { sql, parameters } = WhereBuilder.addClause(qb(), q).compile();
const c = getDummyConnection(); return { sql, parameters };
const kysely = c.dummyConnection.kysely; }
return kysely.selectFrom("t").selectAll();
}
function compile(q: WhereQuery) {
const { sql, parameters } = WhereBuilder.addClause(qb(), q).compile();
return { sql, parameters };
}
describe("WhereBuilder", () => {
test("single validation", () => { test("single validation", () => {
const tests: [WhereQuery, string, any[]][] = [ const tests: [WhereQuery, string, any[]][] = [
[{ name: "Michael", age: 40 }, '("name" = ? and "age" = ?)', ["Michael", 40]], [{ name: "Michael", age: 40 }, '("name" = ? and "age" = ?)', ["Michael", 40]],
@@ -30,7 +23,7 @@ describe("data-query-impl", () => {
[{ val: { $isnull: 0 } }, '"val" is not null', []], [{ val: { $isnull: 0 } }, '"val" is not null', []],
[{ val: { $isnull: false } }, '"val" is not null', []], [{ val: { $isnull: false } }, '"val" is not null', []],
[{ val: { $like: "what" } }, '"val" like ?', ["what"]], [{ 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) { for (const [query, expectedSql, expectedParams] of tests) {
@@ -51,22 +44,22 @@ describe("data-query-impl", () => {
[ [
{ val1: { $eq: "foo" }, val2: { $eq: "bar" } }, { val1: { $eq: "foo" }, val2: { $eq: "bar" } },
'("val1" = ? and "val2" = ?)', '("val1" = ? and "val2" = ?)',
["foo", "bar"] ["foo", "bar"],
], ],
[ [
{ val1: { $eq: "foo" }, val2: { $eq: "bar" } }, { val1: { $eq: "foo" }, val2: { $eq: "bar" } },
'("val1" = ? and "val2" = ?)', '("val1" = ? and "val2" = ?)',
["foo", "bar"] ["foo", "bar"],
], ],
// or constructs // or constructs
[ [
{ $or: { val1: { $eq: "foo" }, val2: { $eq: "bar" } } }, { $or: { val1: { $eq: "foo" }, val2: { $eq: "bar" } } },
'("val1" = ? or "val2" = ?)', '("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]] [{ val1: { $eq: 1 }, $or: { val1: { $eq: 2 } } }, '("val1" = ? or "val1" = ?)', [1, 2]],
]; ];
for (const [query, expectedSql, expectedParams] of tests) { for (const [query, expectedSql, expectedParams] of tests) {
@@ -86,7 +79,7 @@ describe("data-query-impl", () => {
// or constructs // or constructs
[{ $or: { val1: { $eq: "foo" }, val2: { $eq: "bar" } } }, ["val1", "val2"]], [{ $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) { for (const [query, expectedKeys] of tests) {
@@ -94,47 +87,4 @@ describe("data-query-impl", () => {
expect(keys).toEqual(expectedKeys); 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"]
}
}
}
}
}
);
});
});
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" } });
});
}); });
+98 -108
View File
@@ -1,17 +1,15 @@
import { afterAll, describe, expect, test } from "bun:test"; import { afterAll, beforeAll, describe, expect, test } from "bun:test";
import { _jsonp } from "../../../src/core/utils"; import { Entity, EntityManager } from "data/entities";
import { import { ManyToManyRelation, ManyToOneRelation, PolymorphicRelation } from "data/relations";
Entity, import { TextField } from "data/fields";
EntityManager, import * as proto from "data/prototype";
ManyToManyRelation, import { WithBuilder } from "data/entities/query/WithBuilder";
ManyToOneRelation, import { schemaToEm } from "../../helper";
PolymorphicRelation,
TextField,
WithBuilder
} from "../../../src/data";
import * as proto from "../../../src/data/prototype";
import { compileQb, prettyPrintQb, schemaToEm } from "../../helper";
import { getDummyConnection } from "../helper"; import { getDummyConnection } from "../helper";
import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
beforeAll(() => disableConsoleLog());
afterAll(enableConsoleLog);
const { dummyConnection } = getDummyConnection(); const { dummyConnection } = getDummyConnection();
@@ -21,32 +19,32 @@ describe("[data] WithBuilder", async () => {
{ {
posts: proto.entity("posts", {}), posts: proto.entity("posts", {}),
users: proto.entity("users", {}), users: proto.entity("users", {}),
media: proto.entity("media", {}) media: proto.entity("media", {}),
}, },
({ relation }, { posts, users, media }) => { ({ relation }, { posts, users, media }) => {
relation(posts).manyToOne(users); relation(posts).manyToOne(users);
relation(users).polyToOne(media, { mappedBy: "avatar" }); relation(users).polyToOne(media, { mappedBy: "avatar" });
} },
); );
const em = schemaToEm(schema); const em = schemaToEm(schema);
expect(WithBuilder.validateWiths(em, "posts", undefined)).toBe(0); expect(WithBuilder.validateWiths(em, "posts", undefined as any)).toBe(0);
expect(WithBuilder.validateWiths(em, "posts", {})).toBe(0); expect(WithBuilder.validateWiths(em, "posts", {})).toBe(0);
expect(WithBuilder.validateWiths(em, "posts", { users: {} })).toBe(1); expect(WithBuilder.validateWiths(em, "posts", { users: {} })).toBe(1);
expect( expect(
WithBuilder.validateWiths(em, "posts", { WithBuilder.validateWiths(em, "posts", {
users: { users: {
with: { avatar: {} } with: { avatar: {} },
} },
}) }),
).toBe(2); ).toBe(2);
expect(() => WithBuilder.validateWiths(em, "posts", { author: {} })).toThrow(); expect(() => WithBuilder.validateWiths(em, "posts", { author: {} })).toThrow();
expect(() => expect(() =>
WithBuilder.validateWiths(em, "posts", { WithBuilder.validateWiths(em, "posts", {
users: { users: {
with: { glibberish: {} } with: { glibberish: {} },
} },
}) }),
).toThrow(); ).toThrow();
}); });
@@ -56,8 +54,8 @@ describe("[data] WithBuilder", async () => {
expect(() => expect(() =>
WithBuilder.addClause(em, em.connection.kysely.selectFrom("users"), users, { WithBuilder.addClause(em, em.connection.kysely.selectFrom("users"), users, {
posts: {} posts: {},
}) }),
).toThrow('Relation "users<>posts" not found'); ).toThrow('Relation "users<>posts" not found');
}); });
@@ -68,13 +66,13 @@ describe("[data] WithBuilder", async () => {
const em = new EntityManager([users, posts], dummyConnection, relations); const em = new EntityManager([users, posts], dummyConnection, relations);
const qb = WithBuilder.addClause(em, em.connection.kysely.selectFrom("users"), users, { const qb = WithBuilder.addClause(em, em.connection.kysely.selectFrom("users"), users, {
posts: {} posts: {},
}); });
const res = qb.compile(); const res = qb.compile();
expect(res.sql).toBe( 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" as "posts" where "posts"."author_id" = "users"."id" order by "posts"."id" asc limit ? offset ?) 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([10, 0]); expect(res.parameters).toEqual([10, 0]);
@@ -83,16 +81,16 @@ describe("[data] WithBuilder", async () => {
em.connection.kysely.selectFrom("posts"), em.connection.kysely.selectFrom("posts"),
posts, // @todo: try with "users", it gives output! posts, // @todo: try with "users", it gives output!
{ {
author: {} author: {},
} },
); );
const res2 = qb2.compile(); const res2 = qb2.compile();
expect(res2.sql).toBe( 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" as "author" where "author"."id" = "posts"."author_id" order by "users"."id" asc limit ? offset ?) as obj) as "author" from "posts"' 'select (select json_object(\'id\', "obj"."id", \'username\', "obj"."username") from (select "author"."id" as "id", "author"."username" as "username" from "users" as "author" where "author"."id" = "posts"."author_id" order by "author"."id" asc limit ?) as obj) as "author" from "posts"',
); );
expect(res2.parameters).toEqual([1, 0]); expect(res2.parameters).toEqual([1]);
}); });
test("test with empty join", async () => { test("test with empty join", async () => {
@@ -124,7 +122,7 @@ describe("[data] WithBuilder", async () => {
.values([ .values([
{ posts_id: 1, categories_id: 1 }, { posts_id: 1, categories_id: 1 },
{ posts_id: 2, categories_id: 2 }, { posts_id: 2, categories_id: 2 },
{ posts_id: 1, categories_id: 2 } { posts_id: 1, categories_id: 2 },
]) ])
.execute(); .execute();
@@ -138,14 +136,14 @@ describe("[data] WithBuilder", async () => {
title: "fashion post", title: "fashion post",
categories: [ categories: [
{ id: 1, label: "fashion" }, { id: 1, label: "fashion" },
{ id: 2, label: "beauty" } { id: 2, label: "beauty" },
] ],
}, },
{ {
id: 2, id: 2,
title: "beauty post", 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: {} } });
@@ -156,21 +154,21 @@ describe("[data] WithBuilder", async () => {
{ {
id: 1, id: 1,
label: "fashion", label: "fashion",
posts: [{ id: 1, title: "fashion post" }] posts: [{ id: 1, title: "fashion post" }],
}, },
{ {
id: 2, id: 2,
label: "beauty", label: "beauty",
posts: [ posts: [
{ id: 1, title: "fashion post" }, { id: 1, title: "fashion post" },
{ id: 2, title: "beauty post" } { id: 2, title: "beauty post" },
] ],
}, },
{ {
id: 3, id: 3,
label: "tech", label: "tech",
posts: [] posts: [],
} },
]); ]);
}); });
@@ -181,7 +179,7 @@ describe("[data] WithBuilder", async () => {
const entities = [media, categories]; const entities = [media, categories];
const single = new PolymorphicRelation(categories, media, { const single = new PolymorphicRelation(categories, media, {
mappedBy: "single", mappedBy: "single",
targetCardinality: 1 targetCardinality: 1,
}); });
const multiple = new PolymorphicRelation(categories, media, { mappedBy: "multiple" }); const multiple = new PolymorphicRelation(categories, media, { mappedBy: "multiple" });
@@ -191,24 +189,20 @@ describe("[data] WithBuilder", async () => {
em, em,
em.connection.kysely.selectFrom("categories"), em.connection.kysely.selectFrom("categories"),
categories, categories,
{ single: {} } { single: {} },
); );
const res = qb.compile(); const res = qb.compile();
expect(res.sql).toBe( expect(res.sql).toMatchSnapshot();
'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( const qb2 = WithBuilder.addClause(
em, em,
em.connection.kysely.selectFrom("categories"), em.connection.kysely.selectFrom("categories"),
categories, categories,
{ multiple: {} } { multiple: {} },
); );
const res2 = qb2.compile(); const res2 = qb2.compile();
expect(res2.sql).toBe( expect(res2.sql).toMatchSnapshot();
'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", 10, 0]); expect(res2.parameters).toEqual(["categories.multiple", 10, 0]);
}); });
@@ -240,16 +234,16 @@ describe("[data] WithBuilder", async () => {
{ {
posts: proto.entity("posts", {}), posts: proto.entity("posts", {}),
users: proto.entity("users", { users: proto.entity("users", {
username: proto.text() username: proto.text(),
}), }),
media: proto.entity("media", { media: proto.entity("media", {
path: proto.text() path: proto.text(),
}) }),
}, },
({ relation }, { posts, users, media }) => { ({ relation }, { posts, users, media }) => {
relation(posts).manyToOne(users); relation(posts).manyToOne(users);
relation(users).polyToOne(media, { mappedBy: "avatar" }); relation(users).polyToOne(media, { mappedBy: "avatar" });
} },
); );
const em = schemaToEm(schema); const em = schemaToEm(schema);
@@ -265,18 +259,16 @@ describe("[data] WithBuilder", async () => {
with: { with: {
avatar: { avatar: {
select: ["id", "path"], select: ["id", "path"],
limit: 2 // ignored limit: 2, // ignored
} },
} },
} },
} },
); );
//prettyPrintQb(qb); //prettyPrintQb(qb);
expect(qb.compile().sql).toBe( expect(qb.compile().sql).toMatchSnapshot();
'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, 1]);
);
expect(qb.compile().parameters).toEqual(["users.avatar", 1, 0, 1, 0]);
}); });
test("compiles with many", async () => { test("compiles with many", async () => {
@@ -285,17 +277,17 @@ describe("[data] WithBuilder", async () => {
posts: proto.entity("posts", {}), posts: proto.entity("posts", {}),
comments: proto.entity("comments", {}), comments: proto.entity("comments", {}),
users: proto.entity("users", { users: proto.entity("users", {
username: proto.text() username: proto.text(),
}), }),
media: proto.entity("media", { media: proto.entity("media", {
path: proto.text() path: proto.text(),
}) }),
}, },
({ relation }, { posts, comments, users, media }) => { ({ relation }, { posts, comments, users, media }) => {
relation(posts).manyToOne(users).polyToOne(media, { mappedBy: "images" }); relation(posts).manyToOne(users).polyToOne(media, { mappedBy: "images" });
relation(users).polyToOne(media, { mappedBy: "avatar" }); relation(users).polyToOne(media, { mappedBy: "avatar" });
relation(comments).manyToOne(posts).manyToOne(users); relation(comments).manyToOne(posts).manyToOne(users);
} },
); );
const em = schemaToEm(schema); const em = schemaToEm(schema);
@@ -308,40 +300,38 @@ describe("[data] WithBuilder", async () => {
limit: 12, limit: 12,
with: { with: {
users: { users: {
select: ["username"] select: ["username"],
} },
} },
} },
} },
); );
expect(qb.compile().sql).toBe( expect(qb.compile().sql).toMatchSnapshot();
'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, 12, 0]);
);
expect(qb.compile().parameters).toEqual([1, 0, 12, 0]);
}); });
test("returns correct result", async () => { test("returns correct result", async () => {
const schema = proto.em( const schema = proto.em(
{ {
posts: proto.entity("posts", { posts: proto.entity("posts", {
title: proto.text() title: proto.text(),
}), }),
comments: proto.entity("comments", { comments: proto.entity("comments", {
content: proto.text() content: proto.text(),
}), }),
users: proto.entity("users", { users: proto.entity("users", {
username: proto.text() username: proto.text(),
}), }),
media: proto.entity("media", { media: proto.entity("media", {
path: proto.text() path: proto.text(),
}) }),
}, },
({ relation }, { posts, comments, users, media }) => { ({ relation }, { posts, comments, users, media }) => {
relation(posts).manyToOne(users).polyToOne(media, { mappedBy: "images" }); relation(posts).manyToOne(users).polyToOne(media, { mappedBy: "images" });
relation(users).polyToOne(media, { mappedBy: "avatar" }); relation(users).polyToOne(media, { mappedBy: "avatar" });
relation(comments).manyToOne(posts).manyToOne(users); relation(comments).manyToOne(posts).manyToOne(users);
} },
); );
const em = schemaToEm(schema); const em = schemaToEm(schema);
await em.schema().sync({ force: true }); await em.schema().sync({ force: true });
@@ -351,7 +341,7 @@ describe("[data] WithBuilder", async () => {
await em.mutator("posts").insertMany([ await em.mutator("posts").insertMany([
{ title: "post1", users_id: 1 }, { title: "post1", users_id: 1 },
{ title: "post2", users_id: 1 }, { title: "post2", users_id: 1 },
{ title: "post3", users_id: 2 } { title: "post3", users_id: 2 },
]); ]);
await em.mutator("comments").insertMany([ await em.mutator("comments").insertMany([
{ content: "comment1", posts_id: 1, users_id: 1 }, { content: "comment1", posts_id: 1, users_id: 1 },
@@ -360,7 +350,7 @@ describe("[data] WithBuilder", async () => {
{ content: "comment3", posts_id: 2, users_id: 1 }, { content: "comment3", posts_id: 2, users_id: 1 },
{ content: "comment4", posts_id: 2, users_id: 2 }, { content: "comment4", posts_id: 2, users_id: 2 },
{ content: "comment5", posts_id: 3, users_id: 1 }, { content: "comment5", posts_id: 3, users_id: 1 },
{ content: "comment6", posts_id: 3, users_id: 2 } { content: "comment6", posts_id: 3, users_id: 2 },
]); ]);
const result = await em.repo("posts").findMany({ const result = await em.repo("posts").findMany({
@@ -371,11 +361,11 @@ describe("[data] WithBuilder", async () => {
select: ["content"], select: ["content"],
with: { with: {
users: { users: {
select: ["username"] select: ["username"],
} },
} },
} },
} },
}); });
expect(result.data).toEqual([ expect(result.data).toEqual([
@@ -385,16 +375,16 @@ describe("[data] WithBuilder", async () => {
{ {
content: "comment1", content: "comment1",
users: { users: {
username: "user1" username: "user1",
} },
}, },
{ {
content: "comment1-1", content: "comment1-1",
users: { users: {
username: "user1" username: "user1",
} },
} },
] ],
}, },
{ {
title: "post2", title: "post2",
@@ -402,16 +392,16 @@ describe("[data] WithBuilder", async () => {
{ {
content: "comment3", content: "comment3",
users: { users: {
username: "user1" username: "user1",
} },
}, },
{ {
content: "comment4", content: "comment4",
users: { users: {
username: "user2" username: "user2",
} },
} },
] ],
}, },
{ {
title: "post3", title: "post3",
@@ -419,17 +409,17 @@ describe("[data] WithBuilder", async () => {
{ {
content: "comment5", content: "comment5",
users: { users: {
username: "user1" username: "user1",
} },
}, },
{ {
content: "comment6", content: "comment6",
users: { users: {
username: "user2" username: "user2",
} },
} },
] ],
} },
]); ]);
//console.log(_jsonp(result.data)); //console.log(_jsonp(result.data));
}); });
@@ -0,0 +1,9 @@
// Bun Snapshot v1, https://bun.sh/docs/test/snapshots
exports[`[data] WithBuilder recursive compiles with singles 1`] = `"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 "avatar"."id" as "id", "avatar"."path" as "path" from "media" where "media"."reference" = ? and "users"."id" = "media"."entity_id" order by "avatar"."id" asc limit ?) as obj) as "avatar" from "users" as "users" where "users"."id" = "posts"."users_id" order by "users"."username" asc limit ?) as obj) as "users" from "posts""`;
exports[`[data] WithBuilder recursive compiles with many 1`] = `"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 ?) 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""`;
exports[`[data] WithBuilder polymorphic 1`] = `"select (select json_object('id', "obj"."id", 'path', "obj"."path") from (select "single"."id" as "id", "single"."path" as "path" from "media" where "media"."reference" = ? and "categories"."id" = "media"."entity_id" order by "single"."id" asc limit ?) as obj) as "single" from "categories""`;
exports[`[data] WithBuilder polymorphic 2`] = `"select (select coalesce(json_group_array(json_object('id', "agg"."id", 'path', "agg"."path")), '[]') from (select "multiple"."id" as "id", "multiple"."path" as "path" from "media" where "media"."reference" = ? and "categories"."id" = "media"."entity_id" order by "multiple"."id" asc limit ? offset ?) as agg) as "multiple" from "categories""`;
@@ -1,5 +1,5 @@
import { afterAll, describe, expect, test } from "bun:test"; import { afterAll, describe, expect, test } from "bun:test";
import { EntityManager } from "../../../../src/data"; import { EntityManager } from "data/entities/EntityManager";
import { getDummyConnection } from "../../helper"; import { getDummyConnection } from "../../helper";
const { dummyConnection, afterAllCleanup } = getDummyConnection(); const { dummyConnection, afterAllCleanup } = getDummyConnection();
@@ -22,10 +22,10 @@ describe("Connection", async () => {
columns: [ columns: [
{ {
name: "name", name: "name",
order: 0 order: 0,
} },
] ],
} },
]); ]);
}); });
@@ -54,14 +54,14 @@ describe("Connection", async () => {
columns: [ columns: [
{ {
name: "name", name: "name",
order: 0 order: 0,
}, },
{ {
name: "desc", name: "desc",
order: 1 order: 1,
} },
] ],
} },
]); ]);
}); });
@@ -83,10 +83,10 @@ describe("Connection", async () => {
columns: [ columns: [
{ {
name: "name", 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: true,
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: true,
comment: undefined,
},
{
name: "number",
dataType: "INTEGER",
isNullable: true,
isAutoIncrementing: false,
hasDefaultValue: false,
comment: undefined,
},
],
},
]);
});
});
@@ -1,9 +1,10 @@
import { bunTestRunner } from "adapter/bun/test";
import { describe, expect, test } from "bun:test"; import { describe, expect, test } from "bun:test";
import { BooleanField } from "../../../../src/data"; import { BooleanField } from "data/fields";
import { runBaseFieldTests, transformPersist } from "./inc"; import { fieldTestSuite, transformPersist } from "data/fields/field-test-suite";
describe("[data] BooleanField", async () => { describe("[data] BooleanField", async () => {
runBaseFieldTests(BooleanField, { defaultValue: true, schemaType: "boolean" }); fieldTestSuite(bunTestRunner, BooleanField, { defaultValue: true, schemaType: "boolean" });
test("transformRetrieve", async () => { test("transformRetrieve", async () => {
const field = new BooleanField("test"); const field = new BooleanField("test");
@@ -1,9 +1,15 @@
import { describe, expect, test } from "bun:test"; import { describe, test } from "bun:test";
import { DateField } from "../../../../src/data"; import { DateField } from "data/fields";
import { runBaseFieldTests } from "./inc"; import { fieldTestSuite } from "data/fields/field-test-suite";
import { bunTestRunner } from "adapter/bun/test";
describe("[data] DateField", async () => { describe("[data] DateField", async () => {
runBaseFieldTests(DateField, { defaultValue: new Date(), schemaType: "date" }); fieldTestSuite(
bunTestRunner,
DateField,
{ defaultValue: new Date(), schemaType: "date" },
{ type: "date" },
);
// @todo: add datefield tests // @todo: add datefield tests
test("week", async () => { test("week", async () => {
@@ -1,25 +1,30 @@
import { bunTestRunner } from "adapter/bun/test";
import { describe, expect, test } from "bun:test"; import { describe, expect, test } from "bun:test";
import { EnumField } from "../../../../src/data"; import { EnumField } from "data/fields";
import { runBaseFieldTests, transformPersist } from "./inc"; import { fieldTestSuite, transformPersist } from "data/fields/field-test-suite";
function options(strings: string[]) { function options(strings: string[]) {
return { type: "strings", values: strings }; return { type: "strings", values: strings };
} }
describe("[data] EnumField", async () => { describe("[data] EnumField", async () => {
runBaseFieldTests( fieldTestSuite(
bunTestRunner,
// @ts-ignore
EnumField, EnumField,
{ defaultValue: "a", schemaType: "text" }, { defaultValue: "a", schemaType: "text" },
{ options: options(["a", "b", "c"]) } { options: options(["a", "b", "c"]) },
); );
test("yields if default value is not a valid option", async () => { test("yields if default value is not a valid option", async () => {
expect( expect(
() => new EnumField("test", { options: options(["a", "b"]), default_value: "c" }) // @ts-ignore
() => new EnumField("test", { options: options(["a", "b"]), default_value: "c" }),
).toThrow(); ).toThrow();
}); });
test("transformPersist (config)", async () => { test("transformPersist (config)", async () => {
// @ts-ignore
const field = new EnumField("test", { options: options(["a", "b", "c"]) }); const field = new EnumField("test", { options: options(["a", "b", "c"]) });
expect(transformPersist(field, null)).resolves.toBeUndefined(); expect(transformPersist(field, null)).resolves.toBeUndefined();
@@ -29,9 +34,10 @@ describe("[data] EnumField", async () => {
test("transformRetrieve", async () => { test("transformRetrieve", async () => {
const field = new EnumField("test", { const field = new EnumField("test", {
// @ts-ignore
options: options(["a", "b", "c"]), options: options(["a", "b", "c"]),
default_value: "a", default_value: "a",
required: true required: true,
}); });
expect(field.transformRetrieve(null)).toBe("a"); expect(field.transformRetrieve(null)).toBe("a");
+20 -13
View File
@@ -1,43 +1,50 @@
import { describe, expect, test } from "bun:test"; import { describe, expect, test } from "bun:test";
import { Default, parse, stripMark } from "../../../../src/core/utils"; import { baseFieldConfigSchema, Field } from "../../../../src/data/fields/Field";
import { Field, type SchemaResponse, TextField, baseFieldConfigSchema } from "../../../../src/data"; import { fieldTestSuite } from "data/fields/field-test-suite";
import { runBaseFieldTests, transformPersist } from "./inc"; import { bunTestRunner } from "adapter/bun/test";
import { stripMark } from "core/utils/schema";
describe("[data] Field", async () => { describe("[data] Field", async () => {
class FieldSpec extends Field { class FieldSpec extends Field {
schema(): SchemaResponse {
return this.useSchemaHelper("text");
}
getSchema() { getSchema() {
return baseFieldConfigSchema; return baseFieldConfigSchema;
} }
} }
runBaseFieldTests(FieldSpec, { defaultValue: "test", schemaType: "text" }); test("fieldSpec", () => {
expect(new FieldSpec("test").schema()).toEqual({
name: "test",
type: "text",
nullable: true, // always true
dflt: undefined, // never using default value
});
});
fieldTestSuite(bunTestRunner, FieldSpec, { defaultValue: "test", schemaType: "text" });
test("default config", async () => { test("default config", async () => {
const config = Default(baseFieldConfigSchema, {}); const config = baseFieldConfigSchema.template({});
expect(stripMark(new FieldSpec("test").config)).toEqual(config); expect(stripMark(new FieldSpec("test").config)).toEqual(config as any);
}); });
test("transformPersist (specific)", async () => { test("transformPersist (specific)", async () => {
const required = new FieldSpec("test", { required: true }); const required = new FieldSpec("test", { required: true });
const requiredDefault = new FieldSpec("test", { const requiredDefault = new FieldSpec("test", {
required: true, required: true,
default_value: "test" default_value: "test",
}); });
expect(required.transformPersist(null, undefined as any, undefined as any)).rejects.toThrow(); expect(required.transformPersist(null, undefined as any, undefined as any)).rejects.toThrow();
expect( expect(
required.transformPersist(undefined, undefined as any, undefined as any) required.transformPersist(undefined, undefined as any, undefined as any),
).rejects.toThrow(); ).rejects.toThrow();
// works because it has a default value // works because it has a default value
expect( expect(
requiredDefault.transformPersist(null, undefined as any, undefined as any) requiredDefault.transformPersist(null, undefined as any, undefined as any),
).resolves.toBeDefined(); ).resolves.toBeDefined();
expect( expect(
requiredDefault.transformPersist(undefined, undefined as any, undefined as any) requiredDefault.transformPersist(undefined, undefined as any, undefined as any),
).resolves.toBeDefined(); ).resolves.toBeDefined();
}); });
}); });
@@ -1,19 +1,14 @@
import { describe, expect, test } from "bun:test"; import { describe, expect, test } from "bun:test";
import { Type } from "../../../../src/core/utils"; import { Entity } from "data/entities";
import { import { Field, EntityIndex } from "data/fields";
Entity, import { s } from "core/utils/schema";
EntityIndex,
type EntityManager,
Field,
type SchemaResponse
} from "../../../../src/data";
class TestField extends Field { class TestField extends Field {
protected getSchema(): any { protected getSchema(): any {
return Type.Any(); return s.any();
} }
schema(em: EntityManager<any>): SchemaResponse { override schema() {
return undefined as any; return undefined as any;
} }
} }
@@ -28,11 +23,4 @@ describe("FieldIndex", async () => {
expect(index.name).toEqual("idx_test_name"); expect(index.name).toEqual("idx_test_name");
expect(index.unique).toEqual(false); expect(index.unique).toEqual(false);
}); });
test("it fails on non-unique", async () => {
const field = new TestField("name", { required: false });
expect(() => new EntityIndex(entity, [field], true)).toThrowError();
expect(() => new EntityIndex(entity, [field])).toBeDefined();
});
}); });
@@ -1,13 +1,14 @@
import { bunTestRunner } from "adapter/bun/test";
import { describe, expect, test } from "bun:test"; import { describe, expect, test } from "bun:test";
import { JsonField } from "../../../../src/data"; import { JsonField } from "data/fields";
import { runBaseFieldTests, transformPersist } from "./inc"; import { fieldTestSuite, transformPersist } from "data/fields/field-test-suite";
describe("[data] JsonField", async () => { describe("[data] JsonField", async () => {
const field = new JsonField("test"); const field = new JsonField("test");
runBaseFieldTests(JsonField, { fieldTestSuite(bunTestRunner, JsonField, {
defaultValue: { a: 1 }, defaultValue: { a: 1 },
sampleValues: ["string", { test: 1 }, 1], //sampleValues: ["string", { test: 1 }, 1],
schemaType: "text" schemaType: "text",
}); });
test("transformPersist (no config)", async () => { test("transformPersist (no config)", async () => {
@@ -32,9 +33,9 @@ describe("[data] JsonField", async () => {
}); });
test("getValue", async () => { test("getValue", async () => {
expect(field.getValue({ test: 1 }, "form")).toBe('{\n "test": 1\n}'); expect(field.getValue({ test: 1 }, "form")).toEqual({ test: 1 });
expect(field.getValue("string", "form")).toBe('"string"'); expect(field.getValue("string", "form")).toBe("string");
expect(field.getValue(1, "form")).toBe("1"); expect(field.getValue(1, "form")).toBe(1);
expect(field.getValue('{"test":1}', "submit")).toEqual({ test: 1 }); expect(field.getValue('{"test":1}', "submit")).toEqual({ test: 1 });
expect(field.getValue('"string"', "submit")).toBe("string"); expect(field.getValue('"string"', "submit")).toBe("string");
@@ -42,6 +43,5 @@ describe("[data] JsonField", async () => {
expect(field.getValue({ test: 1 }, "table")).toBe('{"test":1}'); expect(field.getValue({ test: 1 }, "table")).toBe('{"test":1}');
expect(field.getValue("string", "table")).toBe('"string"'); expect(field.getValue("string", "table")).toBe('"string"');
expect(field.getValue(1, "form")).toBe("1");
}); });
}); });
@@ -1,9 +1,10 @@
import { describe, expect, test } from "bun:test"; import { describe, expect, test } from "bun:test";
import { JsonSchemaField } from "../../../../src/data"; import { JsonSchemaField } from "data/fields";
import { runBaseFieldTests } from "./inc"; import { fieldTestSuite } from "data/fields/field-test-suite";
describe("[data] JsonSchemaField", async () => { describe("[data] JsonSchemaField", async () => {
runBaseFieldTests(JsonSchemaField, { defaultValue: {}, schemaType: "text" }); // @ts-ignore
fieldTestSuite({ expect, test }, JsonSchemaField, { defaultValue: {}, schemaType: "text" });
// @todo: add JsonSchemaField tests // @todo: add JsonSchemaField tests
}); });
@@ -1,6 +1,7 @@
import { bunTestRunner } from "adapter/bun/test";
import { describe, expect, test } from "bun:test"; import { describe, expect, test } from "bun:test";
import { NumberField } from "../../../../src/data"; import { NumberField } from "data/fields";
import { runBaseFieldTests, transformPersist } from "./inc"; import { fieldTestSuite, transformPersist } from "data/fields/field-test-suite";
describe("[data] NumberField", async () => { describe("[data] NumberField", async () => {
test("transformPersist (config)", async () => { test("transformPersist (config)", async () => {
@@ -15,5 +16,5 @@ describe("[data] NumberField", async () => {
expect(transformPersist(field2, 10000)).resolves.toBe(10000); expect(transformPersist(field2, 10000)).resolves.toBe(10000);
}); });
runBaseFieldTests(NumberField, { defaultValue: 12, schemaType: "integer" }); fieldTestSuite(bunTestRunner, NumberField, { defaultValue: 12, schemaType: "integer" });
}); });
@@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test"; import { describe, expect, test } from "bun:test";
import { PrimaryField } from "../../../../src/data"; import { PrimaryField } from "data/fields";
describe("[data] PrimaryField", async () => { describe("[data] PrimaryField", async () => {
const field = new PrimaryField("primary"); const field = new PrimaryField("primary");
@@ -10,7 +10,12 @@ describe("[data] PrimaryField", async () => {
test("schema", () => { test("schema", () => {
expect(field.name).toBe("primary"); 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 () => { test("hasDefault", async () => {
@@ -34,4 +39,28 @@ describe("[data] PrimaryField", async () => {
expect(field.transformPersist(1)).rejects.toThrow(); expect(field.transformPersist(1)).rejects.toThrow();
expect(field.transformRetrieve(1)).toBe(1); expect(field.transformRetrieve(1)).toBe(1);
}); });
test("format", () => {
const uuid = new PrimaryField("uuid", { format: "uuid" });
expect(uuid.format).toBe("uuid");
expect(uuid.fieldType).toBe("text");
expect(uuid.getNewValue()).toBeString();
expect(uuid.toType()).toEqual({
required: true,
comment: undefined,
type: "Generated<string>",
import: [{ package: "kysely", name: "Generated" }],
});
const integer = new PrimaryField("integer", { format: "integer" });
expect(integer.format).toBe("integer");
expect(integer.fieldType).toBe("integer");
expect(integer.getNewValue()).toBeUndefined();
expect(integer.toType()).toEqual({
required: true,
comment: undefined,
type: "Generated<number>",
import: [{ package: "kysely", name: "Generated" }],
});
});
}); });
@@ -1,6 +1,7 @@
import { describe, expect, test } from "bun:test"; import { describe, expect, test } from "bun:test";
import { TextField } from "../../../../src/data"; import { TextField } from "data/fields";
import { runBaseFieldTests, transformPersist } from "./inc"; import { fieldTestSuite, transformPersist } from "data/fields/field-test-suite";
import { bunTestRunner } from "adapter/bun/test";
describe("[data] TextField", async () => { describe("[data] TextField", async () => {
test("transformPersist (config)", async () => { test("transformPersist (config)", async () => {
@@ -11,5 +12,5 @@ describe("[data] TextField", async () => {
expect(transformPersist(field, "abc")).resolves.toBe("abc"); expect(transformPersist(field, "abc")).resolves.toBe("abc");
}); });
runBaseFieldTests(TextField, { defaultValue: "abc", schemaType: "text" }); fieldTestSuite(bunTestRunner, TextField, { defaultValue: "abc", schemaType: "text" });
}); });
@@ -1,18 +1,20 @@
import { describe, expect, it, test } from "bun:test"; import { describe, expect, it, test } from "bun:test";
import { Entity, type EntityManager } from "../../../../src/data"; import { Entity, type EntityManager } from "data/entities";
import { import {
type BaseRelationConfig, type BaseRelationConfig,
EntityRelation, EntityRelation,
EntityRelationAnchor, EntityRelationAnchor,
RelationTypes ManyToManyRelation,
} from "../../../../src/data/relations"; RelationTypes,
} from "data/relations";
import * as proto from "data/prototype";
class TestEntityRelation extends EntityRelation { class TestEntityRelation extends EntityRelation {
constructor(config?: BaseRelationConfig) { constructor(config?: BaseRelationConfig) {
super( super(
new EntityRelationAnchor(new Entity("source"), "source"), new EntityRelationAnchor(new Entity("source"), "source"),
new EntityRelationAnchor(new Entity("target"), "target"), new EntityRelationAnchor(new Entity("target"), "target"),
config config,
); );
} }
initialize(em: EntityManager<any>) {} initialize(em: EntityManager<any>) {}
@@ -24,11 +26,11 @@ class TestEntityRelation extends EntityRelation {
return this; return this;
} }
buildWith(a: any, b: any, c: any): any { buildWith(): any {
return; return;
} }
buildJoin(a: any, b: any): any { buildJoin(): any {
return; return;
} }
} }
@@ -75,4 +77,15 @@ describe("[data] EntityRelation", async () => {
const relation2 = new TestEntityRelation({ required: true }); const relation2 = new TestEntityRelation({ required: true });
expect(relation2.required).toBe(true); expect(relation2.required).toBe(true);
}); });
it("correctly produces the relation name", async () => {
const relation = new ManyToManyRelation(new Entity("apps"), new Entity("organizations"));
expect(relation.getName()).not.toContain(",");
expect(relation.getName()).toBe("mn_apps_organizations");
const relation2 = new ManyToManyRelation(new Entity("apps"), new Entity("organizations"), {
connectionTableMappedName: "appOrganizations",
});
expect(relation2.getName()).toBe("mn_apps_organizations_appOrganizations");
});
}); });
@@ -0,0 +1,24 @@
import { describe, it, expect } from "bun:test";
import * as sDirect from "jsonv-ts";
import { s as sFromBknd } from "bknd/utils";
describe("jsonv-ts resolution", () => {
it("should resolve to a single instance", () => {
const sameNamespace = sDirect === (sFromBknd as unknown as typeof sDirect);
// If this fails, two instances are being loaded via different specifiers/paths
expect(sameNamespace).toBe(true);
});
it("should resolve specifiers to a single package path", async () => {
const base = await import.meta.resolve("jsonv-ts");
const hono = await import.meta.resolve("jsonv-ts/hono");
const mcp = await import.meta.resolve("jsonv-ts/mcp");
expect(typeof base).toBe("string");
expect(typeof hono).toBe("string");
expect(typeof mcp).toBe("string");
// They can be different files (subpath exports), but they should share the same package root
const pkgRoot = (p: string) => p.slice(0, p.lastIndexOf("jsonv-ts") + "jsonv-ts".length);
expect(pkgRoot(base)).toBe(pkgRoot(hono));
expect(pkgRoot(base)).toBe(pkgRoot(mcp));
});
});
+13 -13
View File
@@ -30,23 +30,23 @@ beforeAll(() =>
method: init?.method ?? "GET", method: init?.method ?? "GET",
// @ts-ignore // @ts-ignore
headers: Object.fromEntries(init?.headers?.entries() ?? []), headers: Object.fromEntries(init?.headers?.entries() ?? []),
body: init?.body body: init?.body,
}; };
return new Response(JSON.stringify({ todos: [1, 2], request }), { return new Response(JSON.stringify({ todos: [1, 2], request }), {
status: 200, status: 200,
headers: { "Content-Type": "application/json" } headers: { "Content-Type": "application/json" },
}); });
}) }),
); );
afterAll(unmockFetch); afterAll(unmockFetch);
describe("FetchTask", async () => { describe.skip("FetchTask", async () => {
test("Simple fetch", async () => { test("Simple fetch", async () => {
const task = new FetchTask("Fetch Something", { const task = new FetchTask("Fetch Something", {
url: "https://jsonplaceholder.typicode.com/todos/1", url: "https://jsonplaceholder.typicode.com/todos/1",
method: "GET", method: "GET",
headers: [{ key: "Content-Type", value: "application/json" }] headers: [{ key: "Content-Type", value: "application/json" }],
}); });
const result = await task.run(); const result = await task.run();
@@ -62,18 +62,18 @@ describe("FetchTask", async () => {
expect( expect(
// // @ts-expect-error // // @ts-expect-error
() => new FetchTask("", { url: "https://jsonplaceholder.typicode.com", method: 1 }) () => new FetchTask("", { url: "https://jsonplaceholder.typicode.com", method: 1 }),
).toThrow(); ).toThrow();
expect( expect(
new FetchTask("", { new FetchTask("", {
url: "https://jsonplaceholder.typicode.com", url: "https://jsonplaceholder.typicode.com",
method: "invalid" method: "invalid",
}).execute() }).execute(),
).rejects.toThrow(/^Invalid method/); ).rejects.toThrow(/^Invalid method/);
expect( expect(
() => new FetchTask("", { url: "https://jsonplaceholder.typicode.com", method: "GET" }) () => new FetchTask("", { url: "https://jsonplaceholder.typicode.com", method: "GET" }),
).toBeDefined(); ).toBeDefined();
expect(() => new FetchTask("", { url: "", method: "Invalid" })).toThrow(); expect(() => new FetchTask("", { url: "", method: "Invalid" })).toThrow();
@@ -85,17 +85,17 @@ describe("FetchTask", async () => {
method: "{{ flow.output.method }}", method: "{{ flow.output.method }}",
headers: [ headers: [
{ key: "Content-{{ flow.output.headerKey }}", value: "application/json" }, { 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({ body: JSON.stringify({
email: "{{ flow.output.email }}" email: "{{ flow.output.email }}",
}) }),
}); });
const inputs = { const inputs = {
headerKey: "Type", headerKey: "Type",
apiKey: 123, apiKey: 123,
email: "what@else.com", email: "what@else.com",
method: "PATCH" method: "PATCH",
}; };
const flow = new Flow("", [task]); const flow = new Flow("", [task]);
+26 -47
View File
@@ -1,19 +1,35 @@
import { describe, expect, test } from "bun:test"; import { describe, expect, test } from "bun:test";
import { Flow, LogTask, RenderTask, SubFlowTask } from "../../src/flows"; import { Flow, LogTask, SubFlowTask, RenderTask, Task } from "../../src/flows";
import { s } from "core/utils/schema";
describe("SubFlowTask", async () => { export class StringifyTask<Output extends string> extends Task<
typeof StringifyTask.schema,
Output
> {
type = "stringify";
static override schema = s.object({
input: s.string().optional(),
});
async execute() {
return JSON.stringify(this.params.input) as Output;
}
}
describe.skip("SubFlowTask", async () => {
test("Simple Subflow", async () => { test("Simple Subflow", async () => {
const subTask = new RenderTask("render", { const subTask = new RenderTask("render", {
render: "subflow" render: "subflow",
}); });
const subflow = new Flow("subflow", [subTask]); const subflow = new Flow("subflow", [subTask]);
const task = new LogTask("log"); const task = new LogTask("log");
const task2 = new SubFlowTask("sub", { const task2 = new SubFlowTask("sub", {
flow: subflow flow: subflow,
}); });
const task3 = new RenderTask("render2", { const task3 = new RenderTask("render2", {
render: "Subflow output: {{ sub.output }}" render: "Subflow output: {{ sub.output }}",
}); });
const flow = new Flow("test", [task, task2, task3], []); const flow = new Flow("test", [task, task2, task3], []);
@@ -22,15 +38,13 @@ describe("SubFlowTask", async () => {
const execution = flow.createExecution(); const execution = flow.createExecution();
await execution.start(); await execution.start();
/*console.log(execution.logs);
console.log(execution.getResponse());*/
expect(execution.getResponse()).toEqual("Subflow output: subflow"); expect(execution.getResponse()).toEqual("Subflow output: subflow");
}); });
test("Simple loop", async () => { test("Simple loop", async () => {
const subTask = new RenderTask("render", { const subTask = new RenderTask("render", {
render: "run {{ flow.output }}" render: "run {{ flow.output }}",
}); });
const subflow = new Flow("subflow", [subTask]); const subflow = new Flow("subflow", [subTask]);
@@ -38,10 +52,10 @@ describe("SubFlowTask", async () => {
const task2 = new SubFlowTask("sub", { const task2 = new SubFlowTask("sub", {
flow: subflow, flow: subflow,
loop: true, loop: true,
input: [1, 2, 3] input: [1, 2, 3],
}); });
const task3 = new RenderTask("render2", { const task3 = new StringifyTask("stringify", {
render: `Subflow output: {{ sub.output | join: ", " }}` input: "{{ sub.output }}",
}); });
const flow = new Flow("test", [task, task2, task3], []); const flow = new Flow("test", [task, task2, task3], []);
@@ -51,41 +65,6 @@ describe("SubFlowTask", async () => {
const execution = flow.createExecution(); const execution = flow.createExecution();
await execution.start(); await execution.start();
console.log("errors", execution.getErrors()); expect(execution.getResponse()).toEqual('"run 1,run 2,run 3"');
/*console.log(execution.logs);
console.log(execution.getResponse());*/
expect(execution.getResponse()).toEqual("Subflow output: run 1, run 2, run 3");
});
test("Simple loop from flow input", async () => {
const subTask = new RenderTask("render", {
render: "run {{ flow.output }}"
});
const subflow = new Flow("subflow", [subTask]);
const task = new LogTask("log");
const task2 = new SubFlowTask("sub", {
flow: subflow,
loop: true,
input: "{{ flow.output | json }}"
});
const task3 = new RenderTask("render2", {
render: `Subflow output: {{ sub.output | join: ", " }}`
});
const flow = new Flow("test", [task, task2, task3], []);
flow.task(task).asInputFor(task2);
flow.task(task2).asInputFor(task3);
const execution = flow.createExecution();
await execution.start([4, 5, 6]);
/*console.log(execution.logs);
console.log(execution.getResponse());*/
expect(execution.getResponse()).toEqual("Subflow output: run 4, run 5, run 6");
}); });
}); });
+16 -74
View File
@@ -1,20 +1,20 @@
import { describe, expect, test } from "bun:test"; import { describe, expect, test } from "bun:test";
import { Type } from "../../src/core/utils";
import { Task } from "../../src/flows"; import { Task } from "../../src/flows";
import { dynamic } from "../../src/flows/tasks/Task"; import { dynamic } from "../../src/flows/tasks/Task";
import { s } from "core/utils/schema";
describe("Task", async () => { describe.skip("Task", async () => {
test("resolveParams: template with parse", async () => { test("resolveParams: template with parse", async () => {
const result = await Task.resolveParams( const result = await Task.resolveParams(
Type.Object({ test: dynamic(Type.Number()) }), s.object({ test: dynamic(s.number()) }),
{ {
test: "{{ some.path }}" test: "{{ some.path }}",
}, },
{ {
some: { some: {
path: 1 path: 1,
} },
} },
); );
expect(result.test).toBe(1); expect(result.test).toBe(1);
@@ -22,15 +22,15 @@ describe("Task", async () => {
test("resolveParams: with string", async () => { test("resolveParams: with string", async () => {
const result = await Task.resolveParams( const result = await Task.resolveParams(
Type.Object({ test: Type.String() }), s.object({ test: s.string() }),
{ {
test: "{{ some.path }}" test: "{{ some.path }}",
}, },
{ {
some: { some: {
path: "1/1" path: "1/1",
} },
} },
); );
expect(result.test).toBe("1/1"); expect(result.test).toBe("1/1");
@@ -38,75 +38,17 @@ describe("Task", async () => {
test("resolveParams: with object", async () => { test("resolveParams: with object", async () => {
const result = await Task.resolveParams( const result = await Task.resolveParams(
Type.Object({ test: dynamic(Type.Object({ key: Type.String(), value: Type.String() })) }), s.object({ test: dynamic(s.object({ key: s.string(), value: s.string() })) }),
{ {
test: { key: "path", value: "{{ some.path }}" } test: { key: "path", value: "{{ some.path }}" },
}, },
{ {
some: { some: {
path: "1/1" path: "1/1",
} },
}
);
expect(result.test).toEqual({ key: "path", value: "1/1" });
});
test("resolveParams: with json", async () => {
const result = await Task.resolveParams(
Type.Object({
test: dynamic(Type.Object({ key: Type.String(), value: Type.String() }))
}),
{
test: "{{ some | json }}"
}, },
{
some: {
key: "path",
value: "1/1"
}
}
); );
expect(result.test).toEqual({ key: "path", value: "1/1" }); expect(result.test).toEqual({ key: "path", value: "1/1" });
}); });
test("resolveParams: with array", async () => {
const result = await Task.resolveParams(
Type.Object({
test: dynamic(Type.Array(Type.String()))
}),
{
test: '{{ "1,2,3" | split: "," | json }}'
}
);
expect(result.test).toEqual(["1", "2", "3"]);
});
test("resolveParams: boolean", async () => {
const result = await Task.resolveParams(
Type.Object({
test: dynamic(Type.Boolean())
}),
{
test: "{{ true }}"
}
);
expect(result.test).toEqual(true);
});
test("resolveParams: float", async () => {
const result = await Task.resolveParams(
Type.Object({
test: dynamic(Type.Number(), Number.parseFloat)
}),
{
test: "{{ 3.14 }}"
}
);
expect(result.test).toEqual(3.14);
});
}); });
+3 -3
View File
@@ -7,11 +7,11 @@ const first = getNamedTask(
//throw new Error("Error"); //throw new Error("Error");
return { return {
inner: { inner: {
result: 2 result: 2,
} },
}; };
}, },
1000 1000,
); );
const second = getNamedTask("second (if match)"); const second = getNamedTask("second (if match)");
const third = getNamedTask("third (if error)"); const third = getNamedTask("third (if error)");
+3 -3
View File
@@ -11,7 +11,7 @@ class ExecTask extends Task {
constructor( constructor(
name: string, name: string,
params: any, params: any,
private fn: () => any private fn: () => any,
) { ) {
super(name, params); super(name, params);
} }
@@ -54,8 +54,8 @@ export function getNamedTask(name: string, _func?: () => Promise<any>, delay?: n
return new ExecTask( return new ExecTask(
name, 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 second = new LogTask("Second", { delay: 1000 });
const third = new LogTask("Long Third", { delay: 2500 }); const third = new LogTask("Long Third", { delay: 2500 });
const fourth = new FetchTask("Fetch Something", { 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 const fifth = new LogTask("Task 4", { delay: 500 }); // without connection
+17 -17
View File
@@ -1,7 +1,7 @@
import { describe, expect, test } from "bun:test"; import { describe, expect, test } from "bun:test";
import { Hono } from "hono"; import { Hono } from "hono";
import { Event, EventManager } from "../../src/core/events"; import { Event, EventManager } from "../../src/core/events";
import { type Static, type StaticDecode, Type, parse } from "../../src/core/utils"; import { s, parse } from "core/utils/schema";
import { EventTrigger, Flow, HttpTrigger, type InputsMap, Task } from "../../src/flows"; import { EventTrigger, Flow, HttpTrigger, type InputsMap, Task } from "../../src/flows";
import { dynamic } from "../../src/flows/tasks/Task"; import { dynamic } from "../../src/flows/tasks/Task";
@@ -14,19 +14,19 @@ class Passthrough extends Task {
} }
} }
type OutputIn = Static<typeof OutputParamTask.schema>; type OutputIn = s.Static<typeof OutputParamTask.schema>;
type OutputOut = StaticDecode<typeof OutputParamTask.schema>; type OutputOut = s.StaticCoerced<typeof OutputParamTask.schema>;
class OutputParamTask extends Task<typeof OutputParamTask.schema> { class OutputParamTask extends Task<typeof OutputParamTask.schema> {
type = "output-param"; type = "output-param";
static override schema = Type.Object({ static override schema = s.strictObject({
number: dynamic( number: dynamic(
Type.Number({ s.number({
title: "Output number" title: "Output number",
}), }),
Number.parseInt Number.parseInt,
) ),
}); });
async execute(inputs: InputsMap) { async execute(inputs: InputsMap) {
@@ -43,7 +43,7 @@ class PassthroughFlowInput extends Task {
} }
} }
describe("Flow task inputs", async () => { describe.skip("Flow task inputs", async () => {
test("types", async () => { test("types", async () => {
const schema = OutputParamTask.schema; const schema = OutputParamTask.schema;
@@ -75,7 +75,7 @@ describe("Flow task inputs", async () => {
test("output/input", async () => { test("output/input", async () => {
const task = new OutputParamTask("task1", { number: 111 }); const task = new OutputParamTask("task1", { number: 111 });
const task2 = new OutputParamTask("task2", { const task2 = new OutputParamTask("task2", {
number: "{{ task1.output }}" number: "{{ task1.output }}",
}); });
const flow = new Flow("test", [task, task2]); const flow = new Flow("test", [task, task2]);
@@ -94,10 +94,10 @@ describe("Flow task inputs", async () => {
test("input from flow", async () => { test("input from flow", async () => {
const task = new OutputParamTask("task1", { const task = new OutputParamTask("task1", {
number: "{{flow.output.someFancyParam}}" number: "{{flow.output.someFancyParam}}",
}); });
const task2 = new OutputParamTask("task2", { const task2 = new OutputParamTask("task2", {
number: "{{task1.output}}" number: "{{task1.output}}",
}); });
const flow = new Flow("test", [task, task2]); const flow = new Flow("test", [task, task2]);
@@ -126,7 +126,7 @@ describe("Flow task inputs", async () => {
const emgr = new EventManager({ EventTriggerClass }); const emgr = new EventManager({ EventTriggerClass });
const task = new OutputParamTask("event", { const task = new OutputParamTask("event", {
number: "{{flow.output.number}}" number: "{{flow.output.number}}",
}); });
const flow = new Flow( const flow = new Flow(
"test", "test",
@@ -134,8 +134,8 @@ describe("Flow task inputs", async () => {
[], [],
new EventTrigger({ new EventTrigger({
event: "test-event", event: "test-event",
mode: "sync" mode: "sync",
}) }),
); );
flow.setRespondingTask(task); flow.setRespondingTask(task);
flow.trigger.register(flow, emgr); flow.trigger.register(flow, emgr);
@@ -155,8 +155,8 @@ describe("Flow task inputs", async () => {
new HttpTrigger({ new HttpTrigger({
path: "/test", path: "/test",
method: "GET", method: "GET",
mode: "sync" mode: "sync",
}) }),
); );
flow.setRespondingTask(task); flow.setRespondingTask(task);
+4 -4
View File
@@ -10,7 +10,7 @@ const flows = {
back, back,
fanout, fanout,
parallel, parallel,
simpleFetch simpleFetch,
}; };
const arg = process.argv[2]; const arg = process.argv[2];
@@ -32,7 +32,7 @@ const colors = [
"#F78F1E", // Saffron "#F78F1E", // Saffron
"#BD10E0", // Vivid Purple "#BD10E0", // Vivid Purple
"#50E3C2", // Turquoise "#50E3C2", // Turquoise
"#9013FE" // Grape "#9013FE", // Grape
]; ];
const colorsCache: Record<string, string> = {}; const colorsCache: Record<string, string> = {};
@@ -82,7 +82,7 @@ function TerminalFlow({ flow }: { flow: Flow }) {
} }
return t; return t;
}) }),
); );
} }
}); });
@@ -92,7 +92,7 @@ function TerminalFlow({ flow }: { flow: Flow }) {
console.log("done", response ? response : "(no response)"); console.log("done", response ? response : "(no response)");
console.log( console.log(
"Executed tasks:", "Executed tasks:",
execution.logs.map((l) => l.task.name) execution.logs.map((l) => l.task.name),
); );
console.log("Executed count:", execution.logs.length); console.log("Executed count:", execution.logs.length);
}); });
+8 -8
View File
@@ -11,7 +11,7 @@ class ExecTask extends Task {
constructor( constructor(
name: string, name: string,
params: any, params: any,
private fn: () => any private fn: () => any,
) { ) {
super(name, params); super(name, params);
} }
@@ -30,7 +30,7 @@ class ExecTask extends Task {
} }
} }
describe("Flow trigger", async () => { describe.skip("Flow trigger", async () => {
test("manual trigger", async () => { test("manual trigger", async () => {
let called = false; let called = false;
@@ -60,7 +60,7 @@ describe("Flow trigger", async () => {
"test", "test",
[task], [task],
[], [],
new EventTrigger({ event: "test-event", mode: "sync" }) new EventTrigger({ event: "test-event", mode: "sync" }),
); );
flow.trigger.register(flow, emgr); flow.trigger.register(flow, emgr);
@@ -107,8 +107,8 @@ describe("Flow trigger", async () => {
new HttpTrigger({ new HttpTrigger({
path: "/test", path: "/test",
method: "GET", method: "GET",
mode: "sync" mode: "sync",
}) }),
); );
const hono = new Hono(); const hono = new Hono();
@@ -123,7 +123,7 @@ describe("Flow trigger", async () => {
test("http trigger with response", async () => { test("http trigger with response", async () => {
const task = ExecTask.create("http", () => ({ const task = ExecTask.create("http", () => ({
called: true called: true,
})); }));
const flow = new Flow( const flow = new Flow(
"test", "test",
@@ -132,8 +132,8 @@ describe("Flow trigger", async () => {
new HttpTrigger({ new HttpTrigger({
path: "/test", path: "/test",
method: "GET", method: "GET",
mode: "sync" mode: "sync",
}) }),
); );
flow.setRespondingTask(task); flow.setRespondingTask(task);
+20 -19
View File
@@ -1,7 +1,8 @@
// eslint-disable-next-line import/no-unresolved // eslint-disable-next-line import/no-unresolved
import { describe, expect, test } from "bun:test"; import { describe, expect, test } from "bun:test";
import { isEqual } from "lodash-es"; import { isEqual } from "lodash-es";
import { type Static, Type, _jsonp, withDisabledConsole } from "../../src/core/utils"; import { _jsonp, withDisabledConsole } from "../../src/core/utils";
import { s } from "core/utils/schema";
import { Condition, ExecutionEvent, FetchTask, Flow, LogTask, Task } from "../../src/flows"; import { Condition, ExecutionEvent, FetchTask, Flow, LogTask, Task } from "../../src/flows";
/*beforeAll(disableConsoleLog); /*beforeAll(disableConsoleLog);
@@ -10,19 +11,19 @@ afterAll(enableConsoleLog);*/
class ExecTask extends Task<typeof ExecTask.schema> { class ExecTask extends Task<typeof ExecTask.schema> {
type = "exec"; type = "exec";
static override schema = Type.Object({ static override schema = s.object({
delay: Type.Number({ default: 10 }) delay: s.number({ default: 10 }),
}); });
constructor( constructor(
name: string, name: string,
params: Static<typeof ExecTask.schema>, params: s.Static<typeof ExecTask.schema>,
private func: () => Promise<any> private func: () => Promise<any>,
) { ) {
super(name, params); super(name, params);
} }
override clone(name: string, params: Static<typeof ExecTask.schema>) { override clone(name: string, params: s.Static<typeof ExecTask.schema>) {
return new ExecTask(name, params, this.func); return new ExecTask(name, params, this.func);
} }
@@ -36,12 +37,12 @@ function getTask(num: number = 0, delay: number = 5) {
return new ExecTask( return new ExecTask(
`Task ${num}`, `Task ${num}`,
{ {
delay delay,
}, },
async () => { async () => {
//console.log(`[DONE] Task: ${num}`); //console.log(`[DONE] Task: ${num}`);
return true; return true;
} },
); );
//return new LogTask(`Log ${num}`, { delay }); //return new LogTask(`Log ${num}`, { delay });
} }
@@ -56,9 +57,9 @@ function getNamedTask(name: string, _func?: () => Promise<any>, delay?: number)
return new ExecTask( return new ExecTask(
name, name,
{ {
delay: delay ?? 0 delay: delay ?? 0,
}, },
func func,
); );
} }
@@ -77,7 +78,7 @@ function getObjectDiff(obj1, obj2) {
return diff; return diff;
} }
describe("Flow tests", async () => { describe.skip("Flow tests", async () => {
test("Simple single task", async () => { test("Simple single task", async () => {
const simple = getTask(0); const simple = getTask(0);
@@ -228,7 +229,7 @@ describe("Flow tests", async () => {
back back
.task(third) .task(third)
.getOutTasks() .getOutTasks()
.map((t) => t.name) .map((t) => t.name),
).toEqual(["second", "fourth"]); ).toEqual(["second", "fourth"]);
const execution = back.createExecution(); const execution = back.createExecution();
@@ -263,7 +264,7 @@ describe("Flow tests", async () => {
back back
.task(third) .task(third)
.getOutTasks() .getOutTasks()
.map((t) => t.name) .map((t) => t.name),
).toEqual(["second", "fourth"]); ).toEqual(["second", "fourth"]);
const execution = back.createExecution(); const execution = back.createExecution();
@@ -324,8 +325,8 @@ describe("Flow tests", async () => {
const first = getNamedTask("first", async () => { const first = getNamedTask("first", async () => {
return { return {
inner: { inner: {
result: 2 result: 2,
} },
}; };
}); });
const second = getNamedTask("second"); const second = getNamedTask("second");
@@ -361,7 +362,7 @@ describe("Flow tests", async () => {
"[event]", "[event]",
event.isStart() ? "start" : "end", event.isStart() ? "start" : "end",
event.task().name, event.task().name,
event.isStart() ? undefined : event.succeeded() event.isStart() ? undefined : event.succeeded(),
); );
} }
}); });
@@ -389,7 +390,7 @@ describe("Flow tests", async () => {
const second = new LogTask("Task 1"); const second = new LogTask("Task 1");
const third = new LogTask("Task 2", { delay: 50 }); const third = new LogTask("Task 2", { delay: 50 });
const fourth = new FetchTask("Fetch Something", { 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 const fifth = new LogTask("Task 4"); // without connection
@@ -405,7 +406,7 @@ describe("Flow tests", async () => {
// @todo: fix // @todo: fix
const deserialized = Flow.fromObject("", original, { const deserialized = Flow.fromObject("", original, {
fetch: { cls: FetchTask }, fetch: { cls: FetchTask },
log: { cls: LogTask } log: { cls: LogTask },
} as any); } as any);
const diffdeep = getObjectDiff(original, deserialized.toJSON()); const diffdeep = getObjectDiff(original, deserialized.toJSON());
@@ -414,7 +415,7 @@ describe("Flow tests", async () => {
expect(flow.startTask.name).toEqual(deserialized.startTask.name); expect(flow.startTask.name).toEqual(deserialized.startTask.name);
expect(flow.respondingTask?.name).toEqual( expect(flow.respondingTask?.name).toEqual(
// @ts-ignore // @ts-ignore
deserialized.respondingTask?.name deserialized.respondingTask?.name,
); );
//console.log("--- creating original sequence"); //console.log("--- creating original sequence");
+55 -23
View File
@@ -2,8 +2,12 @@ import { unlink } from "node:fs/promises";
import type { SelectQueryBuilder, SqliteDatabase } from "kysely"; import type { SelectQueryBuilder, SqliteDatabase } from "kysely";
import Database from "libsql"; import Database from "libsql";
import { format as sqlFormat } from "sql-formatter"; 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 type { em as protoEm } from "../src/data/prototype";
import { writeFile } from "node:fs/promises";
import { join } from "node:path";
import { slugify } from "bknd/utils";
import { type Connection, SqliteLocalConnection } from "data/connection";
import { EntityManager } from "data/entities/EntityManager";
export function getDummyDatabase(memory: boolean = true): { export function getDummyDatabase(memory: boolean = true): {
dummyDb: SqliteDatabase; dummyDb: SqliteDatabase;
@@ -17,7 +21,7 @@ export function getDummyDatabase(memory: boolean = true): {
afterAllCleanup: async () => { afterAllCleanup: async () => {
if (!memory) await unlink(DB_NAME); if (!memory) await unlink(DB_NAME);
return true; return true;
} },
}; };
} }
@@ -27,7 +31,7 @@ export function getDummyConnection(memory: boolean = true) {
return { return {
dummyConnection, dummyConnection,
afterAllCleanup afterAllCleanup,
}; };
} }
@@ -35,25 +39,6 @@ export function getLocalLibsqlConnection() {
return { url: "http://127.0.0.1:8080" }; return { url: "http://127.0.0.1:8080" };
} }
type ConsoleSeverity = "log" | "warn" | "error";
const _oldConsoles = {
log: console.log,
warn: console.warn,
error: console.error
};
export function disableConsoleLog(severities: ConsoleSeverity[] = ["log", "warn"]) {
severities.forEach((severity) => {
console[severity] = () => null;
});
}
export function enableConsoleLog() {
Object.entries(_oldConsoles).forEach(([severity, fn]) => {
console[severity as ConsoleSeverity] = fn;
});
}
export function compileQb(qb: SelectQueryBuilder<any, any, any>) { export function compileQb(qb: SelectQueryBuilder<any, any, any>) {
const { sql, parameters } = qb.compile(); const { sql, parameters } = qb.compile();
return { sql, parameters }; return { sql, parameters };
@@ -61,10 +46,57 @@ export function compileQb(qb: SelectQueryBuilder<any, any, any>) {
export function prettyPrintQb(qb: SelectQueryBuilder<any, any, any>) { export function prettyPrintQb(qb: SelectQueryBuilder<any, any, any>) {
const { sql, parameters } = qb.compile(); const { sql, parameters } = qb.compile();
console.log("$", sqlFormat(sql), "\n[params]", parameters); console.info("$", sqlFormat(sql), "\n[params]", parameters);
} }
export function schemaToEm(s: ReturnType<typeof protoEm>, conn?: Connection): EntityManager<any> { export function schemaToEm(s: ReturnType<typeof protoEm>, conn?: Connection): EntityManager<any> {
const connection = conn ? conn : getDummyConnection().dummyConnection; const connection = conn ? conn : getDummyConnection().dummyConnection;
return new EntityManager(Object.values(s.entities), connection, s.relations, s.indices); 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;
// @ts-ignore
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;
};
}
+141 -33
View File
@@ -1,8 +1,15 @@
import { afterAll, beforeAll, describe, expect, it } from "bun:test"; import { afterAll, beforeAll, describe, expect, it } from "bun:test";
import { App, createApp } from "../../src"; import { App, createApp, type AuthResponse } from "../../src";
import type { AuthResponse } from "../../src/auth"; import { auth } from "../../src/modules/middlewares";
import { randomString, secureRandomString, withDisabledConsole } from "../../src/core/utils"; import {
import { disableConsoleLog, enableConsoleLog } from "../helper"; mergeObject,
randomString,
secureRandomString,
withDisabledConsole,
} from "../../src/core/utils";
import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
import { getDummyConnection } from "../helper";
import type { AppAuthSchema } from "auth/auth-schema";
beforeAll(disableConsoleLog); beforeAll(disableConsoleLog);
afterAll(enableConsoleLog); afterAll(enableConsoleLog);
@@ -15,25 +22,25 @@ const roles = {
"system.schema.read", "system.schema.read",
"system.access.api", "system.access.api",
"system.config.read", "system.config.read",
"data.entity.read" "data.entity.read",
], ],
is_default: true is_default: true,
}, },
admin: { admin: {
is_default: true, is_default: true,
implicit_allow: true implicit_allow: true,
} },
}, },
strict: { strict: {
guest: { guest: {
permissions: ["system.access.api", "system.config.read", "data.entity.read"], permissions: ["system.access.api", "system.config.read", "data.entity.read"],
is_default: true is_default: true,
}, },
admin: { admin: {
is_default: true, is_default: true,
implicit_allow: true implicit_allow: true,
} },
} },
}; };
const configs = { const configs = {
auth: { auth: {
@@ -41,31 +48,33 @@ const configs = {
entity_name: "users", entity_name: "users",
jwt: { jwt: {
secret: secureRandomString(20), secret: secureRandomString(20),
issuer: randomString(10) issuer: randomString(10),
}, },
roles: roles.strict, roles: roles.strict,
guard: { guard: {
enabled: true enabled: true,
} },
}, },
users: { users: {
normal: { normal: {
email: "normal@bknd.io", email: "normal@bknd.io",
password: "12345678" password: "12345678",
}, },
admin: { admin: {
email: "admin@bknd.io", email: "admin@bknd.io",
password: "12345678", password: "12345678",
role: "admin" role: "admin",
} },
} },
}; };
function createAuthApp() { function createAuthApp(config?: Partial<AppAuthSchema>) {
const { dummyConnection } = getDummyConnection();
const app = createApp({ const app = createApp({
initialConfig: { connection: dummyConnection,
auth: configs.auth config: {
} auth: mergeObject(configs.auth, config ?? {}),
},
}); });
app.emgr.onEvent( app.emgr.onEvent(
@@ -74,7 +83,7 @@ function createAuthApp() {
await app.createUser(configs.users.normal); await app.createUser(configs.users.normal);
await app.createUser(configs.users.admin); await app.createUser(configs.users.admin);
}, },
"sync" "sync",
); );
return app; return app;
@@ -93,14 +102,14 @@ const fns = <Mode extends "cookie" | "token" = "token">(app: App, mode?: Mode) =
if (mode === "cookie") { if (mode === "cookie") {
return { return {
cookie: `auth=${token};`, cookie: `auth=${token};`,
...additional ...additional,
}; };
} }
return { return {
Authorization: `Bearer ${token}`, Authorization: token ? `Bearer ${token}` : "",
"Content-Type": "application/json", "Content-Type": "application/json",
...additional ...additional,
}; };
} }
function body(obj?: Record<string, any>) { function body(obj?: Record<string, any>) {
@@ -117,25 +126,35 @@ const fns = <Mode extends "cookie" | "token" = "token">(app: App, mode?: Mode) =
return { return {
login: async ( login: async (
user: any user: any,
): Promise<{ res: Response; data: Mode extends "token" ? AuthResponse : string }> => { ): Promise<{ res: Response; data: Mode extends "token" ? AuthResponse : string }> => {
const res = (await app.server.request("/api/auth/password/login", { const res = (await app.server.request("/api/auth/password/login", {
method: "POST", method: "POST",
headers: headers(), headers: headers(),
body: body(user) body: body(user),
})) as Response; })) as Response;
const data = mode === "cookie" ? getCookie(res, "auth") : await res.json(); const data = mode === "cookie" ? getCookie(res, "auth") : await res.json();
return { res, data }; return { res, data };
}, },
register: async (user: any): Promise<{ res: Response; data: AuthResponse }> => {
const res = (await app.server.request("/api/auth/password/register", {
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">> => { me: async (token?: string): Promise<Pick<AuthResponse, "user">> => {
const res = (await app.server.request("/api/auth/me", { const res = (await app.server.request("/api/auth/me", {
method: "GET", method: "GET",
headers: headers(token) headers: headers(token),
})) as Response; })) as Response;
return await res.json(); return await res.json();
} },
}; };
}; };
@@ -146,8 +165,8 @@ describe("integration auth", () => {
const { data: users } = await app.em.repository("users").findMany(); const { data: users } = await app.em.repository("users").findMany();
expect(users.length).toBe(2); expect(users.length).toBe(2);
expect(users[0].email).toBe(configs.users.normal.email); expect(users[0]?.email).toBe(configs.users.normal.email);
expect(users[1].email).toBe(configs.users.admin.email); expect(users[1]?.email).toBe(configs.users.admin.email);
}); });
it("should log you in with API", async () => { it("should log you in with API", async () => {
@@ -210,4 +229,93 @@ describe("integration auth", () => {
expect(res.status).toBe(403); 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()) as any;
expect(data.user).toBe(null);
expect(await $fns.me()).toEqual({ user: null as any });
}
});
it("should register users with default role", async () => {
const app = createAuthApp({ default_role_register: "guest" });
await app.build();
const $fns = fns(app);
// takes default role
expect(
await app
.createUser({
email: "test@bknd.io",
password: "12345678",
})
.then((r) => r.role),
).toBe("guest");
// throws error if role doesn't exist
expect(
app.createUser({
email: "test@bknd.io",
password: "12345678",
role: "doesnt exist",
}),
).rejects.toThrow();
// takes role if provided
expect(
await app
.createUser({
email: "test2@bknd.io",
password: "12345678",
role: "admin",
})
.then((r) => r.role),
).toBe("admin");
// registering with role is not allowed
expect(
await $fns
.register({
email: "test3@bknd.io",
password: "12345678",
role: "admin",
})
.then((r) => r.res.ok),
).toBe(false);
// takes default role
expect(
await $fns
.register({
email: "test3@bknd.io",
password: "12345678",
})
.then((r) => r.data.user.role),
).toBe("guest");
});
}); });

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