@zenith-open/zenithcms-db-postgres 0.1.0 → 1.0.0-beta.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +31 -0
- package/dist/PostgresDrizzleAdapter.d.ts +4 -20
- package/dist/PostgresDrizzleAdapter.js +118 -520
- package/dist/PostgresDrizzleAdapter.js.map +1 -1
- package/package.json +12 -9
- package/eslint.config.mjs +0 -26
- package/src/PostgresDrizzleAdapter.ts +0 -2239
- package/src/index.ts +0 -2
- package/src/query-ast.ts +0 -117
- package/tsconfig.eslint.json +0 -8
- package/tsconfig.json +0 -11
|
@@ -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
|
-
}
|