@zero-server/orm 0.9.1 → 0.9.3

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.
Files changed (61) hide show
  1. package/LICENSE +21 -21
  2. package/index.d.ts +1 -1
  3. package/index.js +35 -35
  4. package/lib/debug.js +372 -0
  5. package/lib/orm/adapters/json.js +290 -0
  6. package/lib/orm/adapters/memory.js +764 -0
  7. package/lib/orm/adapters/mongo.js +764 -0
  8. package/lib/orm/adapters/mysql.js +933 -0
  9. package/lib/orm/adapters/postgres.js +1144 -0
  10. package/lib/orm/adapters/redis.js +1534 -0
  11. package/lib/orm/adapters/sql-base.js +212 -0
  12. package/lib/orm/adapters/sqlite.js +858 -0
  13. package/lib/orm/audit.js +649 -0
  14. package/lib/orm/cache.js +394 -0
  15. package/lib/orm/geo.js +387 -0
  16. package/lib/orm/index.js +784 -0
  17. package/lib/orm/migrate.js +432 -0
  18. package/lib/orm/model.js +1706 -0
  19. package/lib/orm/plugin.js +375 -0
  20. package/lib/orm/procedures.js +836 -0
  21. package/lib/orm/profiler.js +233 -0
  22. package/lib/orm/query.js +1772 -0
  23. package/lib/orm/replicas.js +241 -0
  24. package/lib/orm/schema.js +307 -0
  25. package/lib/orm/search.js +380 -0
  26. package/lib/orm/seed/data/commerce.js +136 -0
  27. package/lib/orm/seed/data/internet.js +111 -0
  28. package/lib/orm/seed/data/locations.js +204 -0
  29. package/lib/orm/seed/data/names.js +338 -0
  30. package/lib/orm/seed/data/person.js +128 -0
  31. package/lib/orm/seed/data/phone.js +211 -0
  32. package/lib/orm/seed/data/words.js +134 -0
  33. package/lib/orm/seed/factory.js +178 -0
  34. package/lib/orm/seed/fake.js +1186 -0
  35. package/lib/orm/seed/index.js +18 -0
  36. package/lib/orm/seed/rng.js +71 -0
  37. package/lib/orm/seed/seeder.js +125 -0
  38. package/lib/orm/seed/unique.js +68 -0
  39. package/lib/orm/snapshot.js +366 -0
  40. package/lib/orm/tenancy.js +605 -0
  41. package/lib/orm/views.js +350 -0
  42. package/package.json +12 -2
  43. package/types/app.d.ts +223 -0
  44. package/types/auth.d.ts +520 -0
  45. package/types/body.d.ts +14 -0
  46. package/types/cli.d.ts +2 -0
  47. package/types/cluster.d.ts +75 -0
  48. package/types/env.d.ts +80 -0
  49. package/types/errors.d.ts +316 -0
  50. package/types/fetch.d.ts +43 -0
  51. package/types/grpc.d.ts +432 -0
  52. package/types/index.d.ts +384 -0
  53. package/types/lifecycle.d.ts +60 -0
  54. package/types/middleware.d.ts +320 -0
  55. package/types/observe.d.ts +304 -0
  56. package/types/orm.d.ts +1887 -0
  57. package/types/request.d.ts +109 -0
  58. package/types/response.d.ts +157 -0
  59. package/types/router.d.ts +78 -0
  60. package/types/sse.d.ts +78 -0
  61. package/types/websocket.d.ts +126 -0
