webspresso 0.0.60 → 0.0.62

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
@@ -336,7 +336,7 @@ Creates and configures the Express app.
336
336
  - `pagesDir` (required): Path to pages directory
337
337
  - `viewsDir` (optional): Path to views/layouts directory
338
338
  - `publicDir` (optional): Path to public/static directory
339
- - `db` (optional): Database instance — exposed as `ctx.db` in plugin hooks (`register`, `onRoutesReady`) and in page `load`/`meta` functions
339
+ - `db` (optional): Database instance — exposed as `ctx.db` in plugin hooks (`register`, `onRoutesReady`) and in page `load`/`meta` functions; also registered for [`getDb()` / `getAppContext()`](#app-context-getdb-hasdb-getappcontext) below
340
340
  - `logging` (optional): Enable request logging (default: true in development)
341
341
  - `helmet` (optional): Helmet security configuration
342
342
  - `true` or `undefined`: Use default secure configuration
@@ -386,6 +386,54 @@ module.exports = {
386
386
  };
387
387
  ```
388
388
 
389
+ ### App context (`req.db`, `getDb`, `attachDbMiddleware`)
390
+
391
+ With **`createApp({ db })`**, file-based **API** routes (`pages/api/*.js`) get the same ORM instance on **`req.db`** before your **`middleware`** array and the handler run — no extra `require` in the handler:
392
+
393
+ ```javascript
394
+ module.exports = async function handler(req, res) {
395
+ if (!req.db) {
396
+ return res.status(503).json({ error: 'Database not configured' });
397
+ }
398
+ const posts = await req.db.getRepository('Post').query().limit(10).list();
399
+ res.json(posts);
400
+ };
401
+
402
+ module.exports.middleware = ['auth']; // can use req.db too
403
+ ```
404
+
405
+ The framework also registers that instance for **non-request** code (scripts, jobs) and for tests:
406
+
407
+ ```javascript
408
+ const { getDb, hasDb } = require('webspresso');
409
+ // hasDb() / getDb() — getDb() throws if createApp had no db
410
+ ```
411
+
412
+ For routes you add manually in **`setupRoutes`**, run **`attachDbMiddleware`** early so those handlers get `req.db`:
413
+
414
+ ```javascript
415
+ const { createApp, attachDbMiddleware } = require('webspresso');
416
+
417
+ createApp({
418
+ pagesDir: './pages',
419
+ db,
420
+ setupRoutes(app) {
421
+ app.use(attachDbMiddleware);
422
+ app.get('/custom/api', (req, res) => res.json({ ok: !!req.db }));
423
+ },
424
+ });
425
+ ```
426
+
427
+ | Export | Role |
428
+ |--------|------|
429
+ | `req.db` | Set on each API request when `createApp({ db })` was used (file-based API routes + after `attachDbMiddleware`) |
430
+ | `getDb()` | Same instance as `req.db`; **throws** if no `db` was passed to `createApp` |
431
+ | `hasDb()` | `true` if `createApp` was given `db` |
432
+ | `getAppContext()` | `{ db }` — `db` may be `null` |
433
+ | `attachDbMiddleware` | Express middleware to populate `req.db` for non–file-router routes |
434
+ | `resetAppContext()` | Clears context (mainly for tests) |
435
+ | `setAppContext(partial)` | Low-level merge; normally only `createApp` uses this |
436
+
389
437
  **Custom Error Pages:**
390
438
 
391
439
  ```javascript
@@ -1240,6 +1288,7 @@ The `zdb` helpers wrap Zod schemas with database column metadata:
1240
1288
  |--------|-------------|---------|
1241
1289
  | `zdb.id()` | Primary key (bigint, auto-increment) | |
1242
1290
  | `zdb.uuid()` | UUID primary key | |
1291
+ | `zdb.nanoid(opts)` | Nanoid primary key (URL-safe string, stored as VARCHAR) | `maxLength` (default `21`) |
1243
1292
  | `zdb.string(opts)` | VARCHAR column | `maxLength`, `unique`, `index`, `nullable` |
1244
1293
  | `zdb.text(opts)` | TEXT column | `nullable` |
1245
1294
  | `zdb.integer(opts)` | INTEGER column | `nullable`, `default` |
@@ -1255,6 +1304,9 @@ The `zdb` helpers wrap Zod schemas with database column metadata:
1255
1304
  | `zdb.enum(values, opts)` | ENUM column | `default`, `nullable` |
1256
1305
  | `zdb.foreignKey(table, opts)` | Foreign key (bigint) | `referenceColumn`, `nullable` |
1257
1306
  | `zdb.foreignUuid(table, opts)` | Foreign key (uuid) | `referenceColumn`, `nullable` |
1307
+ | `zdb.foreignNanoid(table, opts)` | Foreign key (nanoid string) | `referenceColumn`, `nullable`, `maxLength` (must match referenced PK) |
1308
+
1309
+ **Nanoid columns:** Migration scaffolding emits `table.string(column, maxLength)`. For a **nanoid primary key**, if you omit the primary key on `repository.create()`, Webspresso fills it with a cryptographically random ID using the same default alphabet as the [`nanoid`](https://github.com/ai/nanoid) package (implemented in-framework; no extra dependency). You can also call **`generateNanoid`** (exported from `webspresso`) anywhere you need the same generator.
1258
1310
 
1259
1311
  ### Model Definition
1260
1312
 
@@ -101,6 +101,13 @@ function columnToOpenApiType(meta) {
101
101
  result.format = 'uuid';
102
102
  break;
103
103
 
104
+ case 'nanoid':
105
+ result.type = 'string';
106
+ if (meta.maxLength) {
107
+ result.maxLength = meta.maxLength;
108
+ }
109
+ break;
110
+
104
111
  case 'json':
105
112
  result.type = 'object';
106
113
  break;
package/core/orm/index.js CHANGED
@@ -14,6 +14,7 @@ const { createSeeder } = require('./seeder');
14
14
  const { createScopeContext } = require('./scopes');
15
15
  const { ModelEvents, Hooks, HookCancellationError, createEventContext } = require('./events');
16
16
  const { omitHiddenColumns, sanitizeForOutput } = require('./utils');
17
+ const { generateNanoid } = require('./utils/nanoid');
17
18
 
18
19
  /**
19
20
  * Create a database instance
@@ -273,6 +274,7 @@ module.exports = {
273
274
  // Column utilities
274
275
  extractColumnsFromSchema,
275
276
  getColumnMeta,
277
+ generateNanoid,
276
278
  // Output sanitization (exclude hidden columns from API/templates)
277
279
  omitHiddenColumns,
278
280
  sanitizeForOutput,
@@ -164,6 +164,19 @@ function generateColumnLine(columnName, meta) {
164
164
  }
165
165
  break;
166
166
 
167
+ case 'nanoid': {
168
+ const nanoLen = meta.maxLength || 21;
169
+ if (meta.primary) {
170
+ parts.push(`table.string('${columnName}', ${nanoLen})`);
171
+ } else if (meta.references) {
172
+ parts.push(`table.string('${columnName}', ${nanoLen})`);
173
+ fkLine = `table.foreign('${columnName}').references('${meta.referenceColumn || 'id'}').inTable('${meta.references}');`;
174
+ } else {
175
+ parts.push(`table.string('${columnName}', ${nanoLen})`);
176
+ }
177
+ break;
178
+ }
179
+
167
180
  default:
168
181
  parts.push(`table.string('${columnName}')`);
169
182
  }
@@ -359,6 +359,30 @@ function createSchemaHelpers(z) {
359
359
  }, z);
360
360
  },
361
361
 
362
+ /**
363
+ * Nanoid primary key column (URL-safe string, default length 21)
364
+ * @param {Partial<import('./types').ColumnMeta>} [options={}]
365
+ * @returns {SchemaBuilder}
366
+ */
367
+ nanoid(options = {}) {
368
+ const { maxLength = 21, ...rest } = options;
369
+ const schema = z
370
+ .string()
371
+ .length(maxLength)
372
+ .regex(/^[A-Za-z0-9_-]+$/)
373
+ .optional();
374
+ return createSchemaBuilder(
375
+ schema,
376
+ {
377
+ type: 'nanoid',
378
+ primary: true,
379
+ maxLength,
380
+ ...rest,
381
+ },
382
+ z
383
+ );
384
+ },
385
+
362
386
  /**
363
387
  * String column (varchar)
364
388
  * @param {Partial<import('./types').ColumnMeta>} [options={}]
@@ -667,6 +691,36 @@ function createSchemaHelpers(z) {
667
691
  ...rest,
668
692
  }, z);
669
693
  },
694
+
695
+ /**
696
+ * Nanoid foreign key column
697
+ * @param {string} references - Referenced table name
698
+ * @param {Partial<import('./types').ColumnMeta>} [options={}]
699
+ * @returns {SchemaBuilder}
700
+ */
701
+ foreignNanoid(references, options = {}) {
702
+ const {
703
+ referenceColumn = 'id',
704
+ nullable = false,
705
+ maxLength = 21,
706
+ ...rest
707
+ } = options;
708
+ let schema = z
709
+ .string()
710
+ .length(maxLength)
711
+ .regex(/^[A-Za-z0-9_-]+$/);
712
+ if (nullable) {
713
+ schema = schema.nullable().optional();
714
+ }
715
+ return createSchemaBuilder(schema, {
716
+ type: 'nanoid',
717
+ references,
718
+ referenceColumn,
719
+ nullable,
720
+ maxLength,
721
+ ...rest,
722
+ }, z);
723
+ },
670
724
  };
