webspresso 0.0.6 → 0.0.8

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.
@@ -0,0 +1,416 @@
1
+ /**
2
+ * Webspresso ORM - Schema Helpers
3
+ * Wraps Zod with database metadata helpers
4
+ * @module core/orm/schema-helpers
5
+ */
6
+
7
+ const METADATA_MARKER = '__wdb__';
8
+
9
+ /**
10
+ * Encode column metadata into a JSON string for Zod .describe()
11
+ * @param {import('./types').ColumnMeta} meta - Column metadata
12
+ * @returns {string} JSON-encoded metadata
13
+ */
14
+ function encodeColumnMeta(meta) {
15
+ return JSON.stringify({ [METADATA_MARKER]: true, meta });
16
+ }
17
+
18
+ /**
19
+ * Decode column metadata from Zod .describe() string
20
+ * @param {string} description - Zod description string
21
+ * @returns {import('./types').ColumnMeta|null} Decoded metadata or null
22
+ */
23
+ function decodeColumnMeta(description) {
24
+ if (!description) return null;
25
+ try {
26
+ const parsed = JSON.parse(description);
27
+ if (parsed && parsed[METADATA_MARKER]) {
28
+ return parsed.meta;
29
+ }
30
+ } catch {
31
+ // Not our metadata, ignore
32
+ }
33
+ return null;
34
+ }
35
+
36
+ /**
37
+ * Check if a Zod schema has ORM column metadata
38
+ * @param {import('zod').ZodTypeAny} schema - Zod schema
39
+ * @returns {boolean}
40
+ */
41
+ function hasColumnMeta(schema) {
42
+ return decodeColumnMeta(schema.description) !== null;
43
+ }
44
+
45
+ /**
46
+ * Get column metadata from a Zod schema
47
+ * @param {import('zod').ZodTypeAny} schema - Zod schema
48
+ * @returns {import('./types').ColumnMeta|null}
49
+ */
50
+ function getColumnMeta(schema) {
51
+ return decodeColumnMeta(schema.description);
52
+ }
53
+
54
+ /**
55
+ * Create schema helpers bound to a Zod instance
56
+ * @param {typeof import('zod').z} z - Zod instance
57
+ * @returns {Object} Schema helpers (zdb)
58
+ */
59
+ function createSchemaHelpers(z) {
60
+ /**
61
+ * Apply metadata to a Zod schema
62
+ * @param {import('zod').ZodTypeAny} schema - Base Zod schema
63
+ * @param {import('./types').ColumnMeta} meta - Column metadata
64
+ * @returns {import('zod').ZodTypeAny}
65
+ */
66
+ function withMeta(schema, meta) {
67
+ return schema.describe(encodeColumnMeta(meta));
68
+ }
69
+
70
+ return {
71
+ /**
72
+ * Primary key column (bigint, auto-increment)
73
+ * @param {Partial<import('./types').ColumnMeta>} [options={}]
74
+ * @returns {import('zod').ZodNumber}
75
+ */
76
+ id(options = {}) {
77
+ const schema = z.number().int().positive().optional();
78
+ return withMeta(schema, {
79
+ type: 'bigint',
80
+ primary: true,
81
+ autoIncrement: true,
82
+ ...options,
83
+ });
84
+ },
85
+
86
+ /**
87
+ * UUID primary key column
88
+ * @param {Partial<import('./types').ColumnMeta>} [options={}]
89
+ * @returns {import('zod').ZodString}
90
+ */
91
+ uuid(options = {}) {
92
+ const schema = z.string().uuid().optional();
93
+ return withMeta(schema, {
94
+ type: 'uuid',
95
+ primary: true,
96
+ ...options,
97
+ });
98
+ },
99
+
100
+ /**
101
+ * String column (varchar)
102
+ * @param {Partial<import('./types').ColumnMeta>} [options={}]
103
+ * @returns {import('zod').ZodString}
104
+ */
105
+ string(options = {}) {
106
+ const { maxLength = 255, nullable = false, ...rest } = options;
107
+ let schema = z.string().max(maxLength);
108
+ if (nullable) {
109
+ schema = schema.nullable().optional();
110
+ }
111
+ return withMeta(schema, {
112
+ type: 'string',
113
+ maxLength,
114
+ nullable,
115
+ ...rest,
116
+ });
117
+ },
118
+
119
+ /**
120
+ * Text column (unlimited length)
121
+ * @param {Partial<import('./types').ColumnMeta>} [options={}]
122
+ * @returns {import('zod').ZodString}
123
+ */
124
+ text(options = {}) {
125
+ const { nullable = false, ...rest } = options;
126
+ let schema = z.string();
127
+ if (nullable) {
128
+ schema = schema.nullable().optional();
129
+ }
130
+ return withMeta(schema, {
131
+ type: 'text',
132
+ nullable,
133
+ ...rest,
134
+ });
135
+ },
136
+
137
+ /**
138
+ * Integer column
139
+ * @param {Partial<import('./types').ColumnMeta>} [options={}]
140
+ * @returns {import('zod').ZodNumber}
141
+ */
142
+ integer(options = {}) {
143
+ const { nullable = false, ...rest } = options;
144
+ let schema = z.number().int();
145
+ if (nullable) {
146
+ schema = schema.nullable().optional();
147
+ }
148
+ return withMeta(schema, {
149
+ type: 'integer',
150
+ nullable,
151
+ ...rest,
152
+ });
153
+ },
154
+
155
+ /**
156
+ * Big integer column
157
+ * @param {Partial<import('./types').ColumnMeta>} [options={}]
158
+ * @returns {import('zod').ZodNumber}
159
+ */
160
+ bigint(options = {}) {
161
+ const { nullable = false, ...rest } = options;
162
+ let schema = z.number().int();
163
+ if (nullable) {
164
+ schema = schema.nullable().optional();
165
+ }
166
+ return withMeta(schema, {
167
+ type: 'bigint',
168
+ nullable,
169
+ ...rest,
170
+ });
171
+ },
172
+
173
+ /**
174
+ * Float column
175
+ * @param {Partial<import('./types').ColumnMeta>} [options={}]
176
+ * @returns {import('zod').ZodNumber}
177
+ */
178
+ float(options = {}) {
179
+ const { nullable = false, ...rest } = options;
180
+ let schema = z.number();
181
+ if (nullable) {
182
+ schema = schema.nullable().optional();
183
+ }
184
+ return withMeta(schema, {
185
+ type: 'float',
186
+ nullable,
187
+ ...rest,
188
+ });
189
+ },
190
+
191
+ /**
192
+ * Decimal column
193
+ * @param {Partial<import('./types').ColumnMeta>} [options={}]
194
+ * @returns {import('zod').ZodNumber}
195
+ */
196
+ decimal(options = {}) {
197
+ const { precision = 10, scale = 2, nullable = false, ...rest } = options;
198
+ let schema = z.number();
199
+ if (nullable) {
200
+ schema = schema.nullable().optional();
201
+ }
202
+ return withMeta(schema, {
203
+ type: 'decimal',
204
+ precision,
205
+ scale,
206
+ nullable,
207
+ ...rest,
208
+ });
209
+ },
210
+
211
+ /**
212
+ * Boolean column
213
+ * @param {Partial<import('./types').ColumnMeta>} [options={}]
214
+ * @returns {import('zod').ZodBoolean}
215
+ */
216
+ boolean(options = {}) {
217
+ const { nullable = false, default: defaultValue, ...rest } = options;
218
+ let schema = z.boolean();
219
+ if (defaultValue !== undefined) {
220
+ schema = schema.default(defaultValue);
221
+ }
222
+ if (nullable) {
223
+ schema = schema.nullable().optional();
224
+ }
225
+ return withMeta(schema, {
226
+ type: 'boolean',
227
+ nullable,
228
+ default: defaultValue,
229
+ ...rest,
230
+ });
231
+ },
232
+
233
+ /**
234
+ * Date column (date only, no time)
235
+ * @param {Partial<import('./types').ColumnMeta>} [options={}]
236
+ * @returns {import('zod').ZodDate}
237
+ */
238
+ date(options = {}) {
239
+ const { nullable = false, ...rest } = options;
240
+ let schema = z.coerce.date();
241
+ if (nullable) {
242
+ schema = schema.nullable().optional();
243
+ }
244
+ return withMeta(schema, {
245
+ type: 'date',
246
+ nullable,
247
+ ...rest,
248
+ });
249
+ },
250
+
251
+ /**
252
+ * Datetime column
253
+ * @param {Partial<import('./types').ColumnMeta>} [options={}]
254
+ * @returns {import('zod').ZodDate}
255
+ */
256
+ datetime(options = {}) {
257
+ const { nullable = false, ...rest } = options;
258
+ let schema = z.coerce.date();
259
+ if (nullable) {
260
+ schema = schema.nullable().optional();
261
+ }
262
+ return withMeta(schema, {
263
+ type: 'datetime',
264
+ nullable,
265
+ ...rest,
266
+ });
267
+ },
268
+
269
+ /**
270
+ * Timestamp column (with optional auto behavior)
271
+ * @param {Partial<import('./types').ColumnMeta>} [options={}]
272
+ * @returns {import('zod').ZodDate}
273
+ */
274
+ timestamp(options = {}) {
275
+ const { nullable = false, auto, ...rest } = options;
276
+ let schema = z.coerce.date();
277
+ // Auto timestamps are always optional in input
278
+ if (nullable || auto) {
279
+ schema = schema.nullable().optional();
280
+ }
281
+ return withMeta(schema, {
282
+ type: 'timestamp',
283
+ nullable: nullable || !!auto,
284
+ auto,
285
+ ...rest,
286
+ });
287
+ },
288
+
289
+ /**
290
+ * JSON column
291
+ * @param {Partial<import('./types').ColumnMeta>} [options={}]
292
+ * @returns {import('zod').ZodUnknown}
293
+ */
294
+ json(options = {}) {
295
+ const { nullable = false, ...rest } = options;
296
+ let schema = z.unknown();
297
+ if (nullable) {
298
+ schema = schema.nullable().optional();
299
+ }
300
+ return withMeta(schema, {
301
+ type: 'json',
302
+ nullable,
303
+ ...rest,
304
+ });
305
+ },
306
+
307
+ /**
308
+ * Enum column
309
+ * @param {string[]} values - Allowed enum values
310
+ * @param {Partial<import('./types').ColumnMeta>} [options={}]
311
+ * @returns {import('zod').ZodEnum}
312
+ */
313
+ enum(values, options = {}) {
314
+ const { nullable = false, default: defaultValue, ...rest } = options;
315
+ let schema = z.enum(values);
316
+ if (defaultValue !== undefined) {
317
+ schema = schema.default(defaultValue);
318
+ }
319
+ if (nullable) {
320
+ schema = schema.nullable().optional();
321
+ }
322
+ return withMeta(schema, {
323
+ type: 'enum',
324
+ enumValues: values,
325
+ nullable,
326
+ default: defaultValue,
327
+ ...rest,
328
+ });
329
+ },
330
+
331
+ /**
332
+ * Foreign key column (references another table)
333
+ * @param {string} references - Referenced table name
334
+ * @param {Partial<import('./types').ColumnMeta>} [options={}]
335
+ * @returns {import('zod').ZodNumber}
336
+ */
337
+ foreignKey(references, options = {}) {
338
+ const { referenceColumn = 'id', nullable = false, ...rest } = options;
339
+ let schema = z.number().int().positive();
340
+ if (nullable) {
341
+ schema = schema.nullable().optional();
342
+ }
343
+ return withMeta(schema, {
344
+ type: 'bigint',
345
+ references,
346
+ referenceColumn,
347
+ nullable,
348
+ ...rest,
349
+ });
350
+ },
351
+
352
+ /**
353
+ * UUID foreign key column
354
+ * @param {string} references - Referenced table name
355
+ * @param {Partial<import('./types').ColumnMeta>} [options={}]
356
+ * @returns {import('zod').ZodString}
357
+ */
358
+ foreignUuid(references, options = {}) {
359
+ const { referenceColumn = 'id', nullable = false, ...rest } = options;
360
+ let schema = z.string().uuid();
361
+ if (nullable) {
362
+ schema = schema.nullable().optional();
363
+ }
364
+ return withMeta(schema, {
365
+ type: 'uuid',
366
+ references,
367
+ referenceColumn,
368
+ nullable,
369
+ ...rest,
370
+ });
371
+ },
372
+ };
373
+ }
374
+
375
+ /**
376
+ * Extract all column metadata from a Zod object schema
377
+ * @param {import('zod').ZodObject} schema - Zod object schema
378
+ * @returns {Map<string, import('./types').ColumnMeta>}
379
+ */
380
+ function extractColumnsFromSchema(schema) {
381
+ const columns = new Map();
382
+ const shape = schema.shape;
383
+
384
+ for (const [key, fieldSchema] of Object.entries(shape)) {
385
+ // Unwrap optional/nullable wrappers to get to the base schema
386
+ let current = fieldSchema;
387
+ while (current._def) {
388
+ if (current._def.innerType) {
389
+ current = current._def.innerType;
390
+ } else if (current._def.schema) {
391
+ current = current._def.schema;
392
+ } else {
393
+ break;
394
+ }
395
+ }
396
+
397
+ // Check the original field schema for metadata
398
+ const meta = getColumnMeta(fieldSchema) || getColumnMeta(current);
399
+ if (meta) {
400
+ columns.set(key, meta);
401
+ }
402
+ }
403
+
404
+ return columns;
405
+ }
406
+
407
+ module.exports = {
408
+ createSchemaHelpers,
409
+ encodeColumnMeta,
410
+ decodeColumnMeta,
411
+ hasColumnMeta,
412
+ getColumnMeta,
413
+ extractColumnsFromSchema,
414
+ METADATA_MARKER,
415
+ };
416
+
@@ -0,0 +1,183 @@
1
+ /**
2
+ * Webspresso ORM - Scope Modifiers
3
+ * Global scopes for soft delete, timestamps, and multi-tenancy
4
+ * @module core/orm/scopes
5
+ */
6
+
7
+ /**
8
+ * Apply soft delete scope to a query builder
9
+ * @param {import('knex').Knex.QueryBuilder} qb - Knex query builder
10
+ * @param {import('./types').ScopeContext} context - Scope context
11
+ * @param {import('./types').ModelDefinition} model - Model definition
12
+ * @returns {import('knex').Knex.QueryBuilder}
13
+ */
14
+ function applySoftDeleteScope(qb, context, model) {
15
+ if (!model.scopes.softDelete) {
16
+ return qb;
17
+ }
18
+
19
+ // If onlyTrashed is set, only return deleted records
20
+ if (context.onlyTrashed) {
21
+ return qb.whereNotNull('deleted_at');
22
+ }
23
+
24
+ // If withTrashed is set, return all records (no filter)
25
+ if (context.withTrashed) {
26
+ return qb;
27
+ }
28
+
29
+ // Default: only return non-deleted records
30
+ return qb.whereNull('deleted_at');
31
+ }
32
+
33
+ /**
34
+ * Apply tenant scope to a query builder
35
+ * @param {import('knex').Knex.QueryBuilder} qb - Knex query builder
36
+ * @param {import('./types').ScopeContext} context - Scope context
37
+ * @param {import('./types').ModelDefinition} model - Model definition
38
+ * @returns {import('knex').Knex.QueryBuilder}
39
+ */
40
+ function applyTenantScope(qb, context, model) {
41
+ const tenantColumn = model.scopes.tenant;
42
+ if (!tenantColumn || context.tenantId === undefined) {
43
+ return qb;
44
+ }
45
+
46
+ return qb.where(tenantColumn, context.tenantId);
47
+ }
48
+
49
+ /**
50
+ * Apply all global scopes to a query builder
51
+ * @param {import('knex').Knex.QueryBuilder} qb - Knex query builder
52
+ * @param {import('./types').ScopeContext} context - Scope context
53
+ * @param {import('./types').ModelDefinition} model - Model definition
54
+ * @returns {import('knex').Knex.QueryBuilder}
55
+ */
56
+ function applyScopes(qb, context, model) {
57
+ let builder = qb;
58
+ builder = applySoftDeleteScope(builder, context, model);
59
+ builder = applyTenantScope(builder, context, model);
60
+ return builder;
61
+ }
62
+
63
+ /**
64
+ * Apply timestamp values for insert operations
65
+ * @param {Object} data - Data to insert
66
+ * @param {import('./types').ModelDefinition} model - Model definition
67
+ * @returns {Object} Data with timestamps applied
68
+ */
69
+ function applyInsertTimestamps(data, model) {
70
+ if (!model.scopes.timestamps) {
71
+ return data;
72
+ }
73
+
74
+ const now = new Date();
75
+ return {
76
+ ...data,
77
+ created_at: data.created_at || now,
78
+ updated_at: data.updated_at || now,
79
+ };
80
+ }
81
+
82
+ /**
83
+ * Apply timestamp values for update operations
84
+ * @param {Object} data - Data to update
85
+ * @param {import('./types').ModelDefinition} model - Model definition
86
+ * @returns {Object} Data with timestamps applied
87
+ */
88
+ function applyUpdateTimestamps(data, model) {
89
+ if (!model.scopes.timestamps) {
90
+ return data;
91
+ }
92
+
93
+ return {
94
+ ...data,
95
+ updated_at: new Date(),
96
+ };
97
+ }
98
+
99
+ /**
100
+ * Apply tenant ID for insert operations
101
+ * @param {Object} data - Data to insert
102
+ * @param {import('./types').ScopeContext} context - Scope context
103
+ * @param {import('./types').ModelDefinition} model - Model definition
104
+ * @returns {Object} Data with tenant ID applied
105
+ */
106
+ function applyInsertTenant(data, context, model) {
107
+ const tenantColumn = model.scopes.tenant;
108
+ if (!tenantColumn || context.tenantId === undefined) {
109
+ return data;
110
+ }
111
+
112
+ return {
113
+ ...data,
114
+ [tenantColumn]: data[tenantColumn] || context.tenantId,
115
+ };
116
+ }
117
+
118
+ /**
119
+ * Get soft delete data (for UPDATE instead of DELETE)
120
+ * @returns {Object} Soft delete update data
121
+ */
122
+ function getSoftDeleteData() {
123
+ return { deleted_at: new Date() };
124
+ }
125
+
126
+ /**
127
+ * Get restore data (to undo soft delete)
128
+ * @returns {Object} Restore update data
129
+ */
130
+ function getRestoreData() {
131
+ return { deleted_at: null };
132
+ }
133
+
134
+ /**
135
+ * Apply all insert modifiers (timestamps, tenant)
136
+ * @param {Object} data - Data to insert
137
+ * @param {import('./types').ScopeContext} context - Scope context
138
+ * @param {import('./types').ModelDefinition} model - Model definition
139
+ * @returns {Object} Modified data
140
+ */
141
+ function applyInsertModifiers(data, context, model) {
142
+ let modified = { ...data };
143
+ modified = applyInsertTimestamps(modified, model);
144
+ modified = applyInsertTenant(modified, context, model);
145
+ return modified;
146
+ }
147
+
148
+ /**
149
+ * Apply all update modifiers (timestamps)
150
+ * @param {Object} data - Data to update
151
+ * @param {import('./types').ModelDefinition} model - Model definition
152
+ * @returns {Object} Modified data
153
+ */
154
+ function applyUpdateModifiers(data, model) {
155
+ return applyUpdateTimestamps(data, model);
156
+ }
157
+
158
+ /**
159
+ * Create default scope context
160
+ * @returns {import('./types').ScopeContext}
161
+ */
162
+ function createScopeContext() {
163
+ return {
164
+ tenantId: undefined,
165
+ withTrashed: false,
166
+ onlyTrashed: false,
167
+ };
168
+ }
169
+
170
+ module.exports = {
171
+ applySoftDeleteScope,
172
+ applyTenantScope,
173
+ applyScopes,
174
+ applyInsertTimestamps,
175
+ applyUpdateTimestamps,
176
+ applyInsertTenant,
177
+ applyInsertModifiers,
178
+ applyUpdateModifiers,
179
+ getSoftDeleteData,
180
+ getRestoreData,
181
+ createScopeContext,
182
+ };
183
+