@@ -0,0 +1,241 @@
1
+ /**
2
+ * @module orm/replicas
3
+ * @description Read replica management with automatic read/write splitting,
4
+ * round-robin and random selection strategies, sticky writes,
5
+ * and health checking.
6
+ *
7
+ * @example
8
+ * const { Database, ReplicaManager } = require('@zero-server/sdk');
9
+ *
10
+ * const db = Database.connectWithReplicas('postgres',
11
+ * { host: 'primary.db', database: 'app' },
12
+ * [
13
+ * { host: 'replica1.db', database: 'app' },
14
+ * { host: 'replica2.db', database: 'app' },
15
+ * ],
16
+ * { strategy: 'round-robin', stickyWindow: 2000 }
17
+ * );
18
+ */
19
+
20
+ const log = require('../debug')('zero:replicas');
21
+
22
+ class ReplicaManager
23
+ {
24
+ /**
25
+ * @constructor
26
+ * @param {object} [options] - Configuration options.
27
+ * @param {string} [options.strategy='round-robin'] - Selection strategy: 'round-robin' | 'random'.
28
+ * @param {boolean} [options.stickyWrite=true] - Read from primary after a write for stickyWindow ms.
29
+ * @param {number} [options.stickyWindow=1000] - Duration (ms) to read from primary after a write.
30
+ */
31
+ constructor(options = {})
32
+ {
33
+ /** @private */ this._primary = null;
34
+ /** @private */ this._replicas = [];
35
+
36
+ // Validate strategy against whitelist
37
+ const allowed = ['round-robin', 'random'];
38
+ const strategy = options.strategy || 'round-robin';
39
+ if (!allowed.includes(strategy))
40
+ {
41
+ throw new Error(`Invalid replica strategy: "${strategy}". Must be one of: ${allowed.join(', ')}`);
42
+ }
43
+ /** @private */ this._strategy = strategy;
44
+ /** @private */ this._idx = 0;
45
+
46
+ // Sticky writes: read from primary after a write to avoid stale reads
47
+ /** @private */ this._stickyWrite = options.stickyWrite !== false;
48
+ /** @private */ this._stickyWindow = Math.max(0, Number(options.stickyWindow) || 1000);
49
+ /** @private */ this._lastWriteAt = 0;
50
+ }
51
+
52
+ /**
53
+ * Set the primary (read-write) adapter.
54
+ * @param {object} adapter - Database adapter instance.
55
+ * @throws {Error} If adapter is null or undefined.
56
+ */
57
+ setPrimary(adapter)
58
+ {
59
+ if (!adapter) throw new Error('Primary adapter must not be null');
60
+ this._primary = adapter;
61
+ }
62
+
63
+ /**
64
+ * Add a read replica adapter.
65
+ * @param {object} adapter - Database adapter instance.
66
+ */
67
+ addReplica(adapter)
68
+ {
69
+ if (!adapter) throw new Error('Replica adapter must not be null');
70
+ this._replicas.push({ adapter, healthy: true, lastChecked: 0 });
71
+ }
72
+
73
+ /**
74
+ * Number of registered replicas.
75
+ * @type {number}
76
+ */
77
+ get replicaCount()
78
+ {
79
+ return this._replicas.length;
80
+ }
81
+
82
+ /**
83
+ * Get an adapter for read operations.
84
+ * Respects strategy, health status, and sticky writes.
85
+ *
86
+ * @returns {object} Adapter instance.
87
+ */
88
+ getReadAdapter()
89
+ {
90
+ // Sticky writes: use primary during the sticky window
91
+ if (this._stickyWrite && (Date.now() - this._lastWriteAt) < this._stickyWindow)
92
+ {
93
+ log('Sticky write window active, using primary for read');
94
+ return this._primary;
95
+ }
96
+
97
+ const healthy = this._replicas.filter(r => r.healthy);
98
+ if (!healthy.length)
99
+ {
100
+ log('No healthy replicas, falling back to primary');
101
+ return this._primary;
102
+ }
103
+
104
+ let replica;
105
+ if (this._strategy === 'random')
106
+ {
107
+ replica = healthy[Math.floor(Math.random() * healthy.length)];
108
+ }
109
+ else
110
+ {
111
+ // round-robin (reset index to prevent unbounded growth)
112
+ replica = healthy[this._idx % healthy.length];
113
+ this._idx = (this._idx + 1) % Number.MAX_SAFE_INTEGER;
114
+ }
115
+
116
+ return replica.adapter;
117
+ }
118
+
119
+ /**
120
+ * Get the primary adapter for write operations.
121
+ * Also updates the last write timestamp for sticky window tracking.
122
+ *
123
+ * @returns {object} Primary adapter instance.
124
+ */
125
+ getWriteAdapter()
126
+ {
127
+ this._lastWriteAt = Date.now();
128
+ return this._primary;
129
+ }
130
+
131
+ /**
132
+ * Mark a replica as unhealthy (excluded from read routing).
133
+ * @param {object} adapter - Database adapter instance.
134
+ */
135
+ markUnhealthy(adapter)
136
+ {
137
+ const replica = this._replicas.find(r => r.adapter === adapter);
138
+ if (replica)
139
+ {
140
+ replica.healthy = false;
141
+ log('Replica marked unhealthy');
142
+ }
143
+ }
144
+
145
+ /**
146
+ * Mark a replica as healthy (re-included in read routing).
147
+ * @param {object} adapter - Database adapter instance.
148
+ */
149
+ markHealthy(adapter)
150
+ {
151
+ const replica = this._replicas.find(r => r.adapter === adapter);
152
+ if (replica)
153
+ {
154
+ replica.healthy = true;
155
+ }
156
+ }
157
+
158
+ /**
159
+ * Run a health check on all replicas.
160
+ * Calls adapter.ping() if available.
161
+ *
162
+ * @returns {Promise<Array<{ healthy: boolean, lastChecked: number }>>}
163
+ */
164
+ async healthCheck()
165
+ {
166
+ const checks = this._replicas.map(async (replica) =>
167
+ {
168
+ try
169
+ {
170
+ if (typeof replica.adapter.ping === 'function')
171
+ {
172
+ replica.healthy = await replica.adapter.ping();
173
+ }
174
+ else
175
+ {
176
+ // Adapters without ping are assumed healthy
177
+ replica.healthy = true;
178
+ }
179
+ }
180
+ catch
181
+ {
182
+ replica.healthy = false;
183
+ }
184
+ replica.lastChecked = Date.now();
185
+ return { healthy: replica.healthy, lastChecked: replica.lastChecked };
186
+ });
187
+
188
+ return Promise.all(checks);
189
+ }
190
+
191
+ /**
192
+ * Get all adapters (primary + replicas).
193
+ * @returns {object[]} Primary and all replica adapters.
194
+ */
195
+ getAllAdapters()
196
+ {
197
+ return [this._primary, ...this._replicas.map(r => r.adapter)].filter(Boolean);
198
+ }
199
+
200
+ /**
201
+ * Remove a replica adapter from the pool.
202
+ * @param {object} adapter - Database adapter instance.
203
+ */
204
+ removeReplica(adapter)
205
+ {
206
+ this._replicas = this._replicas.filter(r => r.adapter !== adapter);
207
+ }
208
+
209
+ /**
210
+ * Get pool status summary.
211
+ * @returns {{ primary: boolean, total: number, healthy: number, unhealthy: number, strategy: string }}
212
+ */
213
+ status()
214
+ {
215
+ const healthy = this._replicas.filter(r => r.healthy).length;
216
+ return {
217
+ primary: !!this._primary,
218
+ total: this._replicas.length,
219
+ healthy,
220
+ unhealthy: this._replicas.length - healthy,
221
+ strategy: this._strategy,
222
+ };
223
+ }
224
+
225
+ /**
226
+ * Close all adapters (primary + replicas).
227
+ * @returns {Promise<void>}
228
+ */
229
+ async closeAll()
230
+ {
231
+ for (const adapter of this.getAllAdapters())
232
+ {
233
+ if (typeof adapter.close === 'function')
234
+ {
235
+ await adapter.close();
236
+ }
237
+ }
238
+ }
239
+ }
240
+
241
+ module.exports = { ReplicaManager };
@@ -0,0 +1,307 @@
1
+ /**
2
+ * @module orm/schema
3
+ * @description Schema definition and validation for ORM models.
4
+ * Validates data against column definitions, coerces types,
5
+ * and enforces constraints (required, unique, min, max, enum, match).
6
+ *
7
+ * @example
8
+ * const { TYPES, validate } = require('@zero-server/sdk').Schema;
9
+ *
10
+ * const columns = {
11
+ * name: { type: TYPES.STRING, required: true, minLength: 1 },
12
+ * email: { type: TYPES.STRING, required: true, match: /@/ },
13
+ * age: { type: TYPES.INTEGER, min: 0, max: 150 },
14
+ * };
15
+ *
16
+ * const { valid, errors, sanitized } = validate(
17
+ * { name: 'Alice', email: 'alice@example.com', age: '30' },
18
+ * columns,
19
+ * );
20
+ * // valid: true, sanitized.age === 30 (coerced from string)
21
+ */
22
+
23
+ /**
24
+ * Supported column types.
25
+ * @enum {string}
26
+ */
27
+ const TYPES = {
28
+ STRING: 'string',
29
+ INTEGER: 'integer',
30
+ FLOAT: 'float',
31
+ BOOLEAN: 'boolean',
32
+ DATE: 'date',
33
+ DATETIME: 'datetime',
34
+ JSON: 'json',
35
+ TEXT: 'text',
36
+ BLOB: 'blob',
37
+ UUID: 'uuid',
38
+ // Extended numeric types
39
+ BIGINT: 'bigint',
40
+ SMALLINT: 'smallint',
41
+ TINYINT: 'tinyint',
42
+ DECIMAL: 'decimal',
43
+ DOUBLE: 'double',
44
+ REAL: 'real',
45
+ // Extended string / binary types
46
+ CHAR: 'char',
47
+ BINARY: 'binary',
48
+ VARBINARY:'varbinary',
49
+ // Temporal types
50
+ TIMESTAMP:'timestamp',
51
+ TIME: 'time',
52
+ // MySQL-specific
53
+ ENUM: 'enum',
54
+ SET: 'set',
55
+ MEDIUMTEXT: 'mediumtext',
56
+ LONGTEXT: 'longtext',
57
+ MEDIUMBLOB: 'mediumblob',
58
+ LONGBLOB: 'longblob',
59
+ YEAR: 'year',
60
+ // PostgreSQL-specific
61
+ SERIAL: 'serial',
62
+ BIGSERIAL:'bigserial',
63
+ JSONB: 'jsonb',
64
+ INTERVAL: 'interval',
65
+ INET: 'inet',
66
+ CIDR: 'cidr',
67
+ MACADDR: 'macaddr',
68
+ MONEY: 'money',
69
+ XML: 'xml',
70
+ CITEXT: 'citext',
71
+ ARRAY: 'array',
72
+ // SQLite
73
+ NUMERIC: 'numeric',
74
+ };
75
+
76
+ /**
77
+ * Validate and sanitise a single value against a column definition.
78
+ *
79
+ * @param {*} value - Raw input value.
80
+ * @param {object} colDef - Column definition.
81
+ * @param {string} colName - Column name (for error messages).
82
+ * @returns {*} Coerced value.
83
+ * @throws {Error} On validation failure.
84
+ */
85
+ function validateValue(value, colDef, colName)
86
+ {
87
+ const type = colDef.type || 'string';
88
+
89
+ // Handle null/undefined
90
+ if (value === undefined || value === null)
91
+ {
92
+ if (colDef.required && colDef.default === undefined)
93
+ throw new Error(`"${colName}" is required`);
94
+ if (colDef.default !== undefined)
95
+ return typeof colDef.default === 'function' ? colDef.default() : colDef.default;
96
+ return colDef.nullable !== false ? null : undefined;
97
+ }
98
+
99
+ switch (type)
100
+ {
101
+ case 'string':
102
+ case 'text':
103
+ case 'mediumtext':
104
+ case 'longtext':
105
+ case 'char':
106
+ case 'citext':
107
+ case 'xml':
108
+ {
109
+ const val = String(value);
110
+ if (colDef.minLength !== undefined && val.length < colDef.minLength)
111
+ throw new Error(`"${colName}" must be at least ${colDef.minLength} characters`);
112
+ if (colDef.maxLength !== undefined && val.length > colDef.maxLength)
113
+ throw new Error(`"${colName}" must be at most ${colDef.maxLength} characters`);
114
+ if (colDef.match && !colDef.match.test(val))
115
+ throw new Error(`"${colName}" does not match pattern ${colDef.match}`);
116
+ if (colDef.enum && !colDef.enum.includes(val))
117
+ throw new Error(`"${colName}" must be one of [${colDef.enum.join(', ')}]`);
118
+ // Sanitise: prevent SQL-like injection patterns in string values
119
+ return val;
120
+ }
121
+ case 'integer':
122
+ case 'bigint':
123
+ case 'smallint':
124
+ case 'tinyint':
125
+ case 'serial':
126
+ case 'bigserial':
127
+ case 'year':
128
+ {
129
+ const val = typeof value === 'string' ? parseInt(value, 10) : Math.floor(Number(value));
130
+ if (isNaN(val)) throw new Error(`"${colName}" must be an integer`);
131
+ if (colDef.min !== undefined && val < colDef.min)
132
+ throw new Error(`"${colName}" must be >= ${colDef.min}`);
133
+ if (colDef.max !== undefined && val > colDef.max)
134
+ throw new Error(`"${colName}" must be <= ${colDef.max}`);
135
+ return val;
136
+ }
137
+ case 'float':
138
+ case 'decimal':
139
+ case 'double':
140
+ case 'real':
141
+ case 'numeric':
142
+ case 'money':
143
+ {
144
+ const val = Number(value);
145
+ if (isNaN(val)) throw new Error(`"${colName}" must be a number`);
146
+ if (colDef.min !== undefined && val < colDef.min)
147
+ throw new Error(`"${colName}" must be >= ${colDef.min}`);
148
+ if (colDef.max !== undefined && val > colDef.max)
149
+ throw new Error(`"${colName}" must be <= ${colDef.max}`);
150
+ return val;
151
+ }
152
+ case 'boolean':
153
+ {
154
+ if (typeof value === 'boolean') return value;
155
+ if (typeof value === 'string')
156
+ {
157
+ const lower = value.toLowerCase();
158
+ if (['true', '1', 'yes'].includes(lower)) return true;
159
+ if (['false', '0', 'no'].includes(lower)) return false;
160
+ }
161
+ if (typeof value === 'number') return value !== 0;
162
+ throw new Error(`"${colName}" must be a boolean`);
163
+ }
164
+ case 'date':
165
+ case 'datetime':
166
+ case 'timestamp':
167
+ case 'time':
168
+ case 'interval':
169
+ {
170
+ if (value instanceof Date) return value;
171
+ const d = new Date(value);
172
+ if (isNaN(d.getTime())) throw new Error(`"${colName}" must be a valid date`);
173
+ return d;
174
+ }
175
+ case 'json':
176
+ case 'jsonb':
177
+ {
178
+ if (typeof value === 'string')
179
+ {
180
+ try { return JSON.parse(value); }
181
+ catch (e) { throw new Error(`"${colName}" must be valid JSON`); }
182
+ }
183
+ // Already an object/array — return as-is for storage
184
+ return value;
185
+ }
186
+ case 'uuid':
187
+ {
188
+ const val = String(value);
189
+ if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(val))
190
+ throw new Error(`"${colName}" must be a valid UUID`);
191
+ return val;
192
+ }
193
+ case 'blob':
194
+ case 'mediumblob':
195
+ case 'longblob':
196
+ case 'binary':
197
+ case 'varbinary':
198
+ return Buffer.isBuffer(value) ? value : Buffer.from(value);
199
+ case 'enum':
200
+ {
201
+ const val = String(value);
202
+ if (colDef.enum && !colDef.enum.includes(val))
203
+ throw new Error(`"${colName}" must be one of [${colDef.enum.join(', ')}]`);
204
+ return val;
205
+ }
206
+ case 'set':
207
+ {
208
+ const vals = Array.isArray(value) ? value : String(value).split(',');
209
+ if (colDef.values)
210
+ {
211
+ for (const v of vals)
212
+ if (!colDef.values.includes(v.trim()))
213
+ throw new Error(`"${colName}" contains invalid value "${v.trim()}". Allowed: [${colDef.values.join(', ')}]`);
214
+ }
215
+ return vals.map(v => v.trim()).join(',');
216
+ }
217
+ case 'inet':
218
+ case 'cidr':
219
+ case 'macaddr':
220
+ return String(value);
221
+ case 'array':
222
+ return Array.isArray(value) ? value : [value];
223
+ default:
224
+ return value;
225
+ }
226
+ }
227
+
228
+ // -- Validation -------------------------------------------
229
+
230
+ /**
231
+ * Validate all columns of a data object against the schema.
232
+ *
233
+ * @param {object} data - Input data object.
234
+ * @param {object} columns - Schema column definitions.
235
+ * @param {object} [options] - Configuration options.
236
+ * @param {boolean} [options.partial=false] - When true, only validates provided fields (for updates).
237
+ * @returns {{ valid: boolean, errors: string[], sanitized: object }}
238
+ */
239
+ function validate(data, columns, options = {})
240
+ {
241
+ const errors = [];
242
+ const sanitized = {};
243
+
244
+ for (const [colName, colDef] of Object.entries(columns))
245
+ {
246
+ // Skip auto fields on create
247
+ if (colDef.primaryKey && colDef.autoIncrement && data[colName] === undefined) continue;
248
+
249
+ if (options.partial && data[colName] === undefined) continue;
250
+
251
+ // Skip guarded fields not present in data (stripped by mass-assignment)
252
+ if (colDef.guarded && data[colName] === undefined) continue;
253
+
254
+ try
255
+ {
256
+ sanitized[colName] = validateValue(data[colName], colDef, colName);
257
+ }
258
+ catch (e)
259
+ {
260
+ errors.push(e.message);
261
+ }
262
+ }
263
+
264
+ // Reject unknown keys (prevent mass-assignment)
265
+ for (const key of Object.keys(data))
266
+ {
267
+ if (!columns[key])
268
+ {
269
+ errors.push(`Unknown column "${key}"`);
270
+ }
271
+ }
272
+
273
+ return { valid: errors.length === 0, errors, sanitized };
274
+ }
275
+
276
+ // -- DDL Security Helpers --------------------------------
277
+
278
+ const VALID_FK_ACTIONS = new Set(['CASCADE', 'SET NULL', 'SET DEFAULT', 'RESTRICT', 'NO ACTION']);
279
+
280
+ /**
281
+ * Validate and return a FK action string, or throw.
282
+ * @param {string} action - Foreign key action string (CASCADE, SET NULL, etc.).
283
+ * @returns {string} Uppercase validated action
284
+ */
285
+ function validateFKAction(action)
286
+ {
287
+ const upper = String(action).toUpperCase();
288
+ if (!VALID_FK_ACTIONS.has(upper))
289
+ throw new Error(`Invalid FK action: "${action}". Allowed: ${[...VALID_FK_ACTIONS].join(', ')}`);
290
+ return upper;
291
+ }
292
+
293
+ /**
294
+ * Validate a CHECK expression for dangerous SQL patterns.
295
+ * @param {string} expr - SQL CHECK expression to validate.
296
+ * @returns {string} The original expression (validated).
297
+ */
298
+ function validateCheck(expr)
299
+ {
300
+ const s = String(expr);
301
+ // Block semicolons, comment markers, and common injection patterns
302
+ if (/;|--|\bDROP\b|\bDELETE\b|\bINSERT\b|\bUPDATE\b|\bALTER\b|\bCREATE\b|\bEXEC\b/i.test(s))
303
+ throw new Error(`Potentially dangerous CHECK expression: "${s}"`);
304
+ return s;
305
+ }
306
+
307
+ module.exports = { TYPES, validateValue, validate, validateFKAction, validateCheck };