@zenith-open/zenithcms-db-postgres 0.1.0

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,2028 @@
1
+ import NodeCache from 'node-cache';
2
+ import Redis from 'ioredis';
3
+ import pino from 'pino';
4
+ export class LocalCacheLayer {
5
+ cache;
6
+ constructor() {
7
+ this.cache = new NodeCache({ stdTTL: 60, checkperiod: 120 });
8
+ }
9
+ async get(key) {
10
+ return this.cache.get(key);
11
+ }
12
+ async set(key, value, collection) {
13
+ this.cache.set(key, value);
14
+ }
15
+ async invalidate(collection) {
16
+ const keys = this.cache.keys();
17
+ const targets = keys.filter((k) => k.startsWith(`${collection}:`));
18
+ this.cache.del(targets);
19
+ }
20
+ }
21
+ export class RedisCacheLayer {
22
+ redis;
23
+ constructor(redisUrl) {
24
+ this.redis = new Redis(redisUrl, {
25
+ maxRetriesPerRequest: 3,
26
+ });
27
+ logger.info('PostgresDrizzleAdapter: Redis_Cache_Layer Initialized');
28
+ }
29
+ async get(key) {
30
+ try {
31
+ const data = await this.redis.get(key);
32
+ return data ? JSON.parse(data) : undefined;
33
+ }
34
+ catch (error) {
35
+ logger.warn({ error: error.message }, 'RedisCacheLayer: Get failed');
36
+ return undefined;
37
+ }
38
+ }
39
+ async set(key, value, collection) {
40
+ try {
41
+ const setKey = `zenith:cache:collection:${collection}`;
42
+ await this.redis.setex(key, 60, JSON.stringify(value));
43
+ await this.redis.sadd(setKey, key);
44
+ await this.redis.expire(setKey, 120);
45
+ }
46
+ catch (error) {
47
+ logger.warn({ error: error.message }, 'RedisCacheLayer: Set failed');
48
+ }
49
+ }
50
+ async invalidate(collection) {
51
+ try {
52
+ const setKey = `zenith:cache:collection:${collection}`;
53
+ const keys = await this.redis.smembers(setKey);
54
+ if (keys.length > 0) {
55
+ await this.redis.del(...keys);
56
+ }
57
+ await this.redis.del(setKey);
58
+ }
59
+ catch (error) {
60
+ logger.warn({ error: error.message }, 'RedisCacheLayer: Invalidate failed');
61
+ }
62
+ }
63
+ }
64
+ // Import Drizzle ORM and Postgres
65
+ import { drizzle } from 'drizzle-orm/node-postgres';
66
+ import { Pool } from 'pg';
67
+ import { sql, eq, and, desc, or } from 'drizzle-orm';
68
+ import { QueryASTParser } from './query-ast';
69
+ import { pgTable, text, timestamp, jsonb, uuid, integer, boolean, bigint, } from 'drizzle-orm/pg-core';
70
+ const logger = pino();
71
+ /**
72
+ * PostgreSQL Database Adapter with Drizzle ORM
73
+ * ─────────────────────────────────────────────
74
+ * Phase B: "The Tightening"
75
+ * Features:
76
+ * - Dynamic Column Mapping (No more JSONB traps)
77
+ * - Auto-Migration Engine (safe DDL execution on boot)
78
+ * - Atomic Multi-Table Transactions.
79
+ * - Pre-compiled Zod validation caching.
80
+ */
81
+ export class PostgresDrizzleAdapter {
82
+ connectionString;
83
+ name = 'postgres-drizzle';
84
+ pool;
85
+ db;
86
+ cache;
87
+ tables = {};
88
+ configs = {};
89
+ // Registry of tenant connection pools to dynamically switch on-the-fly
90
+ tenantPools = {};
91
+ // Built-in system tables defined via Drizzle
92
+ systemTables = {
93
+ auditLog: pgTable('audit_logs', {
94
+ id: uuid('id').defaultRandom().primaryKey(),
95
+ timestamp: timestamp('timestamp').defaultNow().notNull(),
96
+ collectionName: text('collection_name').notNull(),
97
+ documentId: text('document_id'),
98
+ userId: text('user_id'),
99
+ userEmail: text('user_email'),
100
+ userName: text('user_name'),
101
+ action: text('action').notNull(),
102
+ changes: jsonb('changes'),
103
+ ip: text('ip'),
104
+ userAgent: text('user_agent'),
105
+ status: text('status'),
106
+ resource: text('resource'),
107
+ siteId: text('site_id'),
108
+ hash: text('hash'),
109
+ previousHash: text('previous_hash'),
110
+ }),
111
+ version: pgTable('versions', {
112
+ id: uuid('id').defaultRandom().primaryKey(),
113
+ timestamp: timestamp('timestamp').defaultNow().notNull(),
114
+ collectionName: text('collection_name').notNull(),
115
+ collectionSlug: text('collection_slug').notNull(),
116
+ documentId: text('document_id').notNull(),
117
+ snapshot: jsonb('snapshot').notNull(),
118
+ delta: jsonb('delta'),
119
+ createdBy: text('created_by').notNull(),
120
+ }),
121
+ flows: pgTable('flows', {
122
+ id: uuid('id').defaultRandom().primaryKey(),
123
+ name: text('name').notNull(),
124
+ description: text('description'),
125
+ active: boolean('active').default(false).notNull(),
126
+ trigger: jsonb('trigger').notNull(),
127
+ steps: jsonb('steps').notNull(),
128
+ createdAt: timestamp('created_at').defaultNow().notNull(),
129
+ updatedAt: timestamp('updated_at').defaultNow().notNull(),
130
+ }),
131
+ users: pgTable('users', {
132
+ id: uuid('id').defaultRandom().primaryKey(),
133
+ email: text('email').unique().notNull(),
134
+ password: text('password').notNull(),
135
+ role: text('role').notNull(),
136
+ failedLoginAttempts: integer('failed_login_attempts').default(0).notNull(),
137
+ lockUntil: timestamp('lock_until'),
138
+ emailVerified: boolean('email_verified').default(false).notNull(),
139
+ verificationToken: text('verification_token'),
140
+ verificationTokenExpiry: timestamp('verification_token_expiry'),
141
+ }),
142
+ passwordResets: pgTable('z_password_resets', {
143
+ id: uuid('id').defaultRandom().primaryKey(),
144
+ userId: text('user_id').notNull(),
145
+ token: text('token').notNull(),
146
+ expiresAt: timestamp('expires_at').notNull(),
147
+ used: boolean('used').default(false).notNull(),
148
+ }),
149
+ apiKeys: pgTable('z_api_keys', {
150
+ id: uuid('id').defaultRandom().primaryKey(),
151
+ name: text('name').notNull(),
152
+ key: text('key').notNull(),
153
+ role: text('role').notNull(),
154
+ expiresAt: timestamp('expires_at'),
155
+ revoked: boolean('revoked').default(false).notNull(),
156
+ lastUsed: timestamp('last_used'),
157
+ allowedCollections: jsonb('allowed_collections'),
158
+ }),
159
+ migrations: pgTable('z_migrations', {
160
+ id: uuid('id').defaultRandom().primaryKey(),
161
+ name: text('name').unique().notNull(),
162
+ batch: integer('batch').notNull(),
163
+ executedAt: timestamp('executed_at').defaultNow().notNull(),
164
+ }),
165
+ webhookDelivery: pgTable('z_webhook_deliveries', {
166
+ id: uuid('id').defaultRandom().primaryKey(),
167
+ webhookId: text('webhook_id'),
168
+ timestamp: timestamp('timestamp').defaultNow().notNull(),
169
+ collectionSlug: text('collection_slug'),
170
+ event: text('event').notNull(),
171
+ url: text('url').notNull(),
172
+ payload: jsonb('payload'),
173
+ success: boolean('success').notNull(),
174
+ responseStatus: integer('response_status'),
175
+ }),
176
+ settings: pgTable('z_settings', {
177
+ id: uuid('id').defaultRandom().primaryKey(),
178
+ siteName: text('site_name').default('Zenith CMS'),
179
+ publicUrl: text('public_url'), // No default — must be set explicitly per deployment
180
+ maintenanceMode: boolean('maintenance_mode').default(false),
181
+ enableDrafts: boolean('enable_drafts').default(true),
182
+ defaultLocale: text('default_locale').default('en'),
183
+ allowedOrigins: jsonb('allowed_origins'),
184
+ jwtExpiresIn: text('jwt_expires_in').default('7d'),
185
+ passwordMinLength: integer('password_min_length').default(8),
186
+ rateLimitWindow: integer('rate_limit_window').default(15),
187
+ rateLimitMax: integer('rate_limit_max').default(100),
188
+ customCSS: text('custom_css').default(''),
189
+ }),
190
+ collections: pgTable('z_collections', {
191
+ id: uuid('id').defaultRandom().primaryKey(),
192
+ name: text('name').notNull(),
193
+ slug: text('slug').unique().notNull(),
194
+ labels: jsonb('labels'),
195
+ drafts: boolean('drafts').default(false).notNull(),
196
+ timestamps: boolean('timestamps').default(true).notNull(),
197
+ fields: jsonb('fields').notNull(),
198
+ createdAt: timestamp('created_at').defaultNow().notNull(),
199
+ updatedAt: timestamp('updated_at').defaultNow().notNull(),
200
+ }),
201
+ presence: pgTable('z_presence', {
202
+ id: uuid('id').defaultRandom().primaryKey(),
203
+ userId: text('user_id').notNull(),
204
+ email: text('email').notNull(),
205
+ collectionName: text('collection_name').notNull(),
206
+ documentId: text('document_id').notNull(),
207
+ lastActive: bigint('last_active', { mode: 'number' }).notNull(),
208
+ }),
209
+ sites: pgTable('z_sites', {
210
+ id: uuid('id').defaultRandom().primaryKey(),
211
+ name: text('name').notNull(),
212
+ slug: text('slug').unique().notNull(),
213
+ icon: text('icon').default('🌐'),
214
+ description: text('description'),
215
+ ownerId: text('owner_id').notNull(),
216
+ workspaceId: text('workspace_id'),
217
+ members: jsonb('members').default([]),
218
+ collections: jsonb('collections').default([]),
219
+ globals: jsonb('globals').default([]),
220
+ billingEnabled: boolean('billing_enabled').default(false),
221
+ stripePublicKey: text('stripe_public_key'),
222
+ stripeSecretKey: text('stripe_secret_key'),
223
+ stripeWebhookSecret: text('stripe_webhook_secret'),
224
+ currency: text('currency').default('USD'),
225
+ pricingPlans: jsonb('pricing_plans').default([]),
226
+ createdAt: timestamp('created_at').defaultNow().notNull(),
227
+ updatedAt: timestamp('updated_at').defaultNow().notNull(),
228
+ }),
229
+ workspaces: pgTable('z_workspaces', {
230
+ id: uuid('id').defaultRandom().primaryKey(),
231
+ name: text('name').notNull(),
232
+ slug: text('slug').unique().notNull(),
233
+ ownerId: text('owner_id').notNull(),
234
+ members: jsonb('members').default([]),
235
+ createdAt: timestamp('created_at').defaultNow().notNull(),
236
+ updatedAt: timestamp('updated_at').defaultNow().notNull(),
237
+ }),
238
+ locks: pgTable('z_locks', {
239
+ id: uuid('id').defaultRandom().primaryKey(),
240
+ collectionName: text('collection_name').notNull(),
241
+ documentId: text('document_id').notNull(),
242
+ siteId: text('site_id'),
243
+ lockedBy: text('locked_by').notNull(),
244
+ lockedByEmail: text('locked_by_email').notNull(),
245
+ lockedAt: timestamp('locked_at').defaultNow().notNull(),
246
+ lockExpiresAt: timestamp('lock_expires_at').notNull(),
247
+ }),
248
+ webhookConfigs: pgTable('z_webhook_configs', {
249
+ id: uuid('id').defaultRandom().primaryKey(),
250
+ url: text('url').notNull(),
251
+ secret: text('secret'),
252
+ events: jsonb('events').notNull().default([]),
253
+ enabled: boolean('enabled').default(true).notNull(),
254
+ createdAt: timestamp('created_at').defaultNow().notNull(),
255
+ updatedAt: timestamp('updated_at').defaultNow().notNull(),
256
+ }),
257
+ redirects: pgTable('z_redirects', {
258
+ id: uuid('id').defaultRandom().primaryKey(),
259
+ from: text('from').notNull(),
260
+ to: text('to').notNull(),
261
+ type: text('type').default('301').notNull(),
262
+ siteId: text('site_id'),
263
+ hits: integer('hits').default(0).notNull(),
264
+ lastHitAt: timestamp('last_hit_at'),
265
+ createdBy: text('created_by'),
266
+ createdAt: timestamp('created_at').defaultNow().notNull(),
267
+ }),
268
+ roles: pgTable('z_roles', {
269
+ id: uuid('id').defaultRandom().primaryKey(),
270
+ roleName: text('role_name').notNull().unique(),
271
+ roleType: text('role_type').notNull().default('custom'),
272
+ description: text('description').default(''),
273
+ isSystem: boolean('is_system').default(false).notNull(),
274
+ permissions: jsonb('permissions').default([]).notNull(),
275
+ createdAt: timestamp('created_at').defaultNow().notNull(),
276
+ updatedAt: timestamp('updated_at').defaultNow().notNull(),
277
+ }),
278
+ releases: pgTable('z_releases', {
279
+ id: uuid('id').defaultRandom().primaryKey(),
280
+ name: text('name').notNull(),
281
+ description: text('description').default(''),
282
+ documents: jsonb('documents').default([]).notNull(),
283
+ status: text('status').notNull().default('pending'),
284
+ scheduledAt: timestamp('scheduled_at'),
285
+ publishedAt: timestamp('published_at'),
286
+ publishedBy: text('published_by'),
287
+ failureReason: text('failure_reason'),
288
+ siteId: text('site_id'),
289
+ createdBy: text('created_by'),
290
+ createdAt: timestamp('created_at').defaultNow().notNull(),
291
+ updatedAt: timestamp('updated_at').defaultNow().notNull(),
292
+ }),
293
+ plugins: pgTable('z_plugins', {
294
+ id: text('id').primaryKey(),
295
+ name: text('name').notNull(),
296
+ version: text('version').default('1.0.0'),
297
+ description: text('description').default(''),
298
+ author: text('author').default(''),
299
+ homepage: text('homepage').default(''),
300
+ packageName: text('package_name').default(''),
301
+ configSchema: jsonb('config_schema').default({}),
302
+ config: jsonb('config').default({}),
303
+ enabled: boolean('enabled').default(true).notNull(),
304
+ installedAt: timestamp('installed_at').defaultNow().notNull(),
305
+ updatedAt: timestamp('updated_at').defaultNow().notNull(),
306
+ }),
307
+ };
308
+ constructor(connectionString) {
309
+ this.connectionString = connectionString;
310
+ const redisUrl = process.env.REDIS_URL;
311
+ if (redisUrl) {
312
+ this.cache = new RedisCacheLayer(redisUrl);
313
+ }
314
+ else {
315
+ this.cache = new LocalCacheLayer();
316
+ logger.warn('PostgresDrizzleAdapter: Local_Cache_Layer Initialized (Warning: Cache desync risk under horizontal scaling)');
317
+ }
318
+ logger.info('PostgresDrizzleAdapter: Zod Parser Cache pre-allocated for speed.');
319
+ // Configure connection pooling (configurable via env)
320
+ const poolMax = parseInt(process.env.POSTGRES_POOL_MAX || '20', 10);
321
+ const poolIdleTimeout = parseInt(process.env.POSTGRES_POOL_IDLE_TIMEOUT || '30000', 10);
322
+ const poolConnectionTimeout = parseInt(process.env.POSTGRES_POOL_CONNECT_TIMEOUT || '2000', 10);
323
+ const poolSslRejectUnauthorized = process.env.POSTGRES_SSL_REJECT_UNAUTHORIZED || 'true';
324
+ const poolOptions = {
325
+ connectionString: this.connectionString,
326
+ max: poolMax,
327
+ idleTimeoutMillis: poolIdleTimeout,
328
+ connectionTimeoutMillis: poolConnectionTimeout,
329
+ };
330
+ // Enable SSL when POSTGRES_URI contains sslmode=require or when explicitly configured
331
+ if (process.env.POSTGRES_SSL_ENABLED === 'true' || this.connectionString.includes('sslmode=require')) {
332
+ poolOptions.ssl = {
333
+ rejectUnauthorized: poolSslRejectUnauthorized !== 'false',
334
+ };
335
+ }
336
+ this.pool = new Pool(poolOptions);
337
+ this.db = drizzle(this.pool);
338
+ logger.info('PostgresDrizzleAdapter: Initialized successfully with connection pooling');
339
+ }
340
+ /**
341
+ * Executes a database operation within a tenant-isolated RLS context.
342
+ * If siteId is provided, it begins a transaction, sets the local config parameter,
343
+ * and yields the transaction object.
344
+ */
345
+ async runWithTenantContext(siteId, operation) {
346
+ if (!siteId) {
347
+ return operation(this.db);
348
+ }
349
+ return await this.db.transaction(async (tx) => {
350
+ // Inject hardware-level tenant isolation for this transaction
351
+ await tx.execute(sql `SET LOCAL app.site_id = ${siteId}`);
352
+ return await operation(tx);
353
+ });
354
+ }
355
+ async registerTenant(tenantId, tenantConnectionString) {
356
+ if (this.tenantPools[tenantId]) {
357
+ return;
358
+ }
359
+ logger.info(`PostgresDrizzleAdapter: Dynamically provisioning connection pool for tenant [${tenantId}]`);
360
+ const pool = new Pool({
361
+ connectionString: tenantConnectionString,
362
+ max: 10,
363
+ idleTimeoutMillis: 30000,
364
+ connectionTimeoutMillis: 2000,
365
+ });
366
+ const db = drizzle(pool);
367
+ const client = await pool.connect();
368
+ client.release();
369
+ this.tenantPools[tenantId] = { pool, db };
370
+ await this._ensureSystemTables(db);
371
+ }
372
+ getDbClient(options) {
373
+ const tenantId = options?.tenantId || options?.siteId;
374
+ if (tenantId && this.tenantPools[tenantId]) {
375
+ return this.tenantPools[tenantId].db;
376
+ }
377
+ return this.db;
378
+ }
379
+ async connect() {
380
+ const maxRetries = 5;
381
+ const retryDelay = 3000;
382
+ for (let attempt = 1; attempt <= maxRetries; attempt++) {
383
+ try {
384
+ const client = await this.pool.connect();
385
+ client.release();
386
+ logger.info('PostgresDrizzleAdapter: Connected to PostgreSQL');
387
+ await this._ensureSystemTables();
388
+ return;
389
+ }
390
+ catch (error) {
391
+ logger.error({ attempt, error: error.message }, 'PostgresDrizzleAdapter: Connection failed');
392
+ if (attempt < maxRetries) {
393
+ logger.info(`Retrying PostgreSQL connection in ${retryDelay}ms...`);
394
+ await new Promise((resolve) => setTimeout(resolve, retryDelay));
395
+ }
396
+ else {
397
+ throw error;
398
+ }
399
+ }
400
+ }
401
+ }
402
+ async disconnect() {
403
+ for (const [tenantId, tenant] of Object.entries(this.tenantPools)) {
404
+ await tenant.pool.end();
405
+ logger.info(`PostgresDrizzleAdapter: Disconnected tenant [${tenantId}] pool`);
406
+ }
407
+ await this.pool.end();
408
+ logger.info('PostgresDrizzleAdapter: Disconnected');
409
+ }
410
+ getHealth() {
411
+ if (this.pool.totalCount === 0)
412
+ return 'disconnected';
413
+ return this.pool.idleCount > 0 ? 'ok' : 'connecting';
414
+ }
415
+ async _ensureSystemTables(db = this.db) {
416
+ let acquired = false;
417
+ try {
418
+ await db.execute(sql `SELECT pg_advisory_lock(99999)`);
419
+ acquired = true;
420
+ }
421
+ catch (err) {
422
+ logger.warn({ err: err.message }, 'PostgresDrizzleAdapter: System tables advisory lock acquisition failed/timed out. Proceeding without lock.');
423
+ }
424
+ try {
425
+ const createAuditLogTable = sql `
426
+ CREATE TABLE IF NOT EXISTS audit_logs (
427
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
428
+ timestamp TIMESTAMP DEFAULT NOW() NOT NULL,
429
+ collection_name TEXT NOT NULL,
430
+ document_id TEXT,
431
+ user_id TEXT,
432
+ user_email TEXT,
433
+ user_name TEXT,
434
+ action TEXT NOT NULL,
435
+ changes JSONB,
436
+ ip TEXT,
437
+ user_agent TEXT,
438
+ status TEXT,
439
+ resource TEXT,
440
+ site_id TEXT,
441
+ hash TEXT,
442
+ previous_hash TEXT
443
+ );
444
+ CREATE INDEX IF NOT EXISTS idx_audit_collection ON audit_logs(collection_name);
445
+ CREATE INDEX IF NOT EXISTS idx_audit_timestamp ON audit_logs(timestamp);
446
+ CREATE INDEX IF NOT EXISTS idx_audit_site ON audit_logs(site_id);
447
+
448
+ -- Enable RLS for Audit Logs
449
+ ALTER TABLE audit_logs ENABLE ROW LEVEL SECURITY;
450
+ DROP POLICY IF EXISTS tenant_isolation_policy ON audit_logs;
451
+ CREATE POLICY tenant_isolation_policy ON audit_logs
452
+ FOR ALL
453
+ USING (
454
+ site_id = current_setting('app.site_id', true)
455
+ OR current_setting('app.site_id', true) = ''
456
+ OR current_setting('app.site_id', true) IS NULL
457
+ OR site_id IS NULL
458
+ );
459
+ CREATE INDEX IF NOT EXISTS idx_audit_action ON audit_logs(action);
460
+ `;
461
+ const createVersionTable = sql `
462
+ CREATE TABLE IF NOT EXISTS versions (
463
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
464
+ timestamp TIMESTAMP DEFAULT NOW() NOT NULL,
465
+ collection_name TEXT NOT NULL,
466
+ collection_slug TEXT NOT NULL,
467
+ document_id TEXT NOT NULL,
468
+ snapshot JSONB NOT NULL,
469
+ delta JSONB,
470
+ created_by TEXT NOT NULL
471
+ );
472
+ CREATE INDEX IF NOT EXISTS idx_versions_doc ON versions(document_id);
473
+ `;
474
+ const createFlowsTable = sql `
475
+ CREATE TABLE IF NOT EXISTS flows (
476
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
477
+ name TEXT NOT NULL,
478
+ description TEXT,
479
+ active BOOLEAN DEFAULT false NOT NULL,
480
+ trigger JSONB NOT NULL DEFAULT '{}'::jsonb,
481
+ steps JSONB NOT NULL DEFAULT '[]'::jsonb,
482
+ created_at TIMESTAMP DEFAULT NOW() NOT NULL,
483
+ updated_at TIMESTAMP DEFAULT NOW() NOT NULL
484
+ );
485
+ CREATE INDEX IF NOT EXISTS idx_flows_active ON flows(active);
486
+ `;
487
+ const createUsersTable = sql `
488
+ CREATE TABLE IF NOT EXISTS users (
489
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
490
+ email TEXT UNIQUE NOT NULL,
491
+ password TEXT NOT NULL,
492
+ role TEXT NOT NULL,
493
+ failed_login_attempts INTEGER DEFAULT 0 NOT NULL,
494
+ lock_until TIMESTAMP,
495
+ email_verified BOOLEAN DEFAULT false NOT NULL,
496
+ verification_token TEXT,
497
+ verification_token_expiry TIMESTAMP,
498
+ two_factor_secret TEXT,
499
+ two_factor_enabled BOOLEAN DEFAULT false NOT NULL
500
+ );
501
+ CREATE INDEX IF NOT EXISTS idx_users_email ON users(email);
502
+ `;
503
+ const createPasswordResetsTable = sql `
504
+ CREATE TABLE IF NOT EXISTS z_password_resets (
505
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
506
+ user_id TEXT NOT NULL,
507
+ token TEXT NOT NULL,
508
+ expires_at TIMESTAMP NOT NULL,
509
+ used BOOLEAN DEFAULT false NOT NULL
510
+ );
511
+ CREATE INDEX IF NOT EXISTS idx_resets_token ON z_password_resets(token);
512
+ `;
513
+ const createApiKeysTable = sql `
514
+ CREATE TABLE IF NOT EXISTS z_api_keys (
515
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
516
+ name TEXT NOT NULL,
517
+ key TEXT NOT NULL,
518
+ role TEXT NOT NULL,
519
+ expires_at TIMESTAMP,
520
+ revoked BOOLEAN DEFAULT false NOT NULL,
521
+ last_used TIMESTAMP,
522
+ allowed_collections JSONB DEFAULT '[]'::jsonb
523
+ );
524
+ CREATE INDEX IF NOT EXISTS idx_api_keys_hash ON z_api_keys(key);
525
+ `;
526
+ const createMigrationsTable = sql `
527
+ CREATE TABLE IF NOT EXISTS z_migrations (
528
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
529
+ name TEXT UNIQUE NOT NULL,
530
+ batch INTEGER NOT NULL,
531
+ executed_at TIMESTAMP DEFAULT NOW() NOT NULL
532
+ );
533
+ CREATE INDEX IF NOT EXISTS idx_migrations_name ON z_migrations(name);
534
+ `;
535
+ const createWebhookDeliveriesTable = sql `
536
+ CREATE TABLE IF NOT EXISTS z_webhook_deliveries (
537
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
538
+ timestamp TIMESTAMP DEFAULT NOW() NOT NULL,
539
+ collection_slug TEXT,
540
+ event TEXT NOT NULL,
541
+ url TEXT NOT NULL,
542
+ payload JSONB,
543
+ success BOOLEAN NOT NULL,
544
+ response_status INTEGER
545
+ );
546
+ CREATE INDEX IF NOT EXISTS idx_webhook_deliveries_event ON z_webhook_deliveries(event);
547
+ `;
548
+ const createWebhookConfigsTable = sql `
549
+ CREATE TABLE IF NOT EXISTS z_webhook_configs (
550
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
551
+ url TEXT NOT NULL,
552
+ secret TEXT,
553
+ events JSONB NOT NULL DEFAULT '[]',
554
+ enabled BOOLEAN DEFAULT true NOT NULL,
555
+ created_at TIMESTAMP DEFAULT NOW() NOT NULL,
556
+ updated_at TIMESTAMP DEFAULT NOW() NOT NULL
557
+ );
558
+ CREATE INDEX IF NOT EXISTS idx_webhook_configs_url ON z_webhook_configs(url);
559
+ `;
560
+ const createSchemasTable = sql `
561
+ CREATE TABLE IF NOT EXISTS z_schemas (
562
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
563
+ slug TEXT UNIQUE NOT NULL,
564
+ singular TEXT NOT NULL,
565
+ plural TEXT NOT NULL,
566
+ fields JSONB NOT NULL DEFAULT '[]'::jsonb,
567
+ settings JSONB NOT NULL DEFAULT '{}'::jsonb,
568
+ created_at TIMESTAMP DEFAULT NOW() NOT NULL,
569
+ updated_at TIMESTAMP DEFAULT NOW() NOT NULL
570
+ );
571
+ CREATE INDEX IF NOT EXISTS idx_schemas_slug ON z_schemas(slug);
572
+ `;
573
+ const createCampaignsTable = sql `
574
+ CREATE TABLE IF NOT EXISTS z_campaigns (
575
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
576
+ subject TEXT NOT NULL,
577
+ body TEXT NOT NULL,
578
+ status TEXT DEFAULT 'draft' NOT NULL,
579
+ audience TEXT DEFAULT 'all' NOT NULL,
580
+ sent_at TIMESTAMP,
581
+ created_at TIMESTAMP DEFAULT NOW() NOT NULL,
582
+ updated_at TIMESTAMP DEFAULT NOW() NOT NULL
583
+ );
584
+ `;
585
+ const createPluginsTable = sql `
586
+ CREATE TABLE IF NOT EXISTS z_plugins (
587
+ id TEXT PRIMARY KEY,
588
+ name TEXT NOT NULL,
589
+ version TEXT DEFAULT '1.0.0',
590
+ description TEXT DEFAULT '',
591
+ author TEXT DEFAULT '',
592
+ homepage TEXT DEFAULT '',
593
+ package_name TEXT DEFAULT '',
594
+ config_schema JSONB DEFAULT '{}'::jsonb,
595
+ config JSONB DEFAULT '{}'::jsonb,
596
+ enabled BOOLEAN DEFAULT true NOT NULL,
597
+ installed_at TIMESTAMP DEFAULT NOW() NOT NULL,
598
+ updated_at TIMESTAMP DEFAULT NOW() NOT NULL
599
+ );
600
+ `;
601
+ const createSettingsTable = sql `
602
+ CREATE TABLE IF NOT EXISTS z_settings (
603
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
604
+ site_name TEXT DEFAULT 'Zenith CMS',
605
+ public_url TEXT, -- No default: must be set explicitly per deployment (PUBLIC_URL env var)
606
+ maintenance_mode BOOLEAN DEFAULT false,
607
+ enable_drafts BOOLEAN DEFAULT true,
608
+ default_locale TEXT DEFAULT 'en',
609
+ allowed_origins JSONB DEFAULT '["*"]'::jsonb,
610
+ jwt_expires_in TEXT DEFAULT '7d',
611
+ password_min_length INTEGER DEFAULT 8,
612
+ rate_limit_window INTEGER DEFAULT 15,
613
+ rate_limit_max INTEGER DEFAULT 100,
614
+ custom_css TEXT DEFAULT ''
615
+ );
616
+ `;
617
+ const createSitesTable = sql `
618
+ CREATE TABLE IF NOT EXISTS z_sites (
619
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
620
+ name TEXT NOT NULL,
621
+ slug TEXT UNIQUE NOT NULL,
622
+ icon TEXT DEFAULT '🌐',
623
+ description TEXT,
624
+ owner_id TEXT NOT NULL,
625
+ workspace_id TEXT,
626
+ members JSONB DEFAULT '[]'::jsonb,
627
+ collections JSONB DEFAULT '[]'::jsonb,
628
+ globals JSONB DEFAULT '[]'::jsonb,
629
+ billing_enabled BOOLEAN DEFAULT false,
630
+ stripe_public_key TEXT,
631
+ stripe_secret_key TEXT,
632
+ stripe_webhook_secret TEXT,
633
+ currency TEXT DEFAULT 'USD',
634
+ pricing_plans JSONB DEFAULT '[]'::jsonb,
635
+ created_at TIMESTAMP DEFAULT NOW() NOT NULL,
636
+ updated_at TIMESTAMP DEFAULT NOW() NOT NULL
637
+ );
638
+ CREATE INDEX IF NOT EXISTS idx_sites_slug ON z_sites(slug);
639
+ CREATE INDEX IF NOT EXISTS idx_sites_workspace ON z_sites(workspace_id);
640
+ `;
641
+ const createWorkspacesTable = sql `
642
+ CREATE TABLE IF NOT EXISTS z_workspaces (
643
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
644
+ name TEXT NOT NULL,
645
+ slug TEXT UNIQUE NOT NULL,
646
+ owner_id TEXT NOT NULL,
647
+ members JSONB DEFAULT '[]'::jsonb,
648
+ created_at TIMESTAMP DEFAULT NOW() NOT NULL,
649
+ updated_at TIMESTAMP DEFAULT NOW() NOT NULL
650
+ );
651
+ CREATE INDEX IF NOT EXISTS idx_workspaces_slug ON z_workspaces(slug);
652
+ `;
653
+ const migrateSitesWorkspaceId = sql `
654
+ ALTER TABLE z_sites ADD COLUMN IF NOT EXISTS workspace_id TEXT;
655
+ CREATE INDEX IF NOT EXISTS idx_sites_workspace ON z_sites(workspace_id);
656
+ `;
657
+ const createUserPreferencesTable = sql `
658
+ CREATE TABLE IF NOT EXISTS z_preferences (
659
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
660
+ user_id TEXT NOT NULL,
661
+ key TEXT NOT NULL,
662
+ value JSONB NOT NULL,
663
+ updated_at TIMESTAMP DEFAULT NOW() NOT NULL,
664
+ UNIQUE (user_id, key)
665
+ );
666
+ `;
667
+ const createMembersTable = sql `
668
+ CREATE TABLE IF NOT EXISTS z_members (
669
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
670
+ email TEXT UNIQUE NOT NULL,
671
+ password TEXT,
672
+ name TEXT,
673
+ avatar TEXT,
674
+ is_subscribed BOOLEAN DEFAULT false,
675
+ subscription_status TEXT DEFAULT 'none',
676
+ stripe_customer_id TEXT,
677
+ metadata JSONB DEFAULT '{}'::jsonb,
678
+ last_login TIMESTAMP,
679
+ created_at TIMESTAMP DEFAULT NOW() NOT NULL,
680
+ updated_at TIMESTAMP DEFAULT NOW() NOT NULL
681
+ );
682
+ CREATE INDEX IF NOT EXISTS idx_members_email ON z_members(email);
683
+ `;
684
+ const createDashboardLayoutsTable = sql `
685
+ CREATE TABLE IF NOT EXISTS z_dashboard_layouts (
686
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
687
+ user_id TEXT NOT NULL,
688
+ site_id TEXT,
689
+ widgets JSONB DEFAULT '[]'::jsonb,
690
+ columns INTEGER DEFAULT 12,
691
+ created_at TIMESTAMP DEFAULT NOW() NOT NULL,
692
+ updated_at TIMESTAMP DEFAULT NOW() NOT NULL,
693
+ UNIQUE (user_id, site_id)
694
+ );
695
+ `;
696
+ const createOnboardingStateTable = sql `
697
+ CREATE TABLE IF NOT EXISTS z_onboarding (
698
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
699
+ current_step INTEGER DEFAULT 0,
700
+ total_steps INTEGER DEFAULT 7,
701
+ completed_at TIMESTAMP,
702
+ skipped BOOLEAN DEFAULT false,
703
+ answers JSONB DEFAULT '{}'::jsonb,
704
+ created_at TIMESTAMP DEFAULT NOW() NOT NULL,
705
+ updated_at TIMESTAMP DEFAULT NOW() NOT NULL
706
+ );
707
+ `;
708
+ const createCollectionsTable = sql `
709
+ CREATE TABLE IF NOT EXISTS z_collections (
710
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
711
+ name TEXT NOT NULL,
712
+ slug TEXT UNIQUE NOT NULL,
713
+ labels JSONB,
714
+ drafts BOOLEAN DEFAULT false NOT NULL,
715
+ timestamps BOOLEAN DEFAULT true NOT NULL,
716
+ fields JSONB NOT NULL,
717
+ created_at TIMESTAMP DEFAULT NOW() NOT NULL,
718
+ updated_at TIMESTAMP DEFAULT NOW() NOT NULL
719
+ );
720
+ CREATE INDEX IF NOT EXISTS idx_collections_slug ON z_collections(slug);
721
+ `;
722
+ const createRedirectsTable = sql `
723
+ CREATE TABLE IF NOT EXISTS z_redirects (
724
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
725
+ from_path TEXT NOT NULL,
726
+ to_path TEXT NOT NULL,
727
+ redirect_type TEXT DEFAULT '301' NOT NULL,
728
+ site_id TEXT,
729
+ hits INTEGER DEFAULT 0 NOT NULL,
730
+ last_hit_at TIMESTAMP,
731
+ created_by TEXT,
732
+ created_at TIMESTAMP DEFAULT NOW() NOT NULL
733
+ );
734
+ CREATE INDEX IF NOT EXISTS idx_redirects_from ON z_redirects(from_path);
735
+ `;
736
+ const createRolesTable = sql `
737
+ CREATE TABLE IF NOT EXISTS z_roles (
738
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
739
+ role_name TEXT NOT NULL UNIQUE,
740
+ role_type TEXT NOT NULL DEFAULT 'custom',
741
+ description TEXT DEFAULT '',
742
+ is_system BOOLEAN DEFAULT false NOT NULL,
743
+ permissions JSONB DEFAULT '[]'::jsonb NOT NULL,
744
+ created_at TIMESTAMP DEFAULT NOW() NOT NULL,
745
+ updated_at TIMESTAMP DEFAULT NOW() NOT NULL
746
+ );
747
+ CREATE INDEX IF NOT EXISTS idx_roles_type ON z_roles(role_type);
748
+ `;
749
+ const createReleasesTable = sql `
750
+ CREATE TABLE IF NOT EXISTS z_releases (
751
+ id UUID PRIMARY KEY,
752
+ name TEXT NOT NULL,
753
+ description TEXT DEFAULT '',
754
+ documents JSONB DEFAULT '[]'::jsonb NOT NULL,
755
+ status TEXT NOT NULL DEFAULT 'pending',
756
+ scheduled_at TIMESTAMP,
757
+ published_at TIMESTAMP,
758
+ published_by TEXT,
759
+ failure_reason TEXT,
760
+ site_id TEXT,
761
+ created_by TEXT,
762
+ created_at TIMESTAMP DEFAULT NOW() NOT NULL,
763
+ updated_at TIMESTAMP DEFAULT NOW() NOT NULL
764
+ );
765
+ CREATE INDEX IF NOT EXISTS idx_releases_status ON z_releases(status);
766
+ CREATE INDEX IF NOT EXISTS idx_releases_scheduled ON z_releases(scheduled_at);
767
+ CREATE INDEX IF NOT EXISTS idx_releases_site ON z_releases(site_id);
768
+ `;
769
+ const createPresenceTable = sql `
770
+ CREATE TABLE IF NOT EXISTS z_presence (
771
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
772
+ user_id TEXT NOT NULL,
773
+ email TEXT NOT NULL,
774
+ collection_name TEXT NOT NULL,
775
+ document_id TEXT NOT NULL,
776
+ last_active BIGINT NOT NULL
777
+ );
778
+ `;
779
+ const createLocksTable = sql `
780
+ CREATE TABLE IF NOT EXISTS z_locks (
781
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
782
+ collection_name TEXT NOT NULL,
783
+ document_id TEXT NOT NULL,
784
+ site_id TEXT,
785
+ locked_by TEXT NOT NULL,
786
+ locked_by_email TEXT NOT NULL,
787
+ locked_at TIMESTAMP DEFAULT NOW() NOT NULL,
788
+ lock_expires_at TIMESTAMP NOT NULL
789
+ );
790
+ CREATE INDEX IF NOT EXISTS idx_locks_doc ON z_locks(collection_name, document_id);
791
+ `;
792
+ await db.execute(createAuditLogTable);
793
+ await db.execute(createVersionTable);
794
+ await db.execute(createFlowsTable);
795
+ await db.execute(createUsersTable);
796
+ try {
797
+ await db.execute(sql `ALTER TABLE users ADD COLUMN IF NOT EXISTS two_factor_secret TEXT`);
798
+ await db.execute(sql `ALTER TABLE users ADD COLUMN IF NOT EXISTS two_factor_enabled BOOLEAN DEFAULT false NOT NULL`);
799
+ }
800
+ catch (err) {
801
+ logger.warn({ err }, 'Failed to add 2FA columns to users table');
802
+ }
803
+ await db.execute(createPasswordResetsTable);
804
+ await db.execute(createApiKeysTable);
805
+ await db.execute(createMigrationsTable);
806
+ await db.execute(createWebhookDeliveriesTable);
807
+ await db.execute(createWebhookConfigsTable);
808
+ await db.execute(createSchemasTable);
809
+ await db.execute(createCampaignsTable);
810
+ await db.execute(createPluginsTable);
811
+ await db.execute(createSettingsTable);
812
+ await db.execute(createSitesTable);
813
+ await db.execute(createWorkspacesTable);
814
+ await db.execute(migrateSitesWorkspaceId);
815
+ await db.execute(createUserPreferencesTable);
816
+ await db.execute(createMembersTable);
817
+ await db.execute(createDashboardLayoutsTable);
818
+ await db.execute(createOnboardingStateTable);
819
+ await db.execute(createCollectionsTable);
820
+ await db.execute(createRedirectsTable);
821
+ await db.execute(createRolesTable);
822
+ await db.execute(createReleasesTable);
823
+ await db.execute(createPresenceTable);
824
+ await db.execute(createLocksTable);
825
+ }
826
+ finally {
827
+ if (acquired) {
828
+ try {
829
+ await db.execute(sql `SELECT pg_advisory_unlock(99999)`);
830
+ }
831
+ catch (err) {
832
+ logger.error({ err: err.message }, 'PostgresDrizzleAdapter: Failed to release advisory lock');
833
+ }
834
+ }
835
+ }
836
+ }
837
+ mapFieldToDrizzleColumn(field) {
838
+ if (field.localized) {
839
+ return jsonb(field.name);
840
+ }
841
+ let col;
842
+ switch (field.type) {
843
+ case 'number':
844
+ col = integer(field.name);
845
+ break;
846
+ case 'checkbox':
847
+ case 'boolean':
848
+ col = boolean(field.name);
849
+ break;
850
+ case 'date':
851
+ col = timestamp(field.name);
852
+ break;
853
+ case 'richtext':
854
+ col = field.format === 'json' ? jsonb(field.name) : text(field.name);
855
+ break;
856
+ case 'json':
857
+ case 'array':
858
+ case 'group':
859
+ case 'blocks':
860
+ col = jsonb(field.name);
861
+ break;
862
+ case 'relation':
863
+ if (field.hasMany) {
864
+ col = jsonb(field.name);
865
+ }
866
+ else {
867
+ col = text(field.name);
868
+ }
869
+ break;
870
+ case 'media':
871
+ col = jsonb(field.name);
872
+ break;
873
+ case 'code':
874
+ case 'radio':
875
+ col = text(field.name);
876
+ break;
877
+ case 'collapsible':
878
+ case 'join':
879
+ case 'point':
880
+ col = jsonb(field.name);
881
+ break;
882
+ case 'row':
883
+ case 'ui':
884
+ // Layout/presentational fields — no DB column
885
+ return undefined;
886
+ default:
887
+ col = text(field.name);
888
+ }
889
+ if (field.unique)
890
+ col = col.unique();
891
+ if (field.required && !field.localized)
892
+ col = col.notNull();
893
+ return col;
894
+ }
895
+ mapFieldToSqlType(field) {
896
+ if (field.localized)
897
+ return 'JSONB';
898
+ switch (field.type) {
899
+ case 'number':
900
+ return 'INTEGER';
901
+ case 'checkbox':
902
+ case 'boolean':
903
+ return 'BOOLEAN';
904
+ case 'date':
905
+ return 'TIMESTAMP';
906
+ case 'richtext':
907
+ return field.format === 'json' ? 'JSONB' : 'TEXT';
908
+ case 'json':
909
+ case 'array':
910
+ case 'group':
911
+ case 'blocks':
912
+ return 'JSONB';
913
+ case 'relation':
914
+ return field.hasMany ? 'JSONB' : 'TEXT';
915
+ case 'media':
916
+ return 'JSONB';
917
+ case 'code':
918
+ case 'radio':
919
+ return 'TEXT';
920
+ case 'collapsible':
921
+ case 'join':
922
+ case 'point':
923
+ return 'JSONB';
924
+ case 'row':
925
+ case 'ui':
926
+ return 'SKIP';
927
+ default:
928
+ return 'TEXT';
929
+ }
930
+ }
931
+ async registerCollection(config, db = this.db) {
932
+ logger.info(`PostgresDrizzleAdapter: Dynamic Column Mapping for ${config.slug}`);
933
+ this.configs[config.slug] = config;
934
+ const columns = {
935
+ id: text('id').primaryKey(),
936
+ createdAt: timestamp('created_at').defaultNow().notNull(),
937
+ updatedAt: timestamp('updated_at').defaultNow().notNull(),
938
+ };
939
+ if (config.drafts) {
940
+ columns['status'] = text('status').default('published');
941
+ }
942
+ if (config.softDelete) {
943
+ columns['deletedAt'] = timestamp('deleted_at');
944
+ }
945
+ for (const field of config.fields) {
946
+ if (field.type === 'relation' && field.junctionTable) {
947
+ continue;
948
+ }
949
+ // Layout/presentational fields (row, ui) and virtual fields have no DB column
950
+ if (field.type === 'row' || field.type === 'ui' || field.virtual) {
951
+ continue;
952
+ }
953
+ columns[field.name] = this.mapFieldToDrizzleColumn(field);
954
+ }
955
+ this.tables[config.slug] = pgTable(config.slug, columns);
956
+ if (process.env.DISABLE_AUTO_MIGRATIONS !== 'true') {
957
+ await this._runAutoMigrations(config, db);
958
+ for (const tenant of Object.values(this.tenantPools)) {
959
+ try {
960
+ await this._runAutoMigrations(config, tenant.db);
961
+ }
962
+ catch (err) {
963
+ logger.error({ err: err.message }, `PostgresDrizzleAdapter: Tenant migration failed for ${config.slug}`);
964
+ }
965
+ }
966
+ }
967
+ }
968
+ async getExistingCollections() {
969
+ const result = await this.db.execute(sql `
970
+ SELECT table_name
971
+ FROM information_schema.tables
972
+ WHERE table_schema = 'public'
973
+ `);
974
+ return (result.rows || []).map((r) => r.table_name);
975
+ }
976
+ async _runAutoMigrations(config, db = this.db) {
977
+ const isValidIdentifier = (id) => /^[a-zA-Z0-9_]+$/.test(id);
978
+ if (!isValidIdentifier(config.slug)) {
979
+ throw new Error(`Invalid table name identifier: ${config.slug}`);
980
+ }
981
+ for (const field of config.fields) {
982
+ if (!isValidIdentifier(field.name)) {
983
+ throw new Error(`Invalid column name identifier: ${field.name} on collection ${config.slug}`);
984
+ }
985
+ }
986
+ let acquired = false;
987
+ try {
988
+ await db.execute(sql `SELECT pg_advisory_lock(99999)`);
989
+ acquired = true;
990
+ }
991
+ catch (err) {
992
+ logger.warn({ err: err.message }, 'PostgresDrizzleAdapter: Auto-migration advisory lock acquisition failed/timed out. Proceeding without lock.');
993
+ }
994
+ try {
995
+ let createSql = `CREATE TABLE IF NOT EXISTS "${config.slug}" (\n "id" TEXT PRIMARY KEY`;
996
+ createSql += `,\n "created_at" TIMESTAMP DEFAULT NOW() NOT NULL`;
997
+ createSql += `,\n "updated_at" TIMESTAMP DEFAULT NOW() NOT NULL`;
998
+ if (config.drafts) {
999
+ createSql += `,\n "status" TEXT DEFAULT 'published'`;
1000
+ }
1001
+ if (config.softDelete) {
1002
+ createSql += `,\n "deleted_at" TIMESTAMP`;
1003
+ }
1004
+ for (const field of config.fields) {
1005
+ if (field.type === 'relation' && field.junctionTable) {
1006
+ continue;
1007
+ }
1008
+ // Layout/presentational and virtual fields have no DB column
1009
+ if (field.type === 'row' || field.type === 'ui' || field.virtual) {
1010
+ continue;
1011
+ }
1012
+ const sqlType = this.mapFieldToSqlType(field);
1013
+ createSql += `,\n "${field.name}" ${sqlType}`;
1014
+ if (field.unique)
1015
+ createSql += ' UNIQUE';
1016
+ if (field.required)
1017
+ createSql += ' NOT NULL';
1018
+ }
1019
+ createSql += `\n);`;
1020
+ await db.execute(sql.raw(createSql));
1021
+ const result = await db.execute(sql `
1022
+ SELECT column_name FROM information_schema.columns
1023
+ WHERE table_name = ${config.slug};
1024
+ `);
1025
+ const existingCols = (result.rows || []).map((r) => r.column_name);
1026
+ if (config.softDelete && !existingCols.includes('deleted_at')) {
1027
+ logger.info(`PostgresDrizzleAdapter: Auto-migrating ADD COLUMN "deleted_at" to "${config.slug}"`);
1028
+ await db.execute(sql.raw(`ALTER TABLE "${config.slug}" ADD COLUMN "deleted_at" TIMESTAMP`));
1029
+ }
1030
+ for (const field of config.fields) {
1031
+ if (field.type === 'relation' && field.junctionTable) {
1032
+ continue;
1033
+ }
1034
+ if (field.type === 'row' || field.type === 'ui' || field.virtual) {
1035
+ continue;
1036
+ }
1037
+ if (!existingCols.includes(field.name)) {
1038
+ logger.info(`PostgresDrizzleAdapter: Auto-migrating ADD COLUMN "${field.name}" to "${config.slug}"`);
1039
+ const sqlType = this.mapFieldToSqlType(field);
1040
+ let alterSql = `ALTER TABLE "${config.slug}" ADD COLUMN "${field.name}" ${sqlType}`;
1041
+ if (field.unique)
1042
+ alterSql += ' UNIQUE';
1043
+ if (field.required && !field.localized) {
1044
+ const countRes = await db.execute(sql.raw(`SELECT count(*) as c FROM "${config.slug}"`));
1045
+ const count = parseInt(String(countRes.rows[0].c), 10);
1046
+ if (count === 0) {
1047
+ alterSql += ' NOT NULL';
1048
+ }
1049
+ else {
1050
+ logger.warn(`PostgresDrizzleAdapter: Bypassing NOT NULL for new column "${field.name}" because table "${config.slug}" contains ${count} existing rows. Backfill data before enforcing constraint.`);
1051
+ }
1052
+ }
1053
+ await db.execute(sql.raw(alterSql));
1054
+ }
1055
+ }
1056
+ for (const field of config.fields) {
1057
+ if (field.type === 'relation' && field.junctionTable) {
1058
+ continue;
1059
+ }
1060
+ if (field.type === 'row' || field.type === 'ui' || field.virtual) {
1061
+ continue;
1062
+ }
1063
+ if (field.unique ||
1064
+ field.index ||
1065
+ field.searchable ||
1066
+ field.indexed) {
1067
+ logger.info(`PostgresDrizzleAdapter: Auto-creating index for "${field.name}" on "${config.slug}"`);
1068
+ const indexName = `idx_${config.slug}_${field.name}`;
1069
+ let indexSql;
1070
+ if (field.localized ||
1071
+ field.type === 'json' ||
1072
+ field.type === 'array' ||
1073
+ field.type === 'group' ||
1074
+ field.type === 'blocks') {
1075
+ indexSql = `CREATE INDEX IF NOT EXISTS "${indexName}" ON "${config.slug}" USING gin ("${field.name}");`;
1076
+ }
1077
+ else {
1078
+ indexSql = `CREATE INDEX IF NOT EXISTS "${indexName}" ON "${config.slug}" ("${field.name}");`;
1079
+ }
1080
+ try {
1081
+ await db.execute(sql.raw(indexSql));
1082
+ }
1083
+ catch (err) {
1084
+ logger.warn({ error: err.message }, `PostgresDrizzleAdapter: Index creation skipped or failed for "${indexName}"`);
1085
+ }
1086
+ }
1087
+ }
1088
+ // Process junction tables for relation fields
1089
+ for (const field of config.fields) {
1090
+ if (field.type === 'relation' && field.junctionTable) {
1091
+ const junctionTable = field.junctionTable;
1092
+ if (!isValidIdentifier(junctionTable)) {
1093
+ throw new Error(`Invalid junction table name identifier: ${junctionTable}`);
1094
+ }
1095
+ let createJunctionSql = `CREATE TABLE IF NOT EXISTS "${junctionTable}" (
1096
+ "id" UUID PRIMARY KEY DEFAULT gen_random_uuid(),
1097
+ "source_id" TEXT NOT NULL,
1098
+ "target_id" TEXT NOT NULL,
1099
+ "position" INTEGER NOT NULL DEFAULT 0,
1100
+ "relation_to" TEXT`;
1101
+ const pivotFields = field.pivotFields || [];
1102
+ for (const pf of pivotFields) {
1103
+ if (!isValidIdentifier(pf.name)) {
1104
+ throw new Error(`Invalid pivot column name identifier: ${pf.name} on junction table ${junctionTable}`);
1105
+ }
1106
+ const sqlType = this.mapFieldToSqlType(pf);
1107
+ createJunctionSql += `,\n "${pf.name}" ${sqlType}`;
1108
+ if (pf.unique)
1109
+ createJunctionSql += ' UNIQUE';
1110
+ if (pf.required)
1111
+ createJunctionSql += ' NOT NULL';
1112
+ }
1113
+ createJunctionSql += `\n);`;
1114
+ await db.execute(sql.raw(createJunctionSql));
1115
+ const sourceIdxName = `idx_${junctionTable}_source_id`;
1116
+ const targetIdxName = `idx_${junctionTable}_target_id`;
1117
+ const posIdxName = `idx_${junctionTable}_position`;
1118
+ await db.execute(sql.raw(`CREATE INDEX IF NOT EXISTS "${sourceIdxName}" ON "${junctionTable}" ("source_id");`));
1119
+ await db.execute(sql.raw(`CREATE INDEX IF NOT EXISTS "${targetIdxName}" ON "${junctionTable}" ("target_id");`));
1120
+ await db.execute(sql.raw(`CREATE INDEX IF NOT EXISTS "${posIdxName}" ON "${junctionTable}" ("source_id", "position");`));
1121
+ const jResult = await db.execute(sql `
1122
+ SELECT column_name FROM information_schema.columns
1123
+ WHERE table_name = ${junctionTable};
1124
+ `);
1125
+ const existingJCols = (jResult.rows || []).map((r) => r.column_name);
1126
+ // Always ensure position + relation_to columns exist
1127
+ for (const [col, type] of [['position', 'INTEGER NOT NULL DEFAULT 0'], ['relation_to', 'TEXT']]) {
1128
+ if (!existingJCols.includes(col)) {
1129
+ logger.info(`PostgresDrizzleAdapter: Auto-migrating ADD COLUMN "${col}" to junction table "${junctionTable}"`);
1130
+ await db.execute(sql.raw(`ALTER TABLE "${junctionTable}" ADD COLUMN "${col}" ${type}`));
1131
+ }
1132
+ }
1133
+ for (const pf of pivotFields) {
1134
+ if (!existingJCols.includes(pf.name)) {
1135
+ logger.info(`PostgresDrizzleAdapter: Auto-migrating ADD COLUMN "${pf.name}" to junction table "${junctionTable}"`);
1136
+ const sqlType = this.mapFieldToSqlType(pf);
1137
+ let alterSql = `ALTER TABLE "${junctionTable}" ADD COLUMN "${pf.name}" ${sqlType}`;
1138
+ if (pf.unique)
1139
+ alterSql += ' UNIQUE';
1140
+ await db.execute(sql.raw(alterSql));
1141
+ }
1142
+ }
1143
+ }
1144
+ }
1145
+ }
1146
+ finally {
1147
+ if (acquired) {
1148
+ try {
1149
+ await db.execute(sql `SELECT pg_advisory_unlock(99999)`);
1150
+ }
1151
+ catch (err) {
1152
+ logger.error({ err: err.message }, 'PostgresDrizzleAdapter: Failed to release advisory lock');
1153
+ }
1154
+ }
1155
+ }
1156
+ }
1157
+ getTable(collection) {
1158
+ if (collection === 'flows')
1159
+ return this.systemTables.flows;
1160
+ if (collection === 'users')
1161
+ return this.systemTables.users;
1162
+ if (collection === 'z_password_resets')
1163
+ return this.systemTables.passwordResets;
1164
+ if (collection === 'z_api_keys')
1165
+ return this.systemTables.apiKeys;
1166
+ if (collection === 'z_migrations')
1167
+ return this.systemTables.migrations;
1168
+ if (collection === 'z_settings')
1169
+ return this.systemTables.settings;
1170
+ if (collection === 'z_collections')
1171
+ return this.systemTables.collections;
1172
+ if (collection === 'z_presence')
1173
+ return this.systemTables.presence;
1174
+ if (collection === 'audit_logs' || collection === 'z_audit_logs')
1175
+ return this.systemTables.auditLog;
1176
+ if (collection === 'versions' || collection === 'z_versions')
1177
+ return this.systemTables.version;
1178
+ if (collection === 'z_sites' || collection === 'sites')
1179
+ return this.systemTables.sites;
1180
+ if (collection === 'z_workspaces' || collection === 'workspaces')
1181
+ return this.systemTables.workspaces;
1182
+ if (collection === 'z_locks')
1183
+ return this.systemTables.locks;
1184
+ if (collection === 'z_webhook_configs')
1185
+ return this.systemTables.webhookConfigs;
1186
+ if (collection === 'z_plugins')
1187
+ return this.systemTables.plugins;
1188
+ if (collection === 'z_redirects')
1189
+ return this.systemTables.redirects;
1190
+ if (collection === 'z_roles' || collection === 'roles')
1191
+ return this.systemTables.roles;
1192
+ if (collection === 'z_releases' || collection === 'releases')
1193
+ return this.systemTables.releases;
1194
+ const table = this.tables[collection];
1195
+ if (!table)
1196
+ throw new Error(`Collection "${collection}" not registered in PostgreSQL`);
1197
+ return table;
1198
+ }
1199
+ _getCacheKey(collection, query, options) {
1200
+ return `${collection}:${JSON.stringify(query)}:${JSON.stringify(options)}`;
1201
+ }
1202
+ async _invalidateCache(collection) {
1203
+ await this.cache.invalidate(collection);
1204
+ }
1205
+ buildWhereClause(table, query) {
1206
+ const ast = QueryASTParser.parse(query);
1207
+ return this.mapAstToDrizzle(table, ast);
1208
+ }
1209
+ /** Inject tenant scoping into the WHERE clause to prevent cross-tenant data access */
1210
+ tenantScope(table, where, options) {
1211
+ const siteId = options?.siteId || options?.tenantId;
1212
+ if (siteId && table.siteId) {
1213
+ const siteClause = eq(table.siteId, siteId);
1214
+ return where ? and(siteClause, where) : siteClause;
1215
+ }
1216
+ return where;
1217
+ }
1218
+ mapAstToDrizzle(table, node) {
1219
+ if (node.type === 'field') {
1220
+ const fieldNode = node;
1221
+ let fieldKey = fieldNode.field;
1222
+ if (fieldKey === '_id')
1223
+ fieldKey = 'id';
1224
+ else if (fieldKey === '_status')
1225
+ fieldKey = 'status';
1226
+ const column = table[fieldKey];
1227
+ if (!column)
1228
+ return undefined;
1229
+ switch (fieldNode.operator) {
1230
+ case 'equals':
1231
+ return eq(column, fieldNode.value);
1232
+ case 'not_equals':
1233
+ return sql `${column} <> ${fieldNode.value}`;
1234
+ case 'contains':
1235
+ return sql `${column} ILIKE ${'%' + fieldNode.value + '%'}`;
1236
+ case 'in':
1237
+ return sql `${column} = ANY(${fieldNode.value})`;
1238
+ case 'not_in':
1239
+ return sql `${column} <> ALL(${fieldNode.value})`;
1240
+ case 'gt':
1241
+ return sql `${column} > ${fieldNode.value}`;
1242
+ case 'gte':
1243
+ return sql `${column} >= ${fieldNode.value}`;
1244
+ case 'lt':
1245
+ return sql `${column} < ${fieldNode.value}`;
1246
+ case 'lte':
1247
+ return sql `${column} <= ${fieldNode.value}`;
1248
+ default:
1249
+ return eq(column, fieldNode.value);
1250
+ }
1251
+ }
1252
+ else if (node.type === 'logical') {
1253
+ const logicalNode = node;
1254
+ const conditions = logicalNode.children
1255
+ .map((child) => this.mapAstToDrizzle(table, child))
1256
+ .filter(Boolean);
1257
+ if (conditions.length === 0)
1258
+ return undefined;
1259
+ if (logicalNode.operator === 'and') {
1260
+ return and(...conditions);
1261
+ }
1262
+ else if (logicalNode.operator === 'or') {
1263
+ return or(...conditions);
1264
+ }
1265
+ }
1266
+ return undefined;
1267
+ }
1268
+ async find(collection, query, options = {}) {
1269
+ const cacheKey = this._getCacheKey(collection, query, options);
1270
+ const cached = await this.cache.get(cacheKey);
1271
+ if (cached)
1272
+ return cached;
1273
+ const globalAot = globalThis.zenithAotBridge;
1274
+ const queryKeys = Object.keys(query);
1275
+ const canUseAot = queryKeys.every(k => k === 'id' || k === '_id' || k === 'siteId');
1276
+ if (globalAot && canUseAot && globalAot.hasQuery(collection, 'find')) {
1277
+ const table = this.getTable(collection);
1278
+ const client = this.getDbClient(options);
1279
+ const aotFilters = {};
1280
+ if (query.id)
1281
+ aotFilters.id = query.id;
1282
+ if (query._id)
1283
+ aotFilters.id = query._id;
1284
+ if (query.siteId)
1285
+ aotFilters.siteId = query.siteId;
1286
+ const result = await globalAot.executeQuery(collection, 'find', client, table, aotFilters, options);
1287
+ const mapped = result.map((r) => {
1288
+ const mappedRecord = { ...r, _id: r.id };
1289
+ if ('status' in mappedRecord) {
1290
+ mappedRecord._status = mappedRecord.status;
1291
+ }
1292
+ return mappedRecord;
1293
+ });
1294
+ const loaded = await this._loadJunctionIds(collection, mapped);
1295
+ const populated = await this._populateRelations(collection, loaded, options, [collection]);
1296
+ await this.cache.set(cacheKey, populated, collection);
1297
+ return populated;
1298
+ }
1299
+ const table = this.getTable(collection);
1300
+ const client = this.getDbClient(options);
1301
+ let dbQuery = client.select().from(table).$dynamic();
1302
+ let where = this.buildWhereClause(table, query);
1303
+ where = this.tenantScope(table, where, options);
1304
+ if (where) {
1305
+ dbQuery = dbQuery.where(where);
1306
+ }
1307
+ if (options.limit) {
1308
+ dbQuery = dbQuery.limit(options.limit);
1309
+ }
1310
+ else {
1311
+ dbQuery = dbQuery.limit(100);
1312
+ }
1313
+ if (options.skip) {
1314
+ dbQuery = dbQuery.offset(options.skip);
1315
+ }
1316
+ const result = await dbQuery;
1317
+ const mapped = result.map((r) => {
1318
+ const mappedRecord = { ...r, _id: r.id };
1319
+ if ('status' in mappedRecord) {
1320
+ mappedRecord._status = mappedRecord.status;
1321
+ }
1322
+ return mappedRecord;
1323
+ });
1324
+ const loaded = await this._loadJunctionIds(collection, mapped);
1325
+ const populated = await this._populateRelations(collection, loaded, options, [collection]);
1326
+ await this.cache.set(cacheKey, populated, collection);
1327
+ return populated;
1328
+ }
1329
+ async findOne(collection, query, options = {}) {
1330
+ const table = this.getTable(collection);
1331
+ const client = this.getDbClient(options);
1332
+ const dbQuery = this._selectWithColumns(client, table, collection, options);
1333
+ let where = this.buildWhereClause(table, query);
1334
+ where = this.tenantScope(table, where, options);
1335
+ const result = await dbQuery.where(where ?? sql `1=1`).limit(1);
1336
+ if (result.length === 0)
1337
+ return null;
1338
+ const r = result[0];
1339
+ const mappedRecord = { ...r, _id: r.id };
1340
+ if ('status' in mappedRecord) {
1341
+ mappedRecord._status = mappedRecord.status;
1342
+ }
1343
+ const loaded = await this._loadJunctionIds(collection, [mappedRecord]);
1344
+ const populated = await this._populateRelations(collection, loaded, options, [collection]);
1345
+ return populated[0];
1346
+ }
1347
+ /**
1348
+ * Builds a Drizzle select query, optionally scoped to a subset of columns.
1349
+ * When options.select is populated, only those columns are fetched (plus
1350
+ * always-loaded metadata: id, createdAt, updatedAt, status).
1351
+ */
1352
+ _selectWithColumns(client, table, collection, options) {
1353
+ // When populate is enabled, we need all columns to resolve relation lookups
1354
+ const needsAll = !options.select ||
1355
+ (options.populate &&
1356
+ (Array.isArray(options.populate) ? options.populate.length > 0 : !!options.populate));
1357
+ if (needsAll) {
1358
+ return client.select().from(table).$dynamic();
1359
+ }
1360
+ // Column selection: extract safe column names from config
1361
+ const config = this.configs[collection];
1362
+ const safeCols = new Set(['id', 'created_at', 'updated_at', 'status']);
1363
+ if (config?.fields) {
1364
+ for (const f of config.fields) {
1365
+ safeCols.add(f.name);
1366
+ }
1367
+ }
1368
+ // Always include meta columns for the result mapper
1369
+ const requested = Array.isArray(options.select) ? options.select : [];
1370
+ const toSelect = requested
1371
+ .filter((col) => safeCols.has(col))
1372
+ .map((col) => {
1373
+ const mapped = col === 'id' ? table.id
1374
+ : col === 'created_at' ? table.createdAt
1375
+ : col === 'updated_at' ? table.updatedAt
1376
+ : col === 'status' ? table.status
1377
+ : table[col];
1378
+ return mapped;
1379
+ })
1380
+ .filter(Boolean);
1381
+ if (toSelect.length === 0) {
1382
+ return client.select().from(table).$dynamic();
1383
+ }
1384
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
1385
+ return client.select(...toSelect).from(table).$dynamic();
1386
+ }
1387
+ /** Maximum depth for nested relation population to prevent query explosion */
1388
+ static MAX_POPULATE_DEPTH = 5;
1389
+ async _populateRelations(collection, records, options, populationPath = [collection], _depth = PostgresDrizzleAdapter.MAX_POPULATE_DEPTH) {
1390
+ if (!records || records.length === 0 || !options.populate) {
1391
+ return records;
1392
+ }
1393
+ // Task 07: Depth guard — stop recursing beyond MAX_POPULATE_DEPTH
1394
+ if (_depth <= 0) {
1395
+ logger.debug({ collection, depth: _depth }, 'PostgresDrizzleAdapter: _populateRelations depth limit reached, skipping');
1396
+ return records;
1397
+ }
1398
+ const config = this.configs[collection];
1399
+ if (!config) {
1400
+ return records;
1401
+ }
1402
+ const populateFields = (Array.isArray(options.populate) ? options.populate : [options.populate]).filter(Boolean);
1403
+ // --- Deep nested field walker: find relation fields inside group/array/blocks containers ---
1404
+ const allRelationFields = [];
1405
+ const walkFields = (fields, path = '') => {
1406
+ if (!fields)
1407
+ return;
1408
+ for (const f of fields) {
1409
+ if (f.type === 'relation') {
1410
+ const fullPath = path ? `${path}.${f.name}` : f.name;
1411
+ // Check top-level population whitelist; if empty (fetch-all), include all
1412
+ const inWhitelist = populateFields.length === 0 || populateFields.some(p => p === fullPath || p === f.name || (path && p.startsWith(`${path}.`)));
1413
+ if (inWhitelist)
1414
+ allRelationFields.push({ containerPath: path, field: f });
1415
+ }
1416
+ else if ((f.type === 'group' || f.type === 'array' || f.type === 'blocks') && f.fields) {
1417
+ const blocks = f.type === 'blocks' && f.blocks ? f.blocks : [f];
1418
+ for (const block of blocks) {
1419
+ walkFields(block.fields, path ? `${path}.${f.name}` : f.name);
1420
+ }
1421
+ }
1422
+ }
1423
+ };
1424
+ walkFields(config.fields);
1425
+ // Process each discovered relation field
1426
+ for (const { containerPath, field: relField } of allRelationFields) {
1427
+ const relationTo = relField.relationTo;
1428
+ const hasMany = relField.hasMany ?? true;
1429
+ // Polymorphic relationTo[] — flatten all IDs from { relationTo, value } pairs or bare IDs
1430
+ const resolveIds = (val) => {
1431
+ const ids = new Set();
1432
+ if (!val)
1433
+ return ids;
1434
+ if (Array.isArray(val)) {
1435
+ for (const item of val) {
1436
+ // Polymorphic format: { relationTo: "posts", value: "abc123" }
1437
+ if (item && typeof item === 'object' && 'value' in item && 'relationTo' in item) {
1438
+ if (item.value)
1439
+ ids.add(String(item.value));
1440
+ }
1441
+ else if (item) {
1442
+ ids.add(String(item));
1443
+ }
1444
+ }
1445
+ }
1446
+ else if (val && typeof val === 'object' && 'value' in val && 'relationTo' in val) {
1447
+ // Single polymorphic
1448
+ if (val.value)
1449
+ ids.add(String(val.value));
1450
+ }
1451
+ else if (typeof val === 'string') {
1452
+ const trimmed = val.trim();
1453
+ if (trimmed.startsWith('[') && trimmed.endsWith(']')) {
1454
+ try {
1455
+ resolveIds(JSON.parse(trimmed)).forEach(id => ids.add(id));
1456
+ }
1457
+ catch { /* ignore */ }
1458
+ }
1459
+ else {
1460
+ ids.add(val);
1461
+ }
1462
+ }
1463
+ return ids;
1464
+ };
1465
+ const idsToFetch = new Set();
1466
+ for (const record of records) {
1467
+ if (containerPath) {
1468
+ // Navigate into nested container
1469
+ const parts = containerPath.split('.');
1470
+ let current = record;
1471
+ for (const p of parts) {
1472
+ current = current?.[p];
1473
+ }
1474
+ const nestedVal = current?.[relField.name];
1475
+ resolveIds(nestedVal).forEach(id => idsToFetch.add(id));
1476
+ }
1477
+ else {
1478
+ resolveIds(record[relField.name]).forEach(id => idsToFetch.add(id));
1479
+ }
1480
+ }
1481
+ // Initialize default values when no IDs present
1482
+ if (idsToFetch.size === 0) {
1483
+ for (const record of records) {
1484
+ const setter = (obj) => {
1485
+ if (obj[relField.name] === undefined || obj[relField.name] === null) {
1486
+ obj[relField.name] = hasMany ? [] : null;
1487
+ }
1488
+ };
1489
+ if (containerPath) {
1490
+ const parts = containerPath.split('.');
1491
+ let current = record;
1492
+ for (const p of parts.slice(0, -1)) {
1493
+ current = current?.[p];
1494
+ }
1495
+ setter(current);
1496
+ }
1497
+ else {
1498
+ setter(record);
1499
+ }
1500
+ }
1501
+ continue;
1502
+ }
1503
+ // --- Circular reference protection ---
1504
+ const targetCollections = Array.isArray(relationTo) ? relationTo : [relationTo];
1505
+ for (const target of targetCollections) {
1506
+ if (populationPath.includes(target)) {
1507
+ logger.debug(`[Population] Circular protection: skipping ${collection} → ${target}`);
1508
+ continue;
1509
+ }
1510
+ if (!this.configs[target]) {
1511
+ logger.debug(`[Population] Unknown collection "${target}" — skipping`);
1512
+ continue;
1513
+ }
1514
+ // Recursively populate nested relations (passes path for depth tracking)
1515
+ const nestedPath = [...populationPath, target];
1516
+ const relatedDocs = await this.find(target, { id: { $in: Array.from(idsToFetch) } }, { session: options.session, siteId: options.siteId });
1517
+ // Recurse into the related docs to populate their own nested relations
1518
+ await this._populateRelations(target, relatedDocs, options, nestedPath, _depth - 1);
1519
+ const docMap = new Map();
1520
+ for (const doc of relatedDocs) {
1521
+ docMap.set(doc.id, doc);
1522
+ }
1523
+ const linkMap = new Map();
1524
+ if (relField.junctionTable) {
1525
+ const sourceIds = records.map(r => r.id);
1526
+ const pivotFields = relField.pivotFields || [];
1527
+ const selectCols = ['source_id', 'target_id', 'position', ...pivotFields.map((f) => `"${f.name}"`)];
1528
+ try {
1529
+ const linksResult = await this.db.execute(sql `SELECT ${sql.raw(selectCols.join(', '))} FROM ${sql.raw(`"${relField.junctionTable}"`)} WHERE source_id = ANY(${sourceIds}) ORDER BY "position" ASC NULLS LAST`);
1530
+ for (const link of linksResult.rows || []) {
1531
+ linkMap.set(`${link.source_id}_${link.target_id}`, link);
1532
+ }
1533
+ }
1534
+ catch (err) {
1535
+ logger.warn({ err: err.message }, 'Failed to fetch pivot fields for populated relation');
1536
+ }
1537
+ }
1538
+ for (const record of records) {
1539
+ let rec = record;
1540
+ if (containerPath) {
1541
+ const parts = containerPath.split('.');
1542
+ for (const p of parts.slice(0, -1)) {
1543
+ rec = rec?.[p];
1544
+ }
1545
+ }
1546
+ let val = containerPath ? rec?.[relField.name] : record[relField.name];
1547
+ const isPolymorphic = Array.isArray(relationTo);
1548
+ if (!val)
1549
+ val = hasMany ? [] : null;
1550
+ if (hasMany && Array.isArray(val)) {
1551
+ rec[relField.name] = val
1552
+ .map((idOrObj) => {
1553
+ // Extract ID from polymorphic or bare
1554
+ const id = isPolymorphic && idOrObj && typeof idOrObj === 'object' ? idOrObj.value : String(idOrObj);
1555
+ const doc = docMap.get(id);
1556
+ if (!doc)
1557
+ return null;
1558
+ if (relField.junctionTable) {
1559
+ const cloned = { ...doc };
1560
+ const link = linkMap.get(`${record.id}_${id}`);
1561
+ if (link) {
1562
+ cloned._pivot = { ...link };
1563
+ delete cloned._pivot.source_id;
1564
+ delete cloned._pivot.target_id;
1565
+ delete cloned._pivot.id;
1566
+ }
1567
+ return cloned;
1568
+ }
1569
+ if (isPolymorphic) {
1570
+ const rt = idOrObj?.relationTo || relationTo;
1571
+ return { relationTo: rt, value: doc };
1572
+ }
1573
+ return doc;
1574
+ })
1575
+ .filter(Boolean);
1576
+ }
1577
+ else if (!hasMany) {
1578
+ const id = isPolymorphic && val && typeof val === 'object' ? val.value : String(val);
1579
+ const doc = docMap.get(id);
1580
+ rec[relField.name] = doc ? (isPolymorphic ? { relationTo: relationTo, value: doc } : doc) : null;
1581
+ }
1582
+ }
1583
+ }
1584
+ }
1585
+ return records;
1586
+ }
1587
+ async _loadJunctionIds(collection, records) {
1588
+ if (!records || records.length === 0)
1589
+ return records;
1590
+ const config = this.configs[collection];
1591
+ if (!config)
1592
+ return records;
1593
+ const recordIds = records.map(r => r.id);
1594
+ if (recordIds.length === 0)
1595
+ return records;
1596
+ for (const field of config.fields) {
1597
+ if (field.type === 'relation' && field.junctionTable) {
1598
+ const jTable = field.junctionTable;
1599
+ const relationTo = field.relationTo;
1600
+ const isPolymorphic = Array.isArray(relationTo);
1601
+ try {
1602
+ // Load junction rows ordered by position (for M2M ordering)
1603
+ const rowsResult = await this.db.execute(sql `
1604
+ SELECT source_id, target_id, relation_to, "position"
1605
+ FROM ${sql.raw(`"${jTable}"`)}
1606
+ WHERE source_id = ANY(${recordIds})
1607
+ ORDER BY "position" ASC NULLS LAST
1608
+ `);
1609
+ const rows = rowsResult.rows || [];
1610
+ // Build source → sorted entries map (preserving position order)
1611
+ const sourceToTargets = {};
1612
+ for (const row of rows) {
1613
+ if (!sourceToTargets[row.source_id])
1614
+ sourceToTargets[row.source_id] = [];
1615
+ // Polymorphic format: store { value, relationTo } or bare ID
1616
+ const entry = isPolymorphic
1617
+ ? { value: row.target_id, relationTo: row.relation_to || relationTo[0] }
1618
+ : row.target_id;
1619
+ sourceToTargets[row.source_id].push(entry);
1620
+ }
1621
+ for (const r of records) {
1622
+ r[field.name] = sourceToTargets[r.id] || [];
1623
+ }
1624
+ }
1625
+ catch (err) {
1626
+ logger.warn({ err: err.message }, `Failed to load junction IDs for ${field.name}`);
1627
+ }
1628
+ }
1629
+ }
1630
+ return records;
1631
+ }
1632
+ async _writeJunctionRelations(collection, id, data, executor) {
1633
+ const config = this.configs[collection];
1634
+ if (!config)
1635
+ return data;
1636
+ const updatedData = { ...data };
1637
+ for (const field of config.fields) {
1638
+ if (field.type === 'relation' && field.junctionTable) {
1639
+ const jTable = field.junctionTable;
1640
+ const relationVal = data[field.name];
1641
+ const relationTo = field.relationTo;
1642
+ const isPolymorphic = Array.isArray(relationTo);
1643
+ await executor.execute(sql `DELETE FROM ${sql.raw(`"${jTable}"`)} WHERE source_id = ${id}`);
1644
+ if (Array.isArray(relationVal)) {
1645
+ const pivotFields = field.pivotFields || [];
1646
+ let positionCounter = 0;
1647
+ for (const item of relationVal) {
1648
+ let targetId;
1649
+ let pivotData = {};
1650
+ if (typeof item === 'string') {
1651
+ targetId = item;
1652
+ }
1653
+ else if (item && typeof item === 'object') {
1654
+ // Polymorphic: { value, relationTo } or { id, ...pivot }
1655
+ if ('value' in item && 'relationTo' in item) {
1656
+ targetId = item.value;
1657
+ }
1658
+ else {
1659
+ targetId = item.id || item.target_id || '';
1660
+ }
1661
+ pivotData = { ...item };
1662
+ delete pivotData.id;
1663
+ delete pivotData.target_id;
1664
+ delete pivotData.value;
1665
+ delete pivotData.relationTo;
1666
+ }
1667
+ else {
1668
+ continue;
1669
+ }
1670
+ if (!targetId)
1671
+ continue;
1672
+ const cols = ['source_id', 'target_id', '"position"'];
1673
+ const vals = [id, targetId, positionCounter++];
1674
+ if (isPolymorphic && relationTo.length > 0) {
1675
+ const rt = typeof item === 'object' && 'relationTo' in item
1676
+ ? item.relationTo
1677
+ : (Array.isArray(relationTo) ? relationTo[0] : relationTo);
1678
+ cols.push('"relation_to"');
1679
+ vals.push(rt);
1680
+ }
1681
+ for (const pf of pivotFields) {
1682
+ const val = pivotData[pf.name];
1683
+ if (val !== undefined) {
1684
+ cols.push(`"${pf.name}"`);
1685
+ vals.push(val);
1686
+ }
1687
+ }
1688
+ const fragments = [sql `INSERT INTO ${sql.raw(`"${jTable}"`)} (${sql.raw(cols.join(', '))}) VALUES (`];
1689
+ vals.forEach((val, i) => {
1690
+ if (i > 0)
1691
+ fragments.push(sql `, `);
1692
+ fragments.push(sql `${val}`);
1693
+ });
1694
+ fragments.push(sql `)`);
1695
+ await executor.execute(sql `${fragments}`);
1696
+ }
1697
+ }
1698
+ }
1699
+ }
1700
+ return updatedData;
1701
+ }
1702
+ async create(collection, data, options = {}) {
1703
+ const globalAot = globalThis.zenithAotBridge;
1704
+ if (globalAot && globalAot.hasQuery(collection, 'create')) {
1705
+ const table = this.getTable(collection);
1706
+ const client = this.getDbClient(options);
1707
+ const executor = options.session ? options.session : client;
1708
+ const id = data.id || data._id || crypto.randomUUID();
1709
+ const valuesToInsert = {
1710
+ id,
1711
+ createdAt: new Date(),
1712
+ updatedAt: new Date(),
1713
+ };
1714
+ for (const [key, val] of Object.entries(data)) {
1715
+ let fieldKey = key;
1716
+ if (key === '_status')
1717
+ fieldKey = 'status';
1718
+ else if (key === '_id')
1719
+ fieldKey = 'id';
1720
+ if (fieldKey !== 'id' && fieldKey !== '_id' && table[fieldKey] !== undefined && val !== undefined) {
1721
+ valuesToInsert[fieldKey] = val;
1722
+ }
1723
+ }
1724
+ const doc = await globalAot.executeQuery(collection, 'create', executor, table, valuesToInsert);
1725
+ await this._writeJunctionRelations(collection, id, data, executor);
1726
+ await this._invalidateCache(collection);
1727
+ const mappedRecord = { ...doc, ...data, id, _id: id };
1728
+ if ('status' in mappedRecord) {
1729
+ mappedRecord._status = mappedRecord.status;
1730
+ }
1731
+ return mappedRecord;
1732
+ }
1733
+ const table = this.getTable(collection);
1734
+ const id = data.id || data._id || crypto.randomUUID();
1735
+ const client = this.getDbClient(options);
1736
+ const executor = options.session ? options.session : client;
1737
+ const valuesToInsert = {
1738
+ id,
1739
+ createdAt: new Date(),
1740
+ updatedAt: new Date(),
1741
+ };
1742
+ for (const [key, val] of Object.entries(data)) {
1743
+ let fieldKey = key;
1744
+ if (key === '_status')
1745
+ fieldKey = 'status';
1746
+ else if (key === '_id')
1747
+ fieldKey = 'id';
1748
+ if (fieldKey !== 'id' && fieldKey !== '_id' && table[fieldKey] !== undefined && val !== undefined) {
1749
+ valuesToInsert[fieldKey] = val;
1750
+ }
1751
+ }
1752
+ await executor.insert(table).values(valuesToInsert);
1753
+ await this._writeJunctionRelations(collection, id, data, executor);
1754
+ await this._invalidateCache(collection);
1755
+ const output = { ...valuesToInsert, ...data, _id: id };
1756
+ if ('status' in output) {
1757
+ output._status = output.status;
1758
+ }
1759
+ return output;
1760
+ }
1761
+ async update(collection, id, data, options = {}) {
1762
+ const table = this.getTable(collection);
1763
+ const client = this.getDbClient(options);
1764
+ const executor = options.session ? options.session : client;
1765
+ const existing = await this.findOne(collection, { id }, options);
1766
+ if (!existing)
1767
+ return null;
1768
+ const mergedData = { ...existing, ...data };
1769
+ delete mergedData.id;
1770
+ delete mergedData._id;
1771
+ delete mergedData.createdAt;
1772
+ delete mergedData.updatedAt;
1773
+ const valuesToUpdate = {
1774
+ updatedAt: new Date(),
1775
+ };
1776
+ for (const [key, val] of Object.entries(mergedData)) {
1777
+ let fieldKey = key;
1778
+ if (key === '_status')
1779
+ fieldKey = 'status';
1780
+ else if (key === '_id')
1781
+ fieldKey = 'id';
1782
+ if (table[fieldKey] !== undefined && val !== undefined) {
1783
+ valuesToUpdate[fieldKey] = val;
1784
+ }
1785
+ }
1786
+ await executor.update(table).set(valuesToUpdate).where(eq(table.id, id));
1787
+ await this._writeJunctionRelations(collection, id, mergedData, executor);
1788
+ await this._invalidateCache(collection);
1789
+ const output = { id, ...valuesToUpdate, ...mergedData, _id: id };
1790
+ if ('status' in output) {
1791
+ output._status = output.status;
1792
+ }
1793
+ return output;
1794
+ }
1795
+ async findOneAndUpdate(collection, query, update, options) {
1796
+ const table = this.getTable(collection);
1797
+ const client = this.getDbClient(options);
1798
+ const executor = options?.session ? options.session : client;
1799
+ let where = this.buildWhereClause(table, query);
1800
+ where = this.tenantScope(table, where, options);
1801
+ if (options?.returnDocument === 'after') {
1802
+ const setData = { ...update, updatedAt: new Date() };
1803
+ const result = await executor
1804
+ .update(table)
1805
+ .set(setData)
1806
+ .where(where ?? sql `1=1`)
1807
+ .returning();
1808
+ const rows = result;
1809
+ if (!rows.length)
1810
+ return null;
1811
+ const r = rows[0];
1812
+ const mapped = { ...r, _id: r.id };
1813
+ if ('status' in mapped)
1814
+ mapped._status = mapped.status;
1815
+ return mapped;
1816
+ }
1817
+ // returnDocument: 'before' or omitted — fetch before updating
1818
+ const before = await this.findOne(collection, query, options);
1819
+ if (!before)
1820
+ return null;
1821
+ await executor.update(table).set({ ...update, updatedAt: new Date() }).where(where ?? sql `1=1`);
1822
+ return before;
1823
+ }
1824
+ async updateMany(collection, query, data, options = {}) {
1825
+ const table = this.getTable(collection);
1826
+ const client = this.getDbClient(options);
1827
+ const executor = options.session ? options.session : client;
1828
+ const updatePayload = {
1829
+ updatedAt: new Date(),
1830
+ };
1831
+ for (const [key, val] of Object.entries(data)) {
1832
+ let fieldKey = key;
1833
+ if (key === '_status')
1834
+ fieldKey = 'status';
1835
+ else if (key === '_id')
1836
+ fieldKey = 'id';
1837
+ if (table[fieldKey] !== undefined && val !== undefined) {
1838
+ updatePayload[fieldKey] = val;
1839
+ }
1840
+ }
1841
+ let dbQuery = executor.update(table).set(updatePayload).$dynamic();
1842
+ let where = this.buildWhereClause(table, query);
1843
+ where = this.tenantScope(table, where, options);
1844
+ if (where) {
1845
+ dbQuery = dbQuery.where(where);
1846
+ }
1847
+ const result = await dbQuery.returning({ id: table.id });
1848
+ await this._invalidateCache(collection);
1849
+ return result.length;
1850
+ }
1851
+ async delete(collection, id, options = {}) {
1852
+ const table = this.getTable(collection);
1853
+ const client = this.getDbClient(options);
1854
+ const executor = options.session ? options.session : client;
1855
+ const config = this.configs[collection];
1856
+ if (config) {
1857
+ for (const field of config.fields) {
1858
+ if (field.type === 'relation' && field.junctionTable) {
1859
+ await executor.execute(sql `DELETE FROM ${sql.raw(`"${field.junctionTable}"`)} WHERE source_id = ${id}`);
1860
+ }
1861
+ }
1862
+ }
1863
+ const result = await executor.delete(table).where(eq(table.id, id)).returning({ id: table.id });
1864
+ await this._invalidateCache(collection);
1865
+ return result.length > 0;
1866
+ }
1867
+ async deleteMany(collection, query, options = {}) {
1868
+ const table = this.getTable(collection);
1869
+ const client = this.getDbClient(options);
1870
+ const executor = options.session ? options.session : client;
1871
+ let where = this.buildWhereClause(table, query);
1872
+ where = this.tenantScope(table, where, options);
1873
+ let dbQuery = executor.delete(table).$dynamic();
1874
+ if (where) {
1875
+ dbQuery = dbQuery.where(where);
1876
+ }
1877
+ let ids = [];
1878
+ try {
1879
+ const selectQuery = executor.select({ id: table.id }).from(table).$dynamic();
1880
+ const selectWhere = where ? selectQuery.where(where) : selectQuery;
1881
+ const rows = await selectWhere;
1882
+ ids = rows.map((r) => r.id);
1883
+ }
1884
+ catch (err) {
1885
+ // Ignore select failure
1886
+ }
1887
+ if (ids.length > 0) {
1888
+ const config = this.configs[collection];
1889
+ if (config) {
1890
+ for (const field of config.fields) {
1891
+ if (field.type === 'relation' && field.junctionTable) {
1892
+ await executor.execute(sql `DELETE FROM ${sql.raw(`"${field.junctionTable}"`)} WHERE source_id = ANY(${ids})`);
1893
+ }
1894
+ }
1895
+ }
1896
+ }
1897
+ const result = await dbQuery.returning({ id: table.id });
1898
+ await this._invalidateCache(collection);
1899
+ return result.length;
1900
+ }
1901
+ async count(collection, query, options) {
1902
+ const table = this.getTable(collection);
1903
+ const client = this.getDbClient(options);
1904
+ let dbQuery = client
1905
+ .select({ count: sql `count(*)` })
1906
+ .from(table)
1907
+ .$dynamic();
1908
+ let where = this.buildWhereClause(table, query);
1909
+ where = this.tenantScope(table, where, options);
1910
+ if (where) {
1911
+ dbQuery = dbQuery.where(where);
1912
+ }
1913
+ const result = await dbQuery;
1914
+ return Number(result[0]?.count || 0);
1915
+ }
1916
+ async aggregate(collection, pipeline, options) {
1917
+ throw new Error('Aggregation pipelines not natively supported in Postgres. Use native SQL.');
1918
+ }
1919
+ async transaction(fn) {
1920
+ return this.db.transaction(async (tx) => {
1921
+ return await fn(tx);
1922
+ });
1923
+ }
1924
+ async createAuditLog(data, options) {
1925
+ const client = this.getDbClient(options);
1926
+ await client.insert(this.systemTables.auditLog).values({
1927
+ collectionName: data.collectionName,
1928
+ documentId: data.documentId,
1929
+ userId: data.userId,
1930
+ userEmail: data.userEmail,
1931
+ userName: data.userName,
1932
+ action: data.action,
1933
+ changes: data.changes,
1934
+ ip: data.ip,
1935
+ userAgent: data.userAgent,
1936
+ status: data.status,
1937
+ resource: data.resource,
1938
+ siteId: data.siteId,
1939
+ hash: data.hash,
1940
+ previousHash: data.previousHash,
1941
+ });
1942
+ }
1943
+ async createVersion(data, options) {
1944
+ const client = this.getDbClient(options);
1945
+ await client.insert(this.systemTables.version).values({
1946
+ collectionName: data.collectionName,
1947
+ collectionSlug: data.collectionSlug,
1948
+ documentId: data.documentId,
1949
+ snapshot: data.snapshot,
1950
+ delta: data.delta,
1951
+ createdBy: data.createdBy,
1952
+ });
1953
+ }
1954
+ async getVersions(collection, documentId, options) {
1955
+ const table = this.systemTables.version;
1956
+ const client = this.getDbClient(options);
1957
+ const result = await client
1958
+ .select()
1959
+ .from(table)
1960
+ .where(and(eq(table.collectionName, collection), eq(table.documentId, documentId)))
1961
+ .orderBy(desc(table.timestamp));
1962
+ return result.map((r) => ({
1963
+ ...r,
1964
+ }));
1965
+ }
1966
+ async createWebhookDelivery(data, options) {
1967
+ const client = this.getDbClient(options);
1968
+ await client.insert(this.systemTables.webhookDelivery).values({
1969
+ webhookId: data.webhookId,
1970
+ collectionSlug: data.collectionSlug,
1971
+ event: data.event,
1972
+ url: data.url,
1973
+ payload: data.payload,
1974
+ success: data.success,
1975
+ responseStatus: data.responseStatus,
1976
+ });
1977
+ }
1978
+ async getWebhookDeliveries(webhookId, limit = 50) {
1979
+ const client = this.getDbClient();
1980
+ const table = this.systemTables.webhookDelivery;
1981
+ const docs = await client
1982
+ .select()
1983
+ .from(table)
1984
+ .where(eq(table.webhookId, webhookId))
1985
+ .orderBy(desc(table.timestamp))
1986
+ .limit(limit);
1987
+ return docs.map((d) => ({
1988
+ id: d.id,
1989
+ webhookId: d.webhookId,
1990
+ collectionSlug: d.collectionSlug,
1991
+ event: d.event,
1992
+ url: d.url,
1993
+ payload: d.payload,
1994
+ success: d.success,
1995
+ responseStatus: d.responseStatus,
1996
+ timestamp: d.timestamp,
1997
+ }));
1998
+ }
1999
+ async search(collection, query, fields, limit = 10, options) {
2000
+ const table = this.getTable(collection);
2001
+ const client = this.getDbClient(options);
2002
+ const conditions = fields
2003
+ .filter((f) => table[f] !== undefined)
2004
+ .map((f) => sql `${table[f]} ILIKE ${'%' + query + '%'}`);
2005
+ if (conditions.length === 0)
2006
+ return [];
2007
+ const orWhere = conditions.reduce((acc, cond, i) => i === 0 ? cond : sql `${acc} OR ${cond}`);
2008
+ let whereClause = sql `(${orWhere})`;
2009
+ const siteId = options?.siteId;
2010
+ if (siteId && table.siteId !== undefined) {
2011
+ whereClause = sql `${whereClause} AND ${table.siteId} = ${siteId}`;
2012
+ }
2013
+ const result = await client
2014
+ .select()
2015
+ .from(table)
2016
+ .where(whereClause)
2017
+ .limit(Math.min(limit, 50));
2018
+ const mapped = result.map((r) => {
2019
+ const mappedRecord = { ...r, _id: r.id };
2020
+ if ('status' in mappedRecord) {
2021
+ mappedRecord._status = mappedRecord.status;
2022
+ }
2023
+ return mappedRecord;
2024
+ });
2025
+ return mapped;
2026
+ }
2027
+ }
2028
+ //# sourceMappingURL=PostgresDrizzleAdapter.js.map