webspresso 0.0.5 → 0.0.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -7,6 +7,7 @@ A minimal, file-based SSR framework for Node.js with Nunjucks templating.
7
7
  - **File-Based Routing**: Create pages by adding `.njk` files to a `pages/` directory
8
8
  - **Dynamic Routes**: Use `[param]` for dynamic params and `[...rest]` for catch-all routes
9
9
  - **API Endpoints**: Add `.js` files to `pages/api/` with method suffixes (e.g., `health.get.js`)
10
+ - **Schema Validation**: Zod-based request validation for body, params, and query
10
11
  - **Built-in i18n**: JSON-based translations with automatic locale detection
11
12
  - **Lifecycle Hooks**: Global and route-level hooks for request processing
12
13
  - **Template Helpers**: Laravel-inspired helper functions available in templates
@@ -494,6 +495,75 @@ Create `.js` files in `pages/api/` with optional method suffixes:
494
495
  | `pages/api/echo.post.js` | `POST /api/echo` |
495
496
  | `pages/api/users/[id].get.js` | `GET /api/users/:id` |
496
497
 
498
+ **Basic API Handler:**
499
+
500
+ ```javascript
501
+ // pages/api/health.get.js
502
+ module.exports = async function handler(req, res) {
503
+ res.json({ status: 'ok' });
504
+ };
505
+ ```
506
+
507
+ **With Schema Validation:**
508
+
509
+ ```javascript
510
+ // pages/api/posts.post.js
511
+ module.exports = {
512
+ schema: ({ z }) => ({
513
+ body: z.object({
514
+ title: z.string().min(3).max(100),
515
+ content: z.string(),
516
+ tags: z.array(z.string()).optional()
517
+ }),
518
+ query: z.object({
519
+ draft: z.coerce.boolean().default(false)
520
+ })
521
+ }),
522
+
523
+ async handler(req, res) {
524
+ // Validated & parsed data available in req.input
525
+ const { title, content, tags } = req.input.body;
526
+ const { draft } = req.input.query;
527
+
528
+ // Original req.body, req.query remain untouched
529
+ res.json({ success: true, title, draft });
530
+ }
531
+ };
532
+ ```
533
+
534
+ **Schema Options:**
535
+
536
+ | Key | Description |
537
+ |-----|-------------|
538
+ | `body` | Validates `req.body` (POST/PUT/PATCH) |
539
+ | `params` | Validates route parameters (e.g., `:id`) |
540
+ | `query` | Validates query string parameters |
541
+ | `response` | Response schema (for documentation, not enforced) |
542
+
543
+ All schemas use [Zod](https://zod.dev) for validation. Invalid requests throw a `ZodError` which can be caught by error handlers.
544
+
545
+ **Dynamic Route with Params Validation:**
546
+
547
+ ```javascript
548
+ // pages/api/users/[id].get.js
549
+ module.exports = {
550
+ schema: ({ z }) => ({
551
+ params: z.object({
552
+ id: z.string().uuid()
553
+ }),
554
+ query: z.object({
555
+ fields: z.string().optional()
556
+ })
557
+ }),
558
+
559
+ async handler(req, res) {
560
+ const { id } = req.input.params; // Validated UUID
561
+ const user = await getUser(id);
562
+ res.json(user);
563
+ }
564
+ };
565
+ ```
566
+
497
567
  ### Route Config
498
568
 
499
569
  Add a `.js` file alongside your `.njk` file to configure the route:
@@ -621,6 +691,500 @@ module.exports = {
621
691
  | `DEFAULT_LOCALE` | `en` | Default locale |
622
692
  | `SUPPORTED_LOCALES` | `en` | Comma-separated locales |
623
693
  | `BASE_URL` | `http://localhost:3000` | Base URL for canonical URLs |
694
+ | `DATABASE_URL` | - | Database connection string (for ORM) |
695
+
696
+ ## ORM (Database)
697
+
698
+ Webspresso includes a minimal, Eloquent-inspired ORM built on Knex with Zod schemas as the single source of truth.
699
+
700
+ ### Quick Start
701
+
702
+ ```javascript
703
+ const { z } = require('zod');
704
+ const { createSchemaHelpers, defineModel, createDatabase } = require('webspresso');
705
+
706
+ // 1. Create schema helpers
707
+ const zdb = createSchemaHelpers(z);
708
+
709
+ // 2. Define your schema with database metadata
710
+ const UserSchema = z.object({
711
+ id: zdb.id(),
712
+ email: zdb.string({ unique: true, index: true }),
713
+ name: zdb.string({ maxLength: 100 }),
714
+ status: zdb.enum(['active', 'inactive'], { default: 'active' }),
715
+ company_id: zdb.foreignKey('companies', { nullable: true }),
716
+ created_at: zdb.timestamp({ auto: 'create' }),
717
+ updated_at: zdb.timestamp({ auto: 'update' }),
718
+ deleted_at: zdb.timestamp({ nullable: true }),
719
+ });
720
+
721
+ // 3. Define your model
722
+ const User = defineModel({
723
+ name: 'User',
724
+ table: 'users',
725
+ schema: UserSchema,
726
+ relations: {
727
+ company: { type: 'belongsTo', model: () => Company, foreignKey: 'company_id' },
728
+ posts: { type: 'hasMany', model: () => Post, foreignKey: 'user_id' },
729
+ },
730
+ scopes: { softDelete: true, timestamps: true },
731
+ });
732
+
733
+ // 4. Create database and use
734
+ const db = createDatabase({
735
+ client: 'pg',
736
+ connection: process.env.DATABASE_URL,
737
+ });
738
+
739
+ const UserRepo = db.createRepository(User);
740
+ const user = await UserRepo.findById(1, { with: ['company', 'posts'] });
741
+ ```
742
+
743
+ ### Schema Helpers (zdb)
744
+
745
+ The `zdb` helpers wrap Zod schemas with database column metadata:
746
+
747
+ | Helper | Description | Options |
748
+ |--------|-------------|---------|
749
+ | `zdb.id()` | Primary key (bigint, auto-increment) | |
750
+ | `zdb.uuid()` | UUID primary key | |
751
+ | `zdb.string(opts)` | VARCHAR column | `maxLength`, `unique`, `index`, `nullable` |
752
+ | `zdb.text(opts)` | TEXT column | `nullable` |
753
+ | `zdb.integer(opts)` | INTEGER column | `nullable`, `default` |
754
+ | `zdb.bigint(opts)` | BIGINT column | `nullable` |
755
+ | `zdb.float(opts)` | FLOAT column | `nullable` |
756
+ | `zdb.decimal(opts)` | DECIMAL column | `precision`, `scale`, `nullable` |
757
+ | `zdb.boolean(opts)` | BOOLEAN column | `default`, `nullable` |
758
+ | `zdb.date(opts)` | DATE column | `nullable` |
759
+ | `zdb.datetime(opts)` | DATETIME column | `nullable` |
760
+ | `zdb.timestamp(opts)` | TIMESTAMP column | `auto: 'create'\|'update'`, `nullable` |
761
+ | `zdb.json(opts)` | JSON column | `nullable` |
762
+ | `zdb.enum(values, opts)` | ENUM column | `default`, `nullable` |
763
+ | `zdb.foreignKey(table, opts)` | Foreign key (bigint) | `referenceColumn`, `nullable` |
764
+ | `zdb.foreignUuid(table, opts)` | Foreign key (uuid) | `referenceColumn`, `nullable` |
765
+
766
+ ### Model Definition
767
+
768
+ ```javascript
769
+ const User = defineModel({
770
+ name: 'User', // Model name
771
+ table: 'users', // Database table
772
+ schema: UserSchema, // Zod schema
773
+ primaryKey: 'id', // Primary key column (default: 'id')
774
+
775
+ relations: {
776
+ // belongsTo: this model has foreign key
777
+ company: {
778
+ type: 'belongsTo',
779
+ model: () => Company,
780
+ foreignKey: 'company_id',
781
+ },
782
+ // hasMany: related model has foreign key
783
+ posts: {
784
+ type: 'hasMany',
785
+ model: () => Post,
786
+ foreignKey: 'user_id',
787
+ },
788
+ // hasOne: like hasMany but returns single record
789
+ profile: {
790
+ type: 'hasOne',
791
+ model: () => Profile,
792
+ foreignKey: 'user_id',
793
+ },
794
+ },
795
+
796
+ scopes: {
797
+ softDelete: true, // Use deleted_at column
798
+ timestamps: true, // Auto-manage created_at/updated_at
799
+ tenant: 'tenant_id', // Multi-tenant column (optional)
800
+ },
801
+ });
802
+ ```
803
+
804
+ ### Repository API
805
+
806
+ ```javascript
807
+ const db = createDatabase({ client: 'pg', connection: '...' });
808
+ const UserRepo = db.createRepository(User);
809
+
810
+ // Find by ID (with eager loading)
811
+ const user = await UserRepo.findById(1, { with: ['company', 'posts'] });
812
+
813
+ // Find one by conditions
814
+ const admin = await UserRepo.findOne({ email: 'admin@example.com' });
815
+
816
+ // Find all
817
+ const users = await UserRepo.findAll({ with: ['company'] });
818
+
819
+ // Create
820
+ const newUser = await UserRepo.create({
821
+ email: 'new@example.com',
822
+ name: 'New User',
823
+ });
824
+
825
+ // Create many
826
+ const users = await UserRepo.createMany([
827
+ { email: 'user1@test.com', name: 'User 1' },
828
+ { email: 'user2@test.com', name: 'User 2' },
829
+ ]);
830
+
831
+ // Update
832
+ const updated = await UserRepo.update(1, { name: 'Updated Name' });
833
+
834
+ // Update where
835
+ await UserRepo.updateWhere({ status: 'inactive' }, { status: 'banned' });
836
+
837
+ // Delete (soft delete if enabled)
838
+ await UserRepo.delete(1);
839
+
840
+ // Force delete (permanent)
841
+ await UserRepo.forceDelete(1);
842
+
843
+ // Restore soft-deleted
844
+ await UserRepo.restore(1);
845
+
846
+ // Count
847
+ const count = await UserRepo.count({ status: 'active' });
848
+
849
+ // Exists
850
+ const exists = await UserRepo.exists({ email: 'test@example.com' });
851
+ ```
852
+
853
+ ### Query Builder
854
+
855
+ ```javascript
856
+ const users = await UserRepo.query()
857
+ .where({ status: 'active' })
858
+ .where('created_at', '>', '2024-01-01')
859
+ .whereIn('role', ['admin', 'moderator'])
860
+ .whereNotNull('email_verified_at')
861
+ .orderBy('name', 'asc')
862
+ .orderBy('created_at', 'desc')
863
+ .limit(10)
864
+ .offset(20)
865
+ .with('company', 'posts')
866
+ .list();
867
+
868
+ // First result
869
+ const user = await UserRepo.query()
870
+ .where({ email: 'admin@example.com' })
871
+ .first();
872
+
873
+ // Count
874
+ const count = await UserRepo.query()
875
+ .where({ status: 'active' })
876
+ .count();
877
+
878
+ // Pagination
879
+ const result = await UserRepo.query()
880
+ .where({ status: 'active' })
881
+ .orderBy('created_at', 'desc')
882
+ .paginate(1, 20); // page 1, 20 per page
883
+
884
+ // result = { data: [...], total: 150, page: 1, perPage: 20, totalPages: 8 }
885
+
886
+ // Soft delete scopes
887
+ await UserRepo.query().withTrashed().list(); // Include deleted
888
+ await UserRepo.query().onlyTrashed().list(); // Only deleted
889
+
890
+ // Multi-tenant
891
+ await UserRepo.query().forTenant(tenantId).list();
892
+ ```
893
+
894
+ ### Transactions
895
+
896
+ ```javascript
897
+ await db.transaction(async (trx) => {
898
+ const userRepo = trx.createRepository(User);
899
+ const postRepo = trx.createRepository(Post);
900
+
901
+ const user = await userRepo.create({ email: 'new@test.com', name: 'New' });
902
+ await postRepo.create({ title: 'First Post', user_id: user.id });
903
+
904
+ // All changes committed on success
905
+ // Rolled back on error
906
+ });
907
+ ```
908
+
909
+ ### Migrations
910
+
911
+ **CLI Commands:**
912
+
913
+ ```bash
914
+ # Run pending migrations
915
+ webspresso db:migrate
916
+
917
+ # Rollback last batch
918
+ webspresso db:rollback
919
+
920
+ # Rollback all
921
+ webspresso db:rollback --all
922
+
923
+ # Show migration status
924
+ webspresso db:status
925
+
926
+ # Create empty migration
927
+ webspresso db:make create_posts_table
928
+
929
+ # Create migration from model (scaffolding)
930
+ webspresso db:make create_users_table --model User
931
+ ```
932
+
933
+ **Database Config File (`webspresso.db.js`):**
934
+
935
+ ```javascript
936
+ module.exports = {
937
+ client: 'pg', // or 'mysql2', 'better-sqlite3'
938
+ connection: process.env.DATABASE_URL,
939
+ migrations: {
940
+ directory: './migrations',
941
+ tableName: 'knex_migrations',
942
+ },
943
+
944
+ // Environment overrides
945
+ production: {
946
+ connection: process.env.DATABASE_URL,
947
+ pool: { min: 2, max: 10 },
948
+ },
949
+ };
950
+ ```
951
+
952
+ **Programmatic API:**
953
+
954
+ ```javascript
955
+ const db = createDatabase({
956
+ client: 'pg',
957
+ connection: process.env.DATABASE_URL,
958
+ migrations: { directory: './migrations' },
959
+ });
960
+
961
+ await db.migrate.latest(); // Run pending
962
+ await db.migrate.rollback(); // Rollback last batch
963
+ await db.migrate.rollback({ all: true }); // Rollback all
964
+ const status = await db.migrate.status(); // Get status
965
+ ```
966
+
967
+ ### Migration Scaffolding
968
+
969
+ Generate migration from model schema:
970
+
971
+ ```javascript
972
+ const { scaffoldMigration } = require('webspresso');
973
+
974
+ const migration = scaffoldMigration(User);
975
+ // Outputs complete migration file content with:
976
+ // - All columns with proper types
977
+ // - Indexes
978
+ // - Foreign key constraints
979
+ // - Up and down functions
980
+ ```
981
+
982
+ ### Supported Databases
983
+
984
+ Install the appropriate driver as a peer dependency:
985
+
986
+ ```bash
987
+ # PostgreSQL
988
+ npm install pg
989
+
990
+ # MySQL
991
+ npm install mysql2
992
+
993
+ # SQLite
994
+ npm install better-sqlite3
995
+ ```
996
+
997
+ ### Design Philosophy
998
+
999
+ | Boundary | Zod's Job | ORM's Job |
1000
+ |----------|-----------|-----------|
1001
+ | Schema definition | Type shape, validation rules | Column metadata extraction |
1002
+ | Input validation | `.parse()` / `.safeParse()` | Never - pass through to Zod |
1003
+ | Query building | N/A | Full ownership |
1004
+ | Relation resolution | N/A | Eager loading with batch queries |
1005
+ | Timestamps/SoftDelete | N/A | Auto-inject on operations |
1006
+
1007
+ **N+1 Prevention:** Relations are always loaded with batch `WHERE IN (...)` queries, never with individual queries per record.
1008
+
1009
+ ### Database Seeding
1010
+
1011
+ Generate fake data for testing and development using `@faker-js/faker`:
1012
+
1013
+ ```bash
1014
+ npm install @faker-js/faker
1015
+ ```
1016
+
1017
+ **Basic Usage:**
1018
+
1019
+ ```javascript
1020
+ const { faker } = require('@faker-js/faker');
1021
+ const db = createDatabase({ /* config */ });
1022
+
1023
+ const seeder = db.seeder(faker);
1024
+
1025
+ // Generate a single record
1026
+ const user = await seeder.factory('User').create();
1027
+
1028
+ // Generate multiple records
1029
+ const users = await seeder.factory('User').create(10);
1030
+
1031
+ // Generate without saving (for testing)
1032
+ const userData = seeder.factory('User').make();
1033
+ ```
1034
+
1035
+ **Define Factories with Defaults and States:**
1036
+
1037
+ ```javascript
1038
+ seeder.defineFactory('User', {
1039
+ // Default values
1040
+ defaults: {
1041
+ status: 'pending',
1042
+ },
1043
+
1044
+ // Custom generators
1045
+ generators: {
1046
+ username: (f) => f.internet.username().toLowerCase(),
1047
+ },
1048
+
1049
+ // Named states for variations
1050
+ states: {
1051
+ admin: { role: 'admin', status: 'active' },
1052
+ verified: (f) => ({
1053
+ status: 'verified',
1054
+ verified_at: f.date.past().toISOString(),
1055
+ }),
1056
+ },
1057
+ });
1058
+
1059
+ // Use states
1060
+ const admin = await seeder.factory('User').state('admin').create();
1061
+ const verified = await seeder.factory('User').state('verified').create();
1062
+ ```
1063
+
1064
+ **Smart Field Detection:**
1065
+
1066
+ The seeder automatically generates appropriate fake data based on column names:
1067
+
1068
+ | Field Name Pattern | Generated Data |
1069
+ |-------------------|----------------|
1070
+ | `email`, `*_email` | Valid email address |
1071
+ | `name`, `first_name`, `last_name` | Person names |
1072
+ | `username` | Username |
1073
+ | `title` | Short sentence |
1074
+ | `content`, `body`, `description` | Paragraphs |
1075
+ | `slug` | URL-safe slug |
1076
+ | `phone`, `tel` | Phone number |
1077
+ | `address`, `city`, `country` | Location data |
1078
+ | `price`, `amount`, `cost` | Decimal numbers |
1079
+ | `*_url`, `avatar`, `image` | URLs |
1080
+
1081
+ **Override and Custom Generators:**
1082
+
1083
+ ```javascript
1084
+ const user = await seeder.factory('User')
1085
+ .override({ email: 'test@example.com' })
1086
+ .generators({
1087
+ code: (f) => `USR-${f.string.alphanumeric(8)}`,
1088
+ })
1089
+ .create();
1090
+ ```
1091
+
1092
+ **Batch Seeding:**
1093
+
1094
+ ```javascript
1095
+ // Seed multiple models at once
1096
+ const results = await seeder.run([
1097
+ { model: 'Company', count: 5 },
1098
+ { model: 'User', count: 20, state: 'active' },
1099
+ { model: 'Post', count: 50 },
1100
+ ]);
1101
+
1102
+ // Access results
1103
+ console.log(results.Company); // Array of 5 companies
1104
+ console.log(results.User); // Array of 20 users
1105
+ ```
1106
+
1107
+ **Cleanup:**
1108
+
1109
+ ```javascript
1110
+ // Truncate specific tables
1111
+ await seeder.truncate('User');
1112
+ await seeder.truncate(['User', 'Post']);
1113
+
1114
+ // Clear all registered model tables
1115
+ await seeder.clearAll();
1116
+ ```
1117
+
1118
+ ### Schema Explorer Plugin
1119
+
1120
+ A plugin that exposes ORM schema information via API endpoints. Useful for frontend code generation, documentation, or admin tools.
1121
+
1122
+ **Setup:**
1123
+
1124
+ ```javascript
1125
+ const { createApp, schemaExplorerPlugin } = require('webspresso');
1126
+
1127
+ const app = createApp({
1128
+ plugins: [
1129
+ schemaExplorerPlugin({
1130
+ path: '/_schema', // Endpoint path (default: '/_schema')
1131
+ enabled: true, // Force enable (default: auto based on NODE_ENV)
1132
+ exclude: ['Secret'], // Exclude specific models
1133
+ includeColumns: true, // Include column metadata
1134
+ includeRelations: true, // Include relation metadata
1135
+ includeScopes: true, // Include scope configuration
1136
+ authorize: (req) => { // Custom authorization
1137
+ return req.headers['x-api-key'] === 'secret';
1138
+ },
1139
+ }),
1140
+ ],
1141
+ });
1142
+ ```
1143
+
1144
+ **Endpoints:**
1145
+
1146
+ - `GET /_schema` - List all models
1147
+ - `GET /_schema/:modelName` - Get single model details
1148
+ - `GET /_schema/openapi` - Export in OpenAPI 3.0 schema format
1149
+
1150
+ **Example Response (`GET /_schema`):**
1151
+
1152
+ ```json
1153
+ {
1154
+ "meta": {
1155
+ "version": "1.0.0",
1156
+ "generatedAt": "2024-01-01T12:00:00.000Z",
1157
+ "modelCount": 2
1158
+ },
1159
+ "models": [
1160
+ {
1161
+ "name": "User",
1162
+ "table": "users",
1163
+ "primaryKey": "id",
1164
+ "columns": [
1165
+ { "name": "id", "type": "bigint", "primary": true, "autoIncrement": true },
1166
+ { "name": "email", "type": "string", "unique": true },
1167
+ { "name": "company_id", "type": "bigint", "references": "companies" }
1168
+ ],
1169
+ "relations": [
1170
+ { "name": "company", "type": "belongsTo", "relatedModel": "Company", "foreignKey": "company_id" }
1171
+ ],
1172
+ "scopes": { "softDelete": true, "timestamps": true, "tenant": null }
1173
+ }
1174
+ ]
1175
+ }
1176
+ ```
1177
+
1178
+ **Plugin API (programmatic usage):**
1179
+
1180
+ ```javascript
1181
+ const plugin = schemaExplorerPlugin();
1182
+
1183
+ // Plugin API can be used by other plugins or in code
1184
+ const models = plugin.api.getModels(); // All models
1185
+ const user = plugin.api.getModel('User'); // Single model
1186
+ const names = plugin.api.getModelNames(); // Model names
1187
+ ```
624
1188
 
625
1189
  ## Development
626
1190