@zenith-open/zenithcms-db-postgres 0.1.0 → 1.0.0-beta.1

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