@zero-server/orm 0.9.0 → 0.9.2
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/LICENSE +21 -21
- package/README.md +1 -1
- package/index.js +35 -35
- package/lib/debug.js +372 -0
- package/lib/orm/adapters/json.js +290 -0
- package/lib/orm/adapters/memory.js +764 -0
- package/lib/orm/adapters/mongo.js +764 -0
- package/lib/orm/adapters/mysql.js +933 -0
- package/lib/orm/adapters/postgres.js +1144 -0
- package/lib/orm/adapters/redis.js +1534 -0
- package/lib/orm/adapters/sql-base.js +212 -0
- package/lib/orm/adapters/sqlite.js +858 -0
- package/lib/orm/audit.js +649 -0
- package/lib/orm/cache.js +394 -0
- package/lib/orm/geo.js +387 -0
- package/lib/orm/index.js +784 -0
- package/lib/orm/migrate.js +432 -0
- package/lib/orm/model.js +1706 -0
- package/lib/orm/plugin.js +375 -0
- package/lib/orm/procedures.js +836 -0
- package/lib/orm/profiler.js +233 -0
- package/lib/orm/query.js +1772 -0
- package/lib/orm/replicas.js +241 -0
- package/lib/orm/schema.js +307 -0
- package/lib/orm/search.js +380 -0
- package/lib/orm/seed/data/commerce.js +136 -0
- package/lib/orm/seed/data/internet.js +111 -0
- package/lib/orm/seed/data/locations.js +204 -0
- package/lib/orm/seed/data/names.js +338 -0
- package/lib/orm/seed/data/person.js +128 -0
- package/lib/orm/seed/data/phone.js +211 -0
- package/lib/orm/seed/data/words.js +134 -0
- package/lib/orm/seed/factory.js +178 -0
- package/lib/orm/seed/fake.js +1186 -0
- package/lib/orm/seed/index.js +18 -0
- package/lib/orm/seed/rng.js +71 -0
- package/lib/orm/seed/seeder.js +125 -0
- package/lib/orm/seed/unique.js +68 -0
- package/lib/orm/snapshot.js +366 -0
- package/lib/orm/tenancy.js +605 -0
- package/lib/orm/views.js +350 -0
- package/package.json +12 -3
|
@@ -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 };
|