671
725
 
672
726
  return helpers;
@@ -4,6 +4,8 @@
4
4
  * @module core/orm/scopes
5
5
  */
6
6
 
7
+ const { generateNanoid } = require('./utils/nanoid');
8
+
7
9
  /**
8
10
  * Apply soft delete scope to a query builder
9
11
  * @param {import('knex').Knex.QueryBuilder} qb - Knex query builder
@@ -115,6 +117,29 @@ function applyInsertTenant(data, context, model) {
115
117
  };
116
118
  }
117
119
 
120
+ /**
121
+ * Generate nanoid primary key when missing on insert
122
+ * @param {Object} data - Data to insert
123
+ * @param {import('./types').ModelDefinition} model - Model definition
124
+ * @returns {Object}
125
+ */
126
+ function applyInsertNanoidPrimary(data, model) {
127
+ const pk = model.primaryKey;
128
+ const meta = model.columns && model.columns.get(pk);
129
+ if (!meta || meta.type !== 'nanoid') {
130
+ return data;
131
+ }
132
+ const val = data[pk];
133
+ if (val !== undefined && val !== null && val !== '') {
134
+ return data;
135
+ }
136
+ const len = meta.maxLength || 21;
137
+ return {
138
+ ...data,
139
+ [pk]: generateNanoid(len),
140
+ };
141
+ }
142
+
118
143
  /**
119
144
  * Get soft delete data (for UPDATE instead of DELETE)
120
145
  * @returns {Object} Soft delete update data
@@ -142,6 +167,7 @@ function applyInsertModifiers(data, context, model) {
142
167
  let modified = { ...data };
143
168
  modified = applyInsertTimestamps(modified, model);
144
169
  modified = applyInsertTenant(modified, context, model);
170
+ modified = applyInsertNanoidPrimary(modified, model);
145
171
  return modified;
146
172
  }
147
173
 
@@ -174,6 +200,7 @@ module.exports = {
174
200
  applyInsertTimestamps,
175
201
  applyUpdateTimestamps,
176
202
  applyInsertTenant,
203
+ applyInsertNanoidPrimary,
177
204
  applyInsertModifiers,
178
205
  applyUpdateModifiers,
179
206
  getSoftDeleteData,
@@ -5,6 +5,7 @@
5
5
  */
6
6
 
7
7
  const { getModel, getAllModels } = require('./model');
8
+ const { generateNanoid } = require('./utils/nanoid');
8
9
 
9
10
  /**
10
11
  * @typedef {Object} SeederOptions
@@ -189,6 +190,9 @@ function createSeeder(faker, knex) {
189
190
  case 'uuid':
190
191
  return faker.string.uuid();
191
192
 
193
+ case 'nanoid':
194
+ return generateNanoid(meta.maxLength || 21);
195
+
192
196
  case 'json':
193
197
  return { key: faker.lorem.word(), value: faker.lorem.sentence() };
194
198
 
package/core/orm/types.js CHANGED
@@ -9,7 +9,7 @@
9
9
  // ============================================================================
10
10
 
11
11
  /**
12
- * @typedef {'id'|'string'|'text'|'integer'|'bigint'|'float'|'decimal'|'boolean'|'date'|'datetime'|'timestamp'|'json'|'array'|'enum'|'uuid'} ColumnType
12
+ * @typedef {'id'|'string'|'text'|'integer'|'bigint'|'float'|'decimal'|'boolean'|'date'|'datetime'|'timestamp'|'json'|'array'|'enum'|'uuid'|'nanoid'} ColumnType
13
13
  */
14
14
 
15
15
  /**
@@ -0,0 +1,30 @@
1
+ /**
2
+ * URL-safe ID generation compatible with the default nanoid alphabet/algorithm.
3
+ * @module core/orm/utils/nanoid
4
+ */
5
+
6
+ const crypto = require('crypto');
7
+
8
+ /** Same 64-char alphabet as npm `nanoid` default (URL-safe). */
9
+ const URL_ALPHABET =
10
+ 'useandom-26T198340PX75pxJACKVERYMINDBUSHWOLF_GQZbfghjklqvwyzrict';
11
+
12
+ /**
13
+ * @param {number} [size=21] - Id length (default matches nanoid)
14
+ * @returns {string}
15
+ */
16
+ function generateNanoid(size = 21) {
17
+ const n = Math.max(1, Math.floor(size));
18
+ const bytes = new Uint8Array(n);
19
+ crypto.randomFillSync(bytes);
20
+ let id = '';
21
+ for (let i = 0; i < n; i++) {
22
+ id += URL_ALPHABET[bytes[i] & 63];
23
+ }
24
+ return id;
25
+ }
26
+
27
+ module.exports = {
28
+ generateNanoid,
29
+ URL_ALPHABET,
30
+ };
package/index.js CHANGED
@@ -3,6 +3,14 @@
3
3
  */
4
4
 
5
5
  const { createApp } = require('./src/server');
6
+ const {
7
+ attachDbMiddleware,
8
+ getAppContext,
9
+ getDb,
10
+ hasDb,
11
+ resetAppContext,
12
+ setAppContext,
13
+ } = require('./src/app-context');
6
14
  const {
7
15
  mountPages,
8
16
  filePathToRoute,
@@ -35,6 +43,13 @@ const { schemaExplorerPlugin, adminPanelPlugin, siteAnalyticsPlugin, auditLogPlu
35
43
  module.exports = {
36
44
  // Main API
37
45
  createApp,
46
+
47
+ attachDbMiddleware,
48
+ getAppContext,
49
+ getDb,
50
+ hasDb,
51
+ resetAppContext,
52
+ setAppContext,
38
53
 
39
54
  // Router utilities (for advanced use)
40
55
  mountPages,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "webspresso",
3
- "version": "0.0.60",
3
+ "version": "0.0.62",
4
4
  "description": "Minimal, production-ready SSR framework for Node.js with file-based routing, Nunjucks templating, built-in i18n, and CLI tooling",
5
5
  "main": "index.js",
6
6
  "bin": {
@@ -1083,6 +1083,7 @@ function getFieldRenderer(col, modelMeta) {
1083
1083
  json: 'json',
1084
1084
  array: 'array',
1085
1085
  uuid: 'string',
1086
+ nanoid: 'string',
1086
1087
  };
1087
1088
  return FieldRenderers[typeMap[col.type] || 'string'];
1088
1089
  }
@@ -68,6 +68,7 @@ function initializeDefaultRenderers() {
68
68
  registerFieldRenderer('timestamp', basicRenderers.DateTimeField);
69
69
  registerFieldRenderer('enum', basicRenderers.SelectField);
70
70
  registerFieldRenderer('uuid', basicRenderers.TextField);
71
+ registerFieldRenderer('nanoid', basicRenderers.TextField);
71
72
  registerFieldRenderer('id', basicRenderers.NumberField);
72
73
 
73
74
  // Complex types
@@ -0,0 +1,75 @@
1
+ /**
2
+ * Process-wide app context set by createApp() — use from API handlers, jobs, etc.
3
+ * @module src/app-context
4
+ */
5
+
6
+ /** @type {{ db: object|null }} */
7
+ let context = {
8
+ db: null,
9
+ };
10
+
11
+ /**
12
+ * Merge into the current context (typically called once from createApp).
13
+ * @param {{ db?: object|null }} partial
14
+ */
15
+ function setAppContext(partial) {
16
+ context = { ...context, ...partial };
17
+ }
18
+
19
+ /**
20
+ * @returns {{ db: object|null }}
21
+ */
22
+ function getAppContext() {
23
+ return context;
24
+ }
25
+
26
+ /**
27
+ * Database instance passed to createApp({ db }) (Knex + ORM helpers).
28
+ * @returns {object}
29
+ * @throws {Error} If createApp was not given a db instance
30
+ */
31
+ function getDb() {
32
+ if (!context.db) {
33
+ throw new Error(
34
+ 'No database registered. Create a DB with createDatabase(), then pass it to createApp({ db }). ' +
35
+ 'Or use hasDb() before calling getDb().'
36
+ );
37
+ }
38
+ return context.db;
39
+ }
40
+
41
+ /**
42
+ * @returns {boolean}
43
+ */
44
+ function hasDb() {
45
+ return context.db != null;
46
+ }
47
+
48
+ /**
49
+ * Clear context (e.g. between tests).
50
+ */
51
+ function resetAppContext() {
52
+ context = { db: null };
53
+ }
54
+
55
+ /**
56
+ * Express middleware: sets `req.db` from registered app context.
57
+ * File-based `pages/api/*` routes attach this automatically; use in `setupRoutes`
58
+ * for manually registered handlers that need `req.db`.
59
+ * @type {import('express').RequestHandler}
60
+ */
61
+ function attachDbMiddleware(req, res, next) {
62
+ if (context.db != null) {
63
+ req.db = context.db;
64
+ }
65
+ next();
66
+ }
67
+
68
+ module.exports = {
69
+ setAppContext,
70
+ getAppContext,
71
+ getDb,
72
+ hasDb,
73
+ resetAppContext,
74
+ attachDbMiddleware,
75
+ };
@@ -397,6 +397,11 @@ function mountPages(app, options) {
397
397
 
398
398
  app[route.method](route.routePath, async (req, res, next) => {
399
399
  try {
400
+ // Same instance as createApp({ db }) / getAppContext().db — available to handler & route middleware
401
+ if (db != null) {
402
+ req.db = db;
403
+ }
404
+
400
405
  // Reload handler in dev mode
401
406
  if (isDev && require.cache[require.resolve(route.fullPath)]) {
402
407
  delete require.cache[require.resolve(route.fullPath)];
package/src/server.js CHANGED
@@ -8,6 +8,7 @@ const helmet = require('helmet');
8
8
  const nunjucks = require('nunjucks');
9
9
  const timeout = require('connect-timeout');
10
10
 
11
+ const { setAppContext } = require('./app-context');
11
12
  const { mountPages } = require('./file-router');
12
13
  const { configureAssets, createHelpers, getScriptInjector } = require('./helpers');
13
14
  const { createPluginManager } = require('./plugin-manager');
@@ -200,6 +201,8 @@ function createApp(options = {}) {
200
201
  if (!pagesDir) {
201
202
  throw new Error('pagesDir is required');
202
203
  }
204
+
205
+ setAppContext({ db: options.db ?? null });
203
206
 
204
207
  const app = express();
205
208
 
@@ -143,13 +143,14 @@ Analytics plugin adds `fsy.analyticsHead`, `fsy.verificationTags`, etc., when co
143
143
 
144
144
  ## 9. ORM overview
145
145
 
146
- **Define schema** with **`zdb`** (`zdb.id()`, `zdb.string({...})`, `zdb.foreignKey`, `zdb.timestamp`, `zdb.json`, …).
146
+ **Define schema** with **`zdb`** (`zdb.id()`, `zdb.uuid()`, `zdb.nanoid()`, `zdb.string({...})`, `zdb.foreignKey`, `zdb.foreignUuid`, `zdb.foreignNanoid`, `zdb.timestamp`, `zdb.json`, …).
147
147
 
148
148
  **Define model** with **`defineModel({ name, table, schema, relations, scopes, hidden, admin })`**.
149
149
 
150
150
  - **Relations:** `belongsTo`, `hasMany`, `hasOne` with `model: () => OtherModel`.
151
151
  - **Scopes:** `softDelete`, `timestamps`, optional `tenant` column.
152
152
  - **`hidden`:** columns never exposed in admin/API (e.g. `password_hash`).
153
+ - **Nanoid PK:** `zdb.nanoid()` / `zdb.nanoid({ maxLength: 12 })` — string primary key; migrations use `string(length)`. On **`create()`**, omitting the PK auto-fills a URL-safe id (built-in generator, same alphabet as `nanoid`). Use **`zdb.foreignNanoid('table', { maxLength })`** when the parent uses nanoid PKs; **`generateNanoid`** is exported from `webspresso` for manual ids.
153
154
 
154
155
  **Database:** `createDatabase({ client, connection, models: './models' })` — auto-loads `models/*.js` (ignore `_prefix`).
155
156
 
@@ -161,7 +162,7 @@ Analytics plugin adds `fsy.analyticsHead`, `fsy.verificationTags`, etc., when co
161
162
 
162
163
  **Transactions:** `db.transaction(async (trx) => { trx.getRepository('User') })`.
163
164
 
164
- Pass **`db`** into **`createApp({ db })`** so **`ctx.db`** works in pages and plugins.
165
+ Pass **`db`** into **`createApp({ db })`** so **`ctx.db`** works in pages and plugins. **`pages/api/`** handlers receive **`req.db`** (and route **`middleware`** runs after it). Outside requests, use **`getDb()`** / **`hasDb()`**; for **`setupRoutes`**-only routes, use **`attachDbMiddleware`**.
165
166
 
166
167
  ---
167
168