@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.
- package/LICENSE +21 -21
- package/index.d.ts +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 -2
- package/types/app.d.ts +223 -0
- package/types/auth.d.ts +520 -0
- package/types/body.d.ts +14 -0
- package/types/cli.d.ts +2 -0
- package/types/cluster.d.ts +75 -0
- package/types/env.d.ts +80 -0
- package/types/errors.d.ts +316 -0
- package/types/fetch.d.ts +43 -0
- package/types/grpc.d.ts +432 -0
- package/types/index.d.ts +384 -0
- package/types/lifecycle.d.ts +60 -0
- package/types/middleware.d.ts +320 -0
- package/types/observe.d.ts +304 -0
- package/types/orm.d.ts +1887 -0
- package/types/request.d.ts +109 -0
- package/types/response.d.ts +157 -0
- package/types/router.d.ts +78 -0
- package/types/sse.d.ts +78 -0
- package/types/websocket.d.ts +126 -0
|
@@ -0,0 +1,605 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module orm/tenancy
|
|
3
|
+
* @description Multi-tenancy support for the ORM.
|
|
4
|
+
* Provides schema-based tenancy (PostgreSQL) and row-level tenancy
|
|
5
|
+
* with automatic scoping, tenant middleware, and tenant-aware migrations.
|
|
6
|
+
*
|
|
7
|
+
* @example
|
|
8
|
+
* const { TenantManager } = require('@zero-server/sdk');
|
|
9
|
+
*
|
|
10
|
+
* // Row-level tenancy
|
|
11
|
+
* const tenants = new TenantManager(db, {
|
|
12
|
+
* strategy: 'row',
|
|
13
|
+
* tenantColumn: 'tenant_id',
|
|
14
|
+
* });
|
|
15
|
+
*
|
|
16
|
+
* tenants.setCurrentTenant('acme');
|
|
17
|
+
* const users = await User.find(); // auto-scoped to tenant_id = 'acme'
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
const log = require('../debug')('zero:orm:tenancy');
|
|
21
|
+
|
|
22
|
+
// -- Tenant Context (async-local-like) -----------------------
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* @private
|
|
26
|
+
* Simple tenant context store. Uses a stack so nested `withTenant` calls work.
|
|
27
|
+
*/
|
|
28
|
+
class TenantContext
|
|
29
|
+
{
|
|
30
|
+
constructor()
|
|
31
|
+
{
|
|
32
|
+
/** @type {string|null} */
|
|
33
|
+
this._current = null;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
get() { return this._current; }
|
|
37
|
+
set(v) { this._current = v; }
|
|
38
|
+
clear() { this._current = null; }
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// -- TenantManager -------------------------------------------
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Multi-tenancy manager.
|
|
45
|
+
* Supports two strategies:
|
|
46
|
+
* - `'row'` — adds a tenant column to every query (row-level isolation)
|
|
47
|
+
* - `'schema'` — uses separate database schemas per tenant (PostgreSQL)
|
|
48
|
+
*/
|
|
49
|
+
class TenantManager
|
|
50
|
+
{
|
|
51
|
+
/**
|
|
52
|
+
* @constructor
|
|
53
|
+
* @param {import('./index').Database} db - Database instance.
|
|
54
|
+
* @param {object} options - Tenancy configuration.
|
|
55
|
+
* @param {string} [options.strategy='row'] - Tenancy strategy: `'row'` or `'schema'`.
|
|
56
|
+
* @param {string} [options.tenantColumn='tenant_id'] - Column name for row-level tenancy.
|
|
57
|
+
* @param {string} [options.defaultSchema='public'] - Default schema name (schema strategy).
|
|
58
|
+
* @param {string} [options.schemaPrefix='tenant_'] - Schema name prefix (schema strategy).
|
|
59
|
+
*
|
|
60
|
+
* @example
|
|
61
|
+
* const tenants = new TenantManager(db, {
|
|
62
|
+
* strategy: 'row',
|
|
63
|
+
* tenantColumn: 'tenant_id',
|
|
64
|
+
* });
|
|
65
|
+
*/
|
|
66
|
+
constructor(db, options = {})
|
|
67
|
+
{
|
|
68
|
+
if (!db) throw new Error('TenantManager requires a Database instance');
|
|
69
|
+
|
|
70
|
+
/** @type {import('./index').Database} */
|
|
71
|
+
this.db = db;
|
|
72
|
+
|
|
73
|
+
/** @type {string} */
|
|
74
|
+
this.strategy = options.strategy || 'row';
|
|
75
|
+
|
|
76
|
+
/** @type {string} */
|
|
77
|
+
this.tenantColumn = options.tenantColumn || 'tenant_id';
|
|
78
|
+
|
|
79
|
+
/** @type {string} */
|
|
80
|
+
this.defaultSchema = options.defaultSchema || 'public';
|
|
81
|
+
|
|
82
|
+
/** @type {string} */
|
|
83
|
+
this.schemaPrefix = options.schemaPrefix || 'tenant_';
|
|
84
|
+
|
|
85
|
+
/** @type {TenantContext} */
|
|
86
|
+
this._context = new TenantContext();
|
|
87
|
+
|
|
88
|
+
/** @type {Set<string>} Known tenant IDs (for validation). */
|
|
89
|
+
this._knownTenants = new Set();
|
|
90
|
+
|
|
91
|
+
/** @type {Set<typeof import('./model')>} Models registered for tenancy. */
|
|
92
|
+
this._models = new Set();
|
|
93
|
+
|
|
94
|
+
if (this.strategy !== 'row' && this.strategy !== 'schema')
|
|
95
|
+
{
|
|
96
|
+
throw new Error(`Unknown tenancy strategy "${this.strategy}". Use "row" or "schema".`);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
log('TenantManager created', { strategy: this.strategy });
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// -- Tenant Identity ---------------------------------
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Set the current tenant for all subsequent queries.
|
|
106
|
+
*
|
|
107
|
+
* @param {string} tenantId - Tenant identifier.
|
|
108
|
+
* @returns {TenantManager} this (for chaining)
|
|
109
|
+
*
|
|
110
|
+
* @example
|
|
111
|
+
* tenants.setCurrentTenant('acme');
|
|
112
|
+
*/
|
|
113
|
+
setCurrentTenant(tenantId)
|
|
114
|
+
{
|
|
115
|
+
if (!tenantId || typeof tenantId !== 'string')
|
|
116
|
+
{
|
|
117
|
+
throw new Error('tenantId must be a non-empty string');
|
|
118
|
+
}
|
|
119
|
+
if (!/^[a-zA-Z0-9_-]+$/.test(tenantId) || tenantId.length > 128)
|
|
120
|
+
{
|
|
121
|
+
throw new Error('tenantId must be alphanumeric (with _ or -), max 128 characters');
|
|
122
|
+
}
|
|
123
|
+
this._context.set(tenantId);
|
|
124
|
+
log('tenant set', tenantId);
|
|
125
|
+
return this;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Get the current tenant ID.
|
|
130
|
+
*
|
|
131
|
+
* @returns {string|null} Current tenant ID, or null if none set.
|
|
132
|
+
*
|
|
133
|
+
* @example
|
|
134
|
+
* const id = tenants.getCurrentTenant(); // 'acme'
|
|
135
|
+
*/
|
|
136
|
+
getCurrentTenant()
|
|
137
|
+
{
|
|
138
|
+
return this._context.get();
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Clear the current tenant context.
|
|
143
|
+
*
|
|
144
|
+
* @returns {TenantManager} this (for chaining)
|
|
145
|
+
*/
|
|
146
|
+
clearTenant()
|
|
147
|
+
{
|
|
148
|
+
this._context.clear();
|
|
149
|
+
return this;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Execute a function within a specific tenant context.
|
|
154
|
+
* Restores the previous tenant after the callback completes.
|
|
155
|
+
*
|
|
156
|
+
* @param {string} tenantId - Tenant ID.
|
|
157
|
+
* @param {Function} fn - Async callback.
|
|
158
|
+
* @returns {Promise<*>} Result of fn().
|
|
159
|
+
*
|
|
160
|
+
* @example
|
|
161
|
+
* await tenants.withTenant('acme', async () => {
|
|
162
|
+
* const users = await User.find();
|
|
163
|
+
* // users are scoped to acme
|
|
164
|
+
* });
|
|
165
|
+
*/
|
|
166
|
+
async withTenant(tenantId, fn)
|
|
167
|
+
{
|
|
168
|
+
const prev = this._context.get();
|
|
169
|
+
this.setCurrentTenant(tenantId);
|
|
170
|
+
try
|
|
171
|
+
{
|
|
172
|
+
return await fn();
|
|
173
|
+
}
|
|
174
|
+
finally
|
|
175
|
+
{
|
|
176
|
+
if (prev) this._context.set(prev);
|
|
177
|
+
else this._context.clear();
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// -- Model Registration ------------------------------
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Register a Model class for tenant scoping.
|
|
185
|
+
* For row-level tenancy, this patches the model's query methods to auto-filter.
|
|
186
|
+
*
|
|
187
|
+
* @param {typeof import('./model')} ModelClass - Model class to scope.
|
|
188
|
+
* @returns {TenantManager} this (for chaining)
|
|
189
|
+
*
|
|
190
|
+
* @example
|
|
191
|
+
* tenants.addModel(User);
|
|
192
|
+
* tenants.addModel(Post);
|
|
193
|
+
*/
|
|
194
|
+
addModel(ModelClass)
|
|
195
|
+
{
|
|
196
|
+
if (this._models.has(ModelClass)) return this;
|
|
197
|
+
this._models.add(ModelClass);
|
|
198
|
+
|
|
199
|
+
if (this.strategy === 'row')
|
|
200
|
+
{
|
|
201
|
+
this._patchModelForRowTenancy(ModelClass);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
log('model registered for tenancy', ModelClass.table || ModelClass.name);
|
|
205
|
+
return this;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Register multiple Model classes for tenant scoping.
|
|
210
|
+
*
|
|
211
|
+
* @param {...typeof import('./model')} models - Model classes.
|
|
212
|
+
* @returns {TenantManager} this (for chaining)
|
|
213
|
+
*
|
|
214
|
+
* @example
|
|
215
|
+
* tenants.addModels(User, Post, Comment);
|
|
216
|
+
*/
|
|
217
|
+
addModels(...models)
|
|
218
|
+
{
|
|
219
|
+
for (const M of models) this.addModel(M);
|
|
220
|
+
return this;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// -- Row-Level Tenancy Patching ----------------------
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* @private
|
|
227
|
+
* Patch a Model for row-level tenancy.
|
|
228
|
+
* Wraps query(), create(), createMany() to inject tenant filtering.
|
|
229
|
+
*/
|
|
230
|
+
_patchModelForRowTenancy(ModelClass)
|
|
231
|
+
{
|
|
232
|
+
const manager = this;
|
|
233
|
+
const col = this.tenantColumn;
|
|
234
|
+
|
|
235
|
+
// Store originals
|
|
236
|
+
const origQuery = ModelClass.query.bind(ModelClass);
|
|
237
|
+
const origCreate = ModelClass.create.bind(ModelClass);
|
|
238
|
+
const origCreateMany = ModelClass.createMany.bind(ModelClass);
|
|
239
|
+
const origFind = ModelClass.find.bind(ModelClass);
|
|
240
|
+
const origFindOne = ModelClass.findOne.bind(ModelClass);
|
|
241
|
+
const origFindById = ModelClass.findById.bind(ModelClass);
|
|
242
|
+
const origCount = ModelClass.count.bind(ModelClass);
|
|
243
|
+
const origExists = ModelClass.exists.bind(ModelClass);
|
|
244
|
+
|
|
245
|
+
// Patch query() — all query builder paths
|
|
246
|
+
ModelClass.query = function ()
|
|
247
|
+
{
|
|
248
|
+
const q = origQuery();
|
|
249
|
+
const tid = manager.getCurrentTenant();
|
|
250
|
+
if (tid) q.where(col, tid);
|
|
251
|
+
return q;
|
|
252
|
+
};
|
|
253
|
+
|
|
254
|
+
// Patch create() — inject tenant column
|
|
255
|
+
ModelClass.create = async function (data)
|
|
256
|
+
{
|
|
257
|
+
const tid = manager.getCurrentTenant();
|
|
258
|
+
if (tid) data = { ...data, [col]: tid };
|
|
259
|
+
return origCreate(data);
|
|
260
|
+
};
|
|
261
|
+
|
|
262
|
+
// Patch createMany()
|
|
263
|
+
ModelClass.createMany = async function (dataArray)
|
|
264
|
+
{
|
|
265
|
+
const tid = manager.getCurrentTenant();
|
|
266
|
+
if (tid) dataArray = dataArray.map(d => ({ ...d, [col]: tid }));
|
|
267
|
+
return origCreateMany(dataArray);
|
|
268
|
+
};
|
|
269
|
+
|
|
270
|
+
// Patch find()
|
|
271
|
+
ModelClass.find = async function (conditions)
|
|
272
|
+
{
|
|
273
|
+
const tid = manager.getCurrentTenant();
|
|
274
|
+
if (tid) conditions = { ...conditions, [col]: tid };
|
|
275
|
+
return origFind(conditions);
|
|
276
|
+
};
|
|
277
|
+
|
|
278
|
+
// Patch findOne()
|
|
279
|
+
ModelClass.findOne = async function (conditions)
|
|
280
|
+
{
|
|
281
|
+
const tid = manager.getCurrentTenant();
|
|
282
|
+
if (tid) conditions = { ...conditions, [col]: tid };
|
|
283
|
+
return origFindOne(conditions);
|
|
284
|
+
};
|
|
285
|
+
|
|
286
|
+
// Patch findById() — still applies tenant scope
|
|
287
|
+
ModelClass.findById = async function (id)
|
|
288
|
+
{
|
|
289
|
+
const tid = manager.getCurrentTenant();
|
|
290
|
+
if (!tid) return origFindById(id);
|
|
291
|
+
const pk = ModelClass._primaryKey ? ModelClass._primaryKey() : 'id';
|
|
292
|
+
const key = Array.isArray(pk) ? pk[0] : pk;
|
|
293
|
+
return ModelClass.findOne({ [key]: id, [col]: tid });
|
|
294
|
+
};
|
|
295
|
+
|
|
296
|
+
// Patch count()
|
|
297
|
+
ModelClass.count = async function (conditions)
|
|
298
|
+
{
|
|
299
|
+
const tid = manager.getCurrentTenant();
|
|
300
|
+
if (tid) conditions = { ...conditions, [col]: tid };
|
|
301
|
+
return origCount(conditions);
|
|
302
|
+
};
|
|
303
|
+
|
|
304
|
+
// Patch exists()
|
|
305
|
+
ModelClass.exists = async function (conditions)
|
|
306
|
+
{
|
|
307
|
+
const tid = manager.getCurrentTenant();
|
|
308
|
+
if (tid) conditions = { ...conditions, [col]: tid };
|
|
309
|
+
return origExists(conditions);
|
|
310
|
+
};
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// -- Schema-Based Tenancy ----------------------------
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* Create a new tenant schema (PostgreSQL schema-based tenancy).
|
|
317
|
+
* Runs all registered model syncs within the new schema.
|
|
318
|
+
*
|
|
319
|
+
* @param {string} tenantId - Tenant identifier (used as schema suffix).
|
|
320
|
+
* @returns {Promise<void>}
|
|
321
|
+
*
|
|
322
|
+
* @example
|
|
323
|
+
* await tenants.createTenant('acme');
|
|
324
|
+
* // Creates schema "tenant_acme" with all model tables
|
|
325
|
+
*/
|
|
326
|
+
async createTenant(tenantId)
|
|
327
|
+
{
|
|
328
|
+
if (!tenantId || typeof tenantId !== 'string')
|
|
329
|
+
{
|
|
330
|
+
throw new Error('tenantId must be a non-empty string');
|
|
331
|
+
}
|
|
332
|
+
if (!/^[a-zA-Z0-9_-]+$/.test(tenantId) || tenantId.length > 128)
|
|
333
|
+
{
|
|
334
|
+
throw new Error('tenantId must be alphanumeric (with _ or -), max 128 characters');
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
if (this.strategy === 'schema')
|
|
338
|
+
{
|
|
339
|
+
const schema = this.schemaPrefix + tenantId;
|
|
340
|
+
const adapter = this.db.adapter;
|
|
341
|
+
|
|
342
|
+
if (typeof adapter.execute !== 'function')
|
|
343
|
+
{
|
|
344
|
+
throw new Error('Schema-based tenancy requires a SQL adapter');
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
// Sanitize schema name — only allow alphanumerics and underscores
|
|
348
|
+
if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(schema))
|
|
349
|
+
{
|
|
350
|
+
throw new Error(`Invalid schema name: "${schema}"`);
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
await adapter.execute({ raw: `CREATE SCHEMA IF NOT EXISTS "${schema}"` });
|
|
354
|
+
|
|
355
|
+
// Sync model tables into the new schema
|
|
356
|
+
for (const ModelClass of this._models)
|
|
357
|
+
{
|
|
358
|
+
const table = ModelClass.table || ModelClass.name;
|
|
359
|
+
const fullTable = `${schema}.${table}`;
|
|
360
|
+
// Temporarily override table to include schema prefix
|
|
361
|
+
const origTable = ModelClass.table;
|
|
362
|
+
ModelClass.table = fullTable;
|
|
363
|
+
try
|
|
364
|
+
{
|
|
365
|
+
await ModelClass.sync();
|
|
366
|
+
}
|
|
367
|
+
finally
|
|
368
|
+
{
|
|
369
|
+
ModelClass.table = origTable;
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
this._knownTenants.add(tenantId);
|
|
374
|
+
log('tenant schema created', schema);
|
|
375
|
+
}
|
|
376
|
+
else
|
|
377
|
+
{
|
|
378
|
+
// Row-level: just register the tenant
|
|
379
|
+
this._knownTenants.add(tenantId);
|
|
380
|
+
log('tenant registered', tenantId);
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/**
|
|
385
|
+
* Drop a tenant schema (schema-based) or delete tenant rows (row-level).
|
|
386
|
+
*
|
|
387
|
+
* @param {string} tenantId - Tenant identifier.
|
|
388
|
+
* @param {object} [options] - Drop options.
|
|
389
|
+
* @param {boolean} [options.cascade=false] - CASCADE drop (schema strategy).
|
|
390
|
+
* @returns {Promise<void>}
|
|
391
|
+
*
|
|
392
|
+
* @example
|
|
393
|
+
* await tenants.dropTenant('acme', { cascade: true });
|
|
394
|
+
*/
|
|
395
|
+
async dropTenant(tenantId, options = {})
|
|
396
|
+
{
|
|
397
|
+
if (!tenantId || typeof tenantId !== 'string')
|
|
398
|
+
{
|
|
399
|
+
throw new Error('tenantId must be a non-empty string');
|
|
400
|
+
}
|
|
401
|
+
if (!/^[a-zA-Z0-9_-]+$/.test(tenantId) || tenantId.length > 128)
|
|
402
|
+
{
|
|
403
|
+
throw new Error('tenantId must be alphanumeric (with _ or -), max 128 characters');
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
if (this.strategy === 'schema')
|
|
407
|
+
{
|
|
408
|
+
const schema = this.schemaPrefix + tenantId;
|
|
409
|
+
const adapter = this.db.adapter;
|
|
410
|
+
|
|
411
|
+
if (typeof adapter.execute !== 'function')
|
|
412
|
+
{
|
|
413
|
+
throw new Error('Schema-based tenancy requires a SQL adapter');
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(schema))
|
|
417
|
+
{
|
|
418
|
+
throw new Error(`Invalid schema name: "${schema}"`);
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
const cascade = options.cascade ? ' CASCADE' : '';
|
|
422
|
+
await adapter.execute({ raw: `DROP SCHEMA IF EXISTS "${schema}"${cascade}` });
|
|
423
|
+
log('tenant schema dropped', schema);
|
|
424
|
+
}
|
|
425
|
+
else
|
|
426
|
+
{
|
|
427
|
+
// Row-level: delete all rows belonging to this tenant
|
|
428
|
+
for (const ModelClass of this._models)
|
|
429
|
+
{
|
|
430
|
+
const adapter = ModelClass._adapter;
|
|
431
|
+
if (adapter)
|
|
432
|
+
{
|
|
433
|
+
const table = ModelClass.table || ModelClass.name;
|
|
434
|
+
await adapter.execute({
|
|
435
|
+
action: 'delete',
|
|
436
|
+
table,
|
|
437
|
+
where: [{ field: this.tenantColumn, op: '=', value: tenantId }],
|
|
438
|
+
});
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
log('tenant rows deleted', tenantId);
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
this._knownTenants.delete(tenantId);
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
/**
|
|
448
|
+
* List all known tenant IDs.
|
|
449
|
+
*
|
|
450
|
+
* @returns {string[]} Array of tenant identifiers.
|
|
451
|
+
*
|
|
452
|
+
* @example
|
|
453
|
+
* const ids = tenants.listTenants(); // ['acme', 'globex']
|
|
454
|
+
*/
|
|
455
|
+
listTenants()
|
|
456
|
+
{
|
|
457
|
+
return [...this._knownTenants];
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
/**
|
|
461
|
+
* Check if a tenant exists.
|
|
462
|
+
*
|
|
463
|
+
* @param {string} tenantId - Tenant identifier.
|
|
464
|
+
* @returns {boolean}
|
|
465
|
+
*/
|
|
466
|
+
hasTenant(tenantId)
|
|
467
|
+
{
|
|
468
|
+
return this._knownTenants.has(tenantId);
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
// -- Tenant Middleware --------------------------------
|
|
472
|
+
|
|
473
|
+
/**
|
|
474
|
+
* Returns an HTTP middleware function that extracts the tenant ID
|
|
475
|
+
* from the request and sets it on the TenantManager.
|
|
476
|
+
*
|
|
477
|
+
* @param {object} [options] - Middleware options.
|
|
478
|
+
* @param {string} [options.header='x-tenant-id'] - Header to read tenant from.
|
|
479
|
+
* @param {string} [options.queryParam] - Query parameter name (optional).
|
|
480
|
+
* @param {Function} [options.extract] - Custom `(req) => tenantId` function.
|
|
481
|
+
* @param {boolean} [options.required=true] - Reject requests without tenant.
|
|
482
|
+
* @returns {Function} Middleware `(req, res, next) => {}`.
|
|
483
|
+
*
|
|
484
|
+
* @example
|
|
485
|
+
* app.use(tenants.middleware({ header: 'x-tenant-id' }));
|
|
486
|
+
*
|
|
487
|
+
* @example
|
|
488
|
+
* app.use(tenants.middleware({
|
|
489
|
+
* extract: (req) => req.params.tenant,
|
|
490
|
+
* }));
|
|
491
|
+
*/
|
|
492
|
+
middleware(options = {})
|
|
493
|
+
{
|
|
494
|
+
const {
|
|
495
|
+
header = 'x-tenant-id',
|
|
496
|
+
queryParam,
|
|
497
|
+
extract,
|
|
498
|
+
required = true,
|
|
499
|
+
} = options;
|
|
500
|
+
|
|
501
|
+
const manager = this;
|
|
502
|
+
|
|
503
|
+
return function tenantMiddleware(req, res, next)
|
|
504
|
+
{
|
|
505
|
+
let tenantId;
|
|
506
|
+
|
|
507
|
+
if (typeof extract === 'function')
|
|
508
|
+
{
|
|
509
|
+
tenantId = extract(req);
|
|
510
|
+
}
|
|
511
|
+
else if (queryParam && req.query && req.query[queryParam])
|
|
512
|
+
{
|
|
513
|
+
tenantId = String(req.query[queryParam]);
|
|
514
|
+
}
|
|
515
|
+
else if (header && req.headers)
|
|
516
|
+
{
|
|
517
|
+
tenantId = req.headers[header.toLowerCase()];
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
if (!tenantId)
|
|
521
|
+
{
|
|
522
|
+
if (required)
|
|
523
|
+
{
|
|
524
|
+
res.statusCode = 400;
|
|
525
|
+
res.setHeader('Content-Type', 'application/json');
|
|
526
|
+
res.end(JSON.stringify({ error: 'Tenant ID required' }));
|
|
527
|
+
return;
|
|
528
|
+
}
|
|
529
|
+
return next();
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
manager.setCurrentTenant(String(tenantId));
|
|
533
|
+
req.tenantId = String(tenantId);
|
|
534
|
+
next();
|
|
535
|
+
};
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
// -- Tenant-Aware Migrations -------------------------
|
|
539
|
+
|
|
540
|
+
/**
|
|
541
|
+
* Run migrations for a specific tenant.
|
|
542
|
+
* For schema strategy, switches to the tenant's schema before migrating.
|
|
543
|
+
* For row strategy, runs normal migrations (tables are shared).
|
|
544
|
+
*
|
|
545
|
+
* @param {import('./migrate').Migrator} migrator - Migrator instance.
|
|
546
|
+
* @param {string} tenantId - Tenant identifier.
|
|
547
|
+
* @returns {Promise<object>} Migration result.
|
|
548
|
+
*
|
|
549
|
+
* @example
|
|
550
|
+
* await tenants.migrate(migrator, 'acme');
|
|
551
|
+
*/
|
|
552
|
+
async migrate(migrator, tenantId)
|
|
553
|
+
{
|
|
554
|
+
if (this.strategy === 'schema')
|
|
555
|
+
{
|
|
556
|
+
const schema = this.schemaPrefix + tenantId;
|
|
557
|
+
const adapter = this.db.adapter;
|
|
558
|
+
|
|
559
|
+
if (typeof adapter.execute !== 'function')
|
|
560
|
+
{
|
|
561
|
+
throw new Error('Schema-based tenancy requires a SQL adapter');
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
// Set search path
|
|
565
|
+
await adapter.execute({ raw: `SET search_path TO "${schema}"` });
|
|
566
|
+
try
|
|
567
|
+
{
|
|
568
|
+
return await migrator.migrate();
|
|
569
|
+
}
|
|
570
|
+
finally
|
|
571
|
+
{
|
|
572
|
+
// Restore default search path
|
|
573
|
+
await adapter.execute({ raw: `SET search_path TO "${this.defaultSchema}"` });
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
else
|
|
577
|
+
{
|
|
578
|
+
return migrator.migrate();
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
/**
|
|
583
|
+
* Run migrations for all known tenants.
|
|
584
|
+
*
|
|
585
|
+
* @param {import('./migrate').Migrator} migrator - Migrator instance.
|
|
586
|
+
* @returns {Promise<Map<string, object>>} Map of tenantId → migration result.
|
|
587
|
+
*
|
|
588
|
+
* @example
|
|
589
|
+
* const results = await tenants.migrateAll(migrator);
|
|
590
|
+
* for (const [id, result] of results) {
|
|
591
|
+
* console.log(id, result.migrated);
|
|
592
|
+
* }
|
|
593
|
+
*/
|
|
594
|
+
async migrateAll(migrator)
|
|
595
|
+
{
|
|
596
|
+
const results = new Map();
|
|
597
|
+
for (const tenantId of this._knownTenants)
|
|
598
|
+
{
|
|
599
|
+
results.set(tenantId, await this.migrate(migrator, tenantId));
|
|
600
|
+
}
|
|
601
|
+
return results;
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
module.exports = { TenantManager };
|