webspresso 0.0.60 → 0.0.61

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
@@ -1240,6 +1240,7 @@ The `zdb` helpers wrap Zod schemas with database column metadata:
1240
1240
  |--------|-------------|---------|
1241
1241
  | `zdb.id()` | Primary key (bigint, auto-increment) | |
1242
1242
  | `zdb.uuid()` | UUID primary key | |
1243
+ | `zdb.nanoid(opts)` | Nanoid primary key (URL-safe string, stored as VARCHAR) | `maxLength` (default `21`) |
1243
1244
  | `zdb.string(opts)` | VARCHAR column | `maxLength`, `unique`, `index`, `nullable` |
1244
1245
  | `zdb.text(opts)` | TEXT column | `nullable` |
1245
1246
  | `zdb.integer(opts)` | INTEGER column | `nullable`, `default` |
@@ -1255,6 +1256,9 @@ The `zdb` helpers wrap Zod schemas with database column metadata:
1255
1256
  | `zdb.enum(values, opts)` | ENUM column | `default`, `nullable` |
1256
1257
  | `zdb.foreignKey(table, opts)` | Foreign key (bigint) | `referenceColumn`, `nullable` |
1257
1258
  | `zdb.foreignUuid(table, opts)` | Foreign key (uuid) | `referenceColumn`, `nullable` |
1259
+ | `zdb.foreignNanoid(table, opts)` | Foreign key (nanoid string) | `referenceColumn`, `nullable`, `maxLength` (must match referenced PK) |
1260
+
1261
+ **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
1262
 
1259
1263
  ### Model Definition
1260
1264
 
@@ -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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "webspresso",
3
- "version": "0.0.60",
3
+ "version": "0.0.61",
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
@@ -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