@zenith-open/zenithcms-db-postgres 0.1.0 → 1.0.0-beta.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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/dist/schema.d.ts +1 -0
- package/dist/schema.js +2 -0
- package/dist/schema.js.map +1 -0
- 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,71 +1,11 @@
|
|
|
1
|
-
import
|
|
2
|
-
import Redis from 'ioredis';
|
|
1
|
+
import { createCacheLayer } from '@zenith-open/zenithcms-db-common';
|
|
3
2
|
import pino from 'pino';
|
|
4
|
-
export class LocalCacheLayer {
|
|
5
|
-
cache;
|
|
6
|
-
constructor() {
|
|
7
|
-
this.cache = new NodeCache({ stdTTL: 60, checkperiod: 120 });
|
|
8
|
-
}
|
|
9
|
-
async get(key) {
|
|
10
|
-
return this.cache.get(key);
|
|
11
|
-
}
|
|
12
|
-
async set(key, value, collection) {
|
|
13
|
-
this.cache.set(key, value);
|
|
14
|
-
}
|
|
15
|
-
async invalidate(collection) {
|
|
16
|
-
const keys = this.cache.keys();
|
|
17
|
-
const targets = keys.filter((k) => k.startsWith(`${collection}:`));
|
|
18
|
-
this.cache.del(targets);
|
|
19
|
-
}
|
|
20
|
-
}
|
|
21
|
-
export class RedisCacheLayer {
|
|
22
|
-
redis;
|
|
23
|
-
constructor(redisUrl) {
|
|
24
|
-
this.redis = new Redis(redisUrl, {
|
|
25
|
-
maxRetriesPerRequest: 3,
|
|
26
|
-
});
|
|
27
|
-
logger.info('PostgresDrizzleAdapter: Redis_Cache_Layer Initialized');
|
|
28
|
-
}
|
|
29
|
-
async get(key) {
|
|
30
|
-
try {
|
|
31
|
-
const data = await this.redis.get(key);
|
|
32
|
-
return data ? JSON.parse(data) : undefined;
|
|
33
|
-
}
|
|
34
|
-
catch (error) {
|
|
35
|
-
logger.warn({ error: error.message }, 'RedisCacheLayer: Get failed');
|
|
36
|
-
return undefined;
|
|
37
|
-
}
|
|
38
|
-
}
|
|
39
|
-
async set(key, value, collection) {
|
|
40
|
-
try {
|
|
41
|
-
const setKey = `zenith:cache:collection:${collection}`;
|
|
42
|
-
await this.redis.setex(key, 60, JSON.stringify(value));
|
|
43
|
-
await this.redis.sadd(setKey, key);
|
|
44
|
-
await this.redis.expire(setKey, 120);
|
|
45
|
-
}
|
|
46
|
-
catch (error) {
|
|
47
|
-
logger.warn({ error: error.message }, 'RedisCacheLayer: Set failed');
|
|
48
|
-
}
|
|
49
|
-
}
|
|
50
|
-
async invalidate(collection) {
|
|
51
|
-
try {
|
|
52
|
-
const setKey = `zenith:cache:collection:${collection}`;
|
|
53
|
-
const keys = await this.redis.smembers(setKey);
|
|
54
|
-
if (keys.length > 0) {
|
|
55
|
-
await this.redis.del(...keys);
|
|
56
|
-
}
|
|
57
|
-
await this.redis.del(setKey);
|
|
58
|
-
}
|
|
59
|
-
catch (error) {
|
|
60
|
-
logger.warn({ error: error.message }, 'RedisCacheLayer: Invalidate failed');
|
|
61
|
-
}
|
|
62
|
-
}
|
|
63
|
-
}
|
|
64
3
|
// Import Drizzle ORM and Postgres
|
|
65
4
|
import { drizzle } from 'drizzle-orm/node-postgres';
|
|
66
5
|
import { Pool } from 'pg';
|
|
67
|
-
import { sql, eq, and, desc, or } from 'drizzle-orm';
|
|
6
|
+
import { sql, eq, and, desc, or, inArray } from 'drizzle-orm';
|
|
68
7
|
import { QueryASTParser } from './query-ast';
|
|
8
|
+
import { getTableConfig } from 'drizzle-orm/pg-core';
|
|
69
9
|
import { pgTable, text, timestamp, jsonb, uuid, integer, boolean, bigint, } from 'drizzle-orm/pg-core';
|
|
70
10
|
const logger = pino();
|
|
71
11
|
/**
|
|
@@ -128,17 +68,6 @@ export class PostgresDrizzleAdapter {
|
|
|
128
68
|
createdAt: timestamp('created_at').defaultNow().notNull(),
|
|
129
69
|
updatedAt: timestamp('updated_at').defaultNow().notNull(),
|
|
130
70
|
}),
|
|
131
|
-
users: pgTable('users', {
|
|
132
|
-
id: uuid('id').defaultRandom().primaryKey(),
|
|
133
|
-
email: text('email').unique().notNull(),
|
|
134
|
-
password: text('password').notNull(),
|
|
135
|
-
role: text('role').notNull(),
|
|
136
|
-
failedLoginAttempts: integer('failed_login_attempts').default(0).notNull(),
|
|
137
|
-
lockUntil: timestamp('lock_until'),
|
|
138
|
-
emailVerified: boolean('email_verified').default(false).notNull(),
|
|
139
|
-
verificationToken: text('verification_token'),
|
|
140
|
-
verificationTokenExpiry: timestamp('verification_token_expiry'),
|
|
141
|
-
}),
|
|
142
71
|
passwordResets: pgTable('z_password_resets', {
|
|
143
72
|
id: uuid('id').defaultRandom().primaryKey(),
|
|
144
73
|
userId: text('user_id').notNull(),
|
|
@@ -146,16 +75,6 @@ export class PostgresDrizzleAdapter {
|
|
|
146
75
|
expiresAt: timestamp('expires_at').notNull(),
|
|
147
76
|
used: boolean('used').default(false).notNull(),
|
|
148
77
|
}),
|
|
149
|
-
apiKeys: pgTable('z_api_keys', {
|
|
150
|
-
id: uuid('id').defaultRandom().primaryKey(),
|
|
151
|
-
name: text('name').notNull(),
|
|
152
|
-
key: text('key').notNull(),
|
|
153
|
-
role: text('role').notNull(),
|
|
154
|
-
expiresAt: timestamp('expires_at'),
|
|
155
|
-
revoked: boolean('revoked').default(false).notNull(),
|
|
156
|
-
lastUsed: timestamp('last_used'),
|
|
157
|
-
allowedCollections: jsonb('allowed_collections'),
|
|
158
|
-
}),
|
|
159
78
|
migrations: pgTable('z_migrations', {
|
|
160
79
|
id: uuid('id').defaultRandom().primaryKey(),
|
|
161
80
|
name: text('name').unique().notNull(),
|
|
@@ -210,7 +129,7 @@ export class PostgresDrizzleAdapter {
|
|
|
210
129
|
id: uuid('id').defaultRandom().primaryKey(),
|
|
211
130
|
name: text('name').notNull(),
|
|
212
131
|
slug: text('slug').unique().notNull(),
|
|
213
|
-
icon: text('icon').default('
|
|
132
|
+
icon: text('icon').default(''),
|
|
214
133
|
description: text('description'),
|
|
215
134
|
ownerId: text('owner_id').notNull(),
|
|
216
135
|
workspaceId: text('workspace_id'),
|
|
@@ -265,16 +184,6 @@ export class PostgresDrizzleAdapter {
|
|
|
265
184
|
createdBy: text('created_by'),
|
|
266
185
|
createdAt: timestamp('created_at').defaultNow().notNull(),
|
|
267
186
|
}),
|
|
268
|
-
roles: pgTable('z_roles', {
|
|
269
|
-
id: uuid('id').defaultRandom().primaryKey(),
|
|
270
|
-
roleName: text('role_name').notNull().unique(),
|
|
271
|
-
roleType: text('role_type').notNull().default('custom'),
|
|
272
|
-
description: text('description').default(''),
|
|
273
|
-
isSystem: boolean('is_system').default(false).notNull(),
|
|
274
|
-
permissions: jsonb('permissions').default([]).notNull(),
|
|
275
|
-
createdAt: timestamp('created_at').defaultNow().notNull(),
|
|
276
|
-
updatedAt: timestamp('updated_at').defaultNow().notNull(),
|
|
277
|
-
}),
|
|
278
187
|
releases: pgTable('z_releases', {
|
|
279
188
|
id: uuid('id').defaultRandom().primaryKey(),
|
|
280
189
|
name: text('name').notNull(),
|
|
@@ -307,14 +216,7 @@ export class PostgresDrizzleAdapter {
|
|
|
307
216
|
};
|
|
308
217
|
constructor(connectionString) {
|
|
309
218
|
this.connectionString = connectionString;
|
|
310
|
-
|
|
311
|
-
if (redisUrl) {
|
|
312
|
-
this.cache = new RedisCacheLayer(redisUrl);
|
|
313
|
-
}
|
|
314
|
-
else {
|
|
315
|
-
this.cache = new LocalCacheLayer();
|
|
316
|
-
logger.warn('PostgresDrizzleAdapter: Local_Cache_Layer Initialized (Warning: Cache desync risk under horizontal scaling)');
|
|
317
|
-
}
|
|
219
|
+
this.cache = createCacheLayer('PostgresDrizzleAdapter');
|
|
318
220
|
logger.info('PostgresDrizzleAdapter: Zod Parser Cache pre-allocated for speed.');
|
|
319
221
|
// Configure connection pooling (configurable via env)
|
|
320
222
|
const poolMax = parseInt(process.env.POSTGRES_POOL_MAX || '20', 10);
|
|
@@ -369,6 +271,17 @@ export class PostgresDrizzleAdapter {
|
|
|
369
271
|
this.tenantPools[tenantId] = { pool, db };
|
|
370
272
|
await this._ensureSystemTables(db);
|
|
371
273
|
}
|
|
274
|
+
getNativeClient() {
|
|
275
|
+
return this.db;
|
|
276
|
+
}
|
|
277
|
+
async executeRaw(query, params) {
|
|
278
|
+
if (params) {
|
|
279
|
+
// Very naive parameter binding for raw execute escape hatch,
|
|
280
|
+
// primarily used for simple schema queries in migrations.
|
|
281
|
+
return await this.db.execute(sql.raw(query));
|
|
282
|
+
}
|
|
283
|
+
return await this.db.execute(sql.raw(query));
|
|
284
|
+
}
|
|
372
285
|
getDbClient(options) {
|
|
373
286
|
const tenantId = options?.tenantId || options?.siteId;
|
|
374
287
|
if (tenantId && this.tenantPools[tenantId]) {
|
|
@@ -422,406 +335,63 @@ export class PostgresDrizzleAdapter {
|
|
|
422
335
|
logger.warn({ err: err.message }, 'PostgresDrizzleAdapter: System tables advisory lock acquisition failed/timed out. Proceeding without lock.');
|
|
423
336
|
}
|
|
424
337
|
try {
|
|
425
|
-
const
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
collection_name TEXT NOT NULL,
|
|
466
|
-
collection_slug TEXT NOT NULL,
|
|
467
|
-
document_id TEXT NOT NULL,
|
|
468
|
-
snapshot JSONB NOT NULL,
|
|
469
|
-
delta JSONB,
|
|
470
|
-
created_by TEXT NOT NULL
|
|
471
|
-
);
|
|
472
|
-
CREATE INDEX IF NOT EXISTS idx_versions_doc ON versions(document_id);
|
|
473
|
-
`;
|
|
474
|
-
const createFlowsTable = sql `
|
|
475
|
-
CREATE TABLE IF NOT EXISTS flows (
|
|
476
|
-
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
477
|
-
name TEXT NOT NULL,
|
|
478
|
-
description TEXT,
|
|
479
|
-
active BOOLEAN DEFAULT false NOT NULL,
|
|
480
|
-
trigger JSONB NOT NULL DEFAULT '{}'::jsonb,
|
|
481
|
-
steps JSONB NOT NULL DEFAULT '[]'::jsonb,
|
|
482
|
-
created_at TIMESTAMP DEFAULT NOW() NOT NULL,
|
|
483
|
-
updated_at TIMESTAMP DEFAULT NOW() NOT NULL
|
|
484
|
-
);
|
|
485
|
-
CREATE INDEX IF NOT EXISTS idx_flows_active ON flows(active);
|
|
486
|
-
`;
|
|
487
|
-
const createUsersTable = sql `
|
|
488
|
-
CREATE TABLE IF NOT EXISTS users (
|
|
489
|
-
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
490
|
-
email TEXT UNIQUE NOT NULL,
|
|
491
|
-
password TEXT NOT NULL,
|
|
492
|
-
role TEXT NOT NULL,
|
|
493
|
-
failed_login_attempts INTEGER DEFAULT 0 NOT NULL,
|
|
494
|
-
lock_until TIMESTAMP,
|
|
495
|
-
email_verified BOOLEAN DEFAULT false NOT NULL,
|
|
496
|
-
verification_token TEXT,
|
|
497
|
-
verification_token_expiry TIMESTAMP,
|
|
498
|
-
two_factor_secret TEXT,
|
|
499
|
-
two_factor_enabled BOOLEAN DEFAULT false NOT NULL
|
|
500
|
-
);
|
|
501
|
-
CREATE INDEX IF NOT EXISTS idx_users_email ON users(email);
|
|
502
|
-
`;
|
|
503
|
-
const createPasswordResetsTable = sql `
|
|
504
|
-
CREATE TABLE IF NOT EXISTS z_password_resets (
|
|
505
|
-
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
506
|
-
user_id TEXT NOT NULL,
|
|
507
|
-
token TEXT NOT NULL,
|
|
508
|
-
expires_at TIMESTAMP NOT NULL,
|
|
509
|
-
used BOOLEAN DEFAULT false NOT NULL
|
|
510
|
-
);
|
|
511
|
-
CREATE INDEX IF NOT EXISTS idx_resets_token ON z_password_resets(token);
|
|
512
|
-
`;
|
|
513
|
-
const createApiKeysTable = sql `
|
|
514
|
-
CREATE TABLE IF NOT EXISTS z_api_keys (
|
|
515
|
-
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
516
|
-
name TEXT NOT NULL,
|
|
517
|
-
key TEXT NOT NULL,
|
|
518
|
-
role TEXT NOT NULL,
|
|
519
|
-
expires_at TIMESTAMP,
|
|
520
|
-
revoked BOOLEAN DEFAULT false NOT NULL,
|
|
521
|
-
last_used TIMESTAMP,
|
|
522
|
-
allowed_collections JSONB DEFAULT '[]'::jsonb
|
|
523
|
-
);
|
|
524
|
-
CREATE INDEX IF NOT EXISTS idx_api_keys_hash ON z_api_keys(key);
|
|
525
|
-
`;
|
|
526
|
-
const createMigrationsTable = sql `
|
|
527
|
-
CREATE TABLE IF NOT EXISTS z_migrations (
|
|
528
|
-
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
529
|
-
name TEXT UNIQUE NOT NULL,
|
|
530
|
-
batch INTEGER NOT NULL,
|
|
531
|
-
executed_at TIMESTAMP DEFAULT NOW() NOT NULL
|
|
532
|
-
);
|
|
533
|
-
CREATE INDEX IF NOT EXISTS idx_migrations_name ON z_migrations(name);
|
|
534
|
-
`;
|
|
535
|
-
const createWebhookDeliveriesTable = sql `
|
|
536
|
-
CREATE TABLE IF NOT EXISTS z_webhook_deliveries (
|
|
537
|
-
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
538
|
-
timestamp TIMESTAMP DEFAULT NOW() NOT NULL,
|
|
539
|
-
collection_slug TEXT,
|
|
540
|
-
event TEXT NOT NULL,
|
|
541
|
-
url TEXT NOT NULL,
|
|
542
|
-
payload JSONB,
|
|
543
|
-
success BOOLEAN NOT NULL,
|
|
544
|
-
response_status INTEGER
|
|
545
|
-
);
|
|
546
|
-
CREATE INDEX IF NOT EXISTS idx_webhook_deliveries_event ON z_webhook_deliveries(event);
|
|
547
|
-
`;
|
|
548
|
-
const createWebhookConfigsTable = sql `
|
|
549
|
-
CREATE TABLE IF NOT EXISTS z_webhook_configs (
|
|
550
|
-
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
551
|
-
url TEXT NOT NULL,
|
|
552
|
-
secret TEXT,
|
|
553
|
-
events JSONB NOT NULL DEFAULT '[]',
|
|
554
|
-
enabled BOOLEAN DEFAULT true NOT NULL,
|
|
555
|
-
created_at TIMESTAMP DEFAULT NOW() NOT NULL,
|
|
556
|
-
updated_at TIMESTAMP DEFAULT NOW() NOT NULL
|
|
557
|
-
);
|
|
558
|
-
CREATE INDEX IF NOT EXISTS idx_webhook_configs_url ON z_webhook_configs(url);
|
|
559
|
-
`;
|
|
560
|
-
const createSchemasTable = sql `
|
|
561
|
-
CREATE TABLE IF NOT EXISTS z_schemas (
|
|
562
|
-
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
563
|
-
slug TEXT UNIQUE NOT NULL,
|
|
564
|
-
singular TEXT NOT NULL,
|
|
565
|
-
plural TEXT NOT NULL,
|
|
566
|
-
fields JSONB NOT NULL DEFAULT '[]'::jsonb,
|
|
567
|
-
settings JSONB NOT NULL DEFAULT '{}'::jsonb,
|
|
568
|
-
created_at TIMESTAMP DEFAULT NOW() NOT NULL,
|
|
569
|
-
updated_at TIMESTAMP DEFAULT NOW() NOT NULL
|
|
570
|
-
);
|
|
571
|
-
CREATE INDEX IF NOT EXISTS idx_schemas_slug ON z_schemas(slug);
|
|
572
|
-
`;
|
|
573
|
-
const createCampaignsTable = sql `
|
|
574
|
-
CREATE TABLE IF NOT EXISTS z_campaigns (
|
|
575
|
-
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
576
|
-
subject TEXT NOT NULL,
|
|
577
|
-
body TEXT NOT NULL,
|
|
578
|
-
status TEXT DEFAULT 'draft' NOT NULL,
|
|
579
|
-
audience TEXT DEFAULT 'all' NOT NULL,
|
|
580
|
-
sent_at TIMESTAMP,
|
|
581
|
-
created_at TIMESTAMP DEFAULT NOW() NOT NULL,
|
|
582
|
-
updated_at TIMESTAMP DEFAULT NOW() NOT NULL
|
|
583
|
-
);
|
|
584
|
-
`;
|
|
585
|
-
const createPluginsTable = sql `
|
|
586
|
-
CREATE TABLE IF NOT EXISTS z_plugins (
|
|
587
|
-
id TEXT PRIMARY KEY,
|
|
588
|
-
name TEXT NOT NULL,
|
|
589
|
-
version TEXT DEFAULT '1.0.0',
|
|
590
|
-
description TEXT DEFAULT '',
|
|
591
|
-
author TEXT DEFAULT '',
|
|
592
|
-
homepage TEXT DEFAULT '',
|
|
593
|
-
package_name TEXT DEFAULT '',
|
|
594
|
-
config_schema JSONB DEFAULT '{}'::jsonb,
|
|
595
|
-
config JSONB DEFAULT '{}'::jsonb,
|
|
596
|
-
enabled BOOLEAN DEFAULT true NOT NULL,
|
|
597
|
-
installed_at TIMESTAMP DEFAULT NOW() NOT NULL,
|
|
598
|
-
updated_at TIMESTAMP DEFAULT NOW() NOT NULL
|
|
599
|
-
);
|
|
600
|
-
`;
|
|
601
|
-
const createSettingsTable = sql `
|
|
602
|
-
CREATE TABLE IF NOT EXISTS z_settings (
|
|
603
|
-
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
604
|
-
site_name TEXT DEFAULT 'Zenith CMS',
|
|
605
|
-
public_url TEXT, -- No default: must be set explicitly per deployment (PUBLIC_URL env var)
|
|
606
|
-
maintenance_mode BOOLEAN DEFAULT false,
|
|
607
|
-
enable_drafts BOOLEAN DEFAULT true,
|
|
608
|
-
default_locale TEXT DEFAULT 'en',
|
|
609
|
-
allowed_origins JSONB DEFAULT '["*"]'::jsonb,
|
|
610
|
-
jwt_expires_in TEXT DEFAULT '7d',
|
|
611
|
-
password_min_length INTEGER DEFAULT 8,
|
|
612
|
-
rate_limit_window INTEGER DEFAULT 15,
|
|
613
|
-
rate_limit_max INTEGER DEFAULT 100,
|
|
614
|
-
custom_css TEXT DEFAULT ''
|
|
615
|
-
);
|
|
616
|
-
`;
|
|
617
|
-
const createSitesTable = sql `
|
|
618
|
-
CREATE TABLE IF NOT EXISTS z_sites (
|
|
619
|
-
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
620
|
-
name TEXT NOT NULL,
|
|
621
|
-
slug TEXT UNIQUE NOT NULL,
|
|
622
|
-
icon TEXT DEFAULT '🌐',
|
|
623
|
-
description TEXT,
|
|
624
|
-
owner_id TEXT NOT NULL,
|
|
625
|
-
workspace_id TEXT,
|
|
626
|
-
members JSONB DEFAULT '[]'::jsonb,
|
|
627
|
-
collections JSONB DEFAULT '[]'::jsonb,
|
|
628
|
-
globals JSONB DEFAULT '[]'::jsonb,
|
|
629
|
-
billing_enabled BOOLEAN DEFAULT false,
|
|
630
|
-
stripe_public_key TEXT,
|
|
631
|
-
stripe_secret_key TEXT,
|
|
632
|
-
stripe_webhook_secret TEXT,
|
|
633
|
-
currency TEXT DEFAULT 'USD',
|
|
634
|
-
pricing_plans JSONB DEFAULT '[]'::jsonb,
|
|
635
|
-
created_at TIMESTAMP DEFAULT NOW() NOT NULL,
|
|
636
|
-
updated_at TIMESTAMP DEFAULT NOW() NOT NULL
|
|
637
|
-
);
|
|
638
|
-
CREATE INDEX IF NOT EXISTS idx_sites_slug ON z_sites(slug);
|
|
639
|
-
CREATE INDEX IF NOT EXISTS idx_sites_workspace ON z_sites(workspace_id);
|
|
640
|
-
`;
|
|
641
|
-
const createWorkspacesTable = sql `
|
|
642
|
-
CREATE TABLE IF NOT EXISTS z_workspaces (
|
|
643
|
-
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
644
|
-
name TEXT NOT NULL,
|
|
645
|
-
slug TEXT UNIQUE NOT NULL,
|
|
646
|
-
owner_id TEXT NOT NULL,
|
|
647
|
-
members JSONB DEFAULT '[]'::jsonb,
|
|
648
|
-
created_at TIMESTAMP DEFAULT NOW() NOT NULL,
|
|
649
|
-
updated_at TIMESTAMP DEFAULT NOW() NOT NULL
|
|
650
|
-
);
|
|
651
|
-
CREATE INDEX IF NOT EXISTS idx_workspaces_slug ON z_workspaces(slug);
|
|
652
|
-
`;
|
|
653
|
-
const migrateSitesWorkspaceId = sql `
|
|
654
|
-
ALTER TABLE z_sites ADD COLUMN IF NOT EXISTS workspace_id TEXT;
|
|
655
|
-
CREATE INDEX IF NOT EXISTS idx_sites_workspace ON z_sites(workspace_id);
|
|
656
|
-
`;
|
|
657
|
-
const createUserPreferencesTable = sql `
|
|
658
|
-
CREATE TABLE IF NOT EXISTS z_preferences (
|
|
659
|
-
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
660
|
-
user_id TEXT NOT NULL,
|
|
661
|
-
key TEXT NOT NULL,
|
|
662
|
-
value JSONB NOT NULL,
|
|
663
|
-
updated_at TIMESTAMP DEFAULT NOW() NOT NULL,
|
|
664
|
-
UNIQUE (user_id, key)
|
|
665
|
-
);
|
|
666
|
-
`;
|
|
667
|
-
const createMembersTable = sql `
|
|
668
|
-
CREATE TABLE IF NOT EXISTS z_members (
|
|
669
|
-
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
670
|
-
email TEXT UNIQUE NOT NULL,
|
|
671
|
-
password TEXT,
|
|
672
|
-
name TEXT,
|
|
673
|
-
avatar TEXT,
|
|
674
|
-
is_subscribed BOOLEAN DEFAULT false,
|
|
675
|
-
subscription_status TEXT DEFAULT 'none',
|
|
676
|
-
stripe_customer_id TEXT,
|
|
677
|
-
metadata JSONB DEFAULT '{}'::jsonb,
|
|
678
|
-
last_login TIMESTAMP,
|
|
679
|
-
created_at TIMESTAMP DEFAULT NOW() NOT NULL,
|
|
680
|
-
updated_at TIMESTAMP DEFAULT NOW() NOT NULL
|
|
681
|
-
);
|
|
682
|
-
CREATE INDEX IF NOT EXISTS idx_members_email ON z_members(email);
|
|
683
|
-
`;
|
|
684
|
-
const createDashboardLayoutsTable = sql `
|
|
685
|
-
CREATE TABLE IF NOT EXISTS z_dashboard_layouts (
|
|
686
|
-
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
687
|
-
user_id TEXT NOT NULL,
|
|
688
|
-
site_id TEXT,
|
|
689
|
-
widgets JSONB DEFAULT '[]'::jsonb,
|
|
690
|
-
columns INTEGER DEFAULT 12,
|
|
691
|
-
created_at TIMESTAMP DEFAULT NOW() NOT NULL,
|
|
692
|
-
updated_at TIMESTAMP DEFAULT NOW() NOT NULL,
|
|
693
|
-
UNIQUE (user_id, site_id)
|
|
694
|
-
);
|
|
695
|
-
`;
|
|
696
|
-
const createOnboardingStateTable = sql `
|
|
697
|
-
CREATE TABLE IF NOT EXISTS z_onboarding (
|
|
698
|
-
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
699
|
-
current_step INTEGER DEFAULT 0,
|
|
700
|
-
total_steps INTEGER DEFAULT 7,
|
|
701
|
-
completed_at TIMESTAMP,
|
|
702
|
-
skipped BOOLEAN DEFAULT false,
|
|
703
|
-
answers JSONB DEFAULT '{}'::jsonb,
|
|
704
|
-
created_at TIMESTAMP DEFAULT NOW() NOT NULL,
|
|
705
|
-
updated_at TIMESTAMP DEFAULT NOW() NOT NULL
|
|
706
|
-
);
|
|
707
|
-
`;
|
|
708
|
-
const createCollectionsTable = sql `
|
|
709
|
-
CREATE TABLE IF NOT EXISTS z_collections (
|
|
710
|
-
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
711
|
-
name TEXT NOT NULL,
|
|
712
|
-
slug TEXT UNIQUE NOT NULL,
|
|
713
|
-
labels JSONB,
|
|
714
|
-
drafts BOOLEAN DEFAULT false NOT NULL,
|
|
715
|
-
timestamps BOOLEAN DEFAULT true NOT NULL,
|
|
716
|
-
fields JSONB NOT NULL,
|
|
717
|
-
created_at TIMESTAMP DEFAULT NOW() NOT NULL,
|
|
718
|
-
updated_at TIMESTAMP DEFAULT NOW() NOT NULL
|
|
719
|
-
);
|
|
720
|
-
CREATE INDEX IF NOT EXISTS idx_collections_slug ON z_collections(slug);
|
|
721
|
-
`;
|
|
722
|
-
const createRedirectsTable = sql `
|
|
723
|
-
CREATE TABLE IF NOT EXISTS z_redirects (
|
|
724
|
-
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
725
|
-
from_path TEXT NOT NULL,
|
|
726
|
-
to_path TEXT NOT NULL,
|
|
727
|
-
redirect_type TEXT DEFAULT '301' NOT NULL,
|
|
728
|
-
site_id TEXT,
|
|
729
|
-
hits INTEGER DEFAULT 0 NOT NULL,
|
|
730
|
-
last_hit_at TIMESTAMP,
|
|
731
|
-
created_by TEXT,
|
|
732
|
-
created_at TIMESTAMP DEFAULT NOW() NOT NULL
|
|
733
|
-
);
|
|
734
|
-
CREATE INDEX IF NOT EXISTS idx_redirects_from ON z_redirects(from_path);
|
|
735
|
-
`;
|
|
736
|
-
const createRolesTable = sql `
|
|
737
|
-
CREATE TABLE IF NOT EXISTS z_roles (
|
|
738
|
-
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
739
|
-
role_name TEXT NOT NULL UNIQUE,
|
|
740
|
-
role_type TEXT NOT NULL DEFAULT 'custom',
|
|
741
|
-
description TEXT DEFAULT '',
|
|
742
|
-
is_system BOOLEAN DEFAULT false NOT NULL,
|
|
743
|
-
permissions JSONB DEFAULT '[]'::jsonb NOT NULL,
|
|
744
|
-
created_at TIMESTAMP DEFAULT NOW() NOT NULL,
|
|
745
|
-
updated_at TIMESTAMP DEFAULT NOW() NOT NULL
|
|
746
|
-
);
|
|
747
|
-
CREATE INDEX IF NOT EXISTS idx_roles_type ON z_roles(role_type);
|
|
748
|
-
`;
|
|
749
|
-
const createReleasesTable = sql `
|
|
750
|
-
CREATE TABLE IF NOT EXISTS z_releases (
|
|
751
|
-
id UUID PRIMARY KEY,
|
|
752
|
-
name TEXT NOT NULL,
|
|
753
|
-
description TEXT DEFAULT '',
|
|
754
|
-
documents JSONB DEFAULT '[]'::jsonb NOT NULL,
|
|
755
|
-
status TEXT NOT NULL DEFAULT 'pending',
|
|
756
|
-
scheduled_at TIMESTAMP,
|
|
757
|
-
published_at TIMESTAMP,
|
|
758
|
-
published_by TEXT,
|
|
759
|
-
failure_reason TEXT,
|
|
760
|
-
site_id TEXT,
|
|
761
|
-
created_by TEXT,
|
|
762
|
-
created_at TIMESTAMP DEFAULT NOW() NOT NULL,
|
|
763
|
-
updated_at TIMESTAMP DEFAULT NOW() NOT NULL
|
|
764
|
-
);
|
|
765
|
-
CREATE INDEX IF NOT EXISTS idx_releases_status ON z_releases(status);
|
|
766
|
-
CREATE INDEX IF NOT EXISTS idx_releases_scheduled ON z_releases(scheduled_at);
|
|
767
|
-
CREATE INDEX IF NOT EXISTS idx_releases_site ON z_releases(site_id);
|
|
768
|
-
`;
|
|
769
|
-
const createPresenceTable = sql `
|
|
770
|
-
CREATE TABLE IF NOT EXISTS z_presence (
|
|
771
|
-
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
772
|
-
user_id TEXT NOT NULL,
|
|
773
|
-
email TEXT NOT NULL,
|
|
774
|
-
collection_name TEXT NOT NULL,
|
|
775
|
-
document_id TEXT NOT NULL,
|
|
776
|
-
last_active BIGINT NOT NULL
|
|
777
|
-
);
|
|
778
|
-
`;
|
|
779
|
-
const createLocksTable = sql `
|
|
780
|
-
CREATE TABLE IF NOT EXISTS z_locks (
|
|
781
|
-
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
782
|
-
collection_name TEXT NOT NULL,
|
|
783
|
-
document_id TEXT NOT NULL,
|
|
784
|
-
site_id TEXT,
|
|
785
|
-
locked_by TEXT NOT NULL,
|
|
786
|
-
locked_by_email TEXT NOT NULL,
|
|
787
|
-
locked_at TIMESTAMP DEFAULT NOW() NOT NULL,
|
|
788
|
-
lock_expires_at TIMESTAMP NOT NULL
|
|
789
|
-
);
|
|
790
|
-
CREATE INDEX IF NOT EXISTS idx_locks_doc ON z_locks(collection_name, document_id);
|
|
791
|
-
`;
|
|
792
|
-
await db.execute(createAuditLogTable);
|
|
793
|
-
await db.execute(createVersionTable);
|
|
794
|
-
await db.execute(createFlowsTable);
|
|
795
|
-
await db.execute(createUsersTable);
|
|
796
|
-
try {
|
|
797
|
-
await db.execute(sql `ALTER TABLE users ADD COLUMN IF NOT EXISTS two_factor_secret TEXT`);
|
|
798
|
-
await db.execute(sql `ALTER TABLE users ADD COLUMN IF NOT EXISTS two_factor_enabled BOOLEAN DEFAULT false NOT NULL`);
|
|
799
|
-
}
|
|
800
|
-
catch (err) {
|
|
801
|
-
logger.warn({ err }, 'Failed to add 2FA columns to users table');
|
|
338
|
+
for (const table of Object.values(this.systemTables)) {
|
|
339
|
+
const config = getTableConfig(table);
|
|
340
|
+
const colDefs = config.columns.map((col) => {
|
|
341
|
+
let type = 'TEXT';
|
|
342
|
+
if (col.columnType.includes('UUID'))
|
|
343
|
+
type = 'UUID';
|
|
344
|
+
else if (col.columnType.includes('Timestamp'))
|
|
345
|
+
type = 'TIMESTAMP';
|
|
346
|
+
else if (col.columnType.includes('Text'))
|
|
347
|
+
type = 'TEXT';
|
|
348
|
+
else if (col.columnType.includes('Jsonb'))
|
|
349
|
+
type = 'JSONB';
|
|
350
|
+
else if (col.columnType.includes('Boolean'))
|
|
351
|
+
type = 'BOOLEAN';
|
|
352
|
+
else if (col.columnType.includes('Integer'))
|
|
353
|
+
type = 'INTEGER';
|
|
354
|
+
else if (col.columnType.includes('BigInt'))
|
|
355
|
+
type = 'BIGINT';
|
|
356
|
+
let def = `${col.name} ${type}`;
|
|
357
|
+
if (col.primary)
|
|
358
|
+
def += ' PRIMARY KEY';
|
|
359
|
+
if (col.default !== undefined) {
|
|
360
|
+
if (typeof col.default === 'string')
|
|
361
|
+
def += ` DEFAULT '${col.default}'`;
|
|
362
|
+
else
|
|
363
|
+
def += ` DEFAULT ${col.default}`;
|
|
364
|
+
}
|
|
365
|
+
else if (col.defaultFn || col.hasDefault) {
|
|
366
|
+
if (col.name === 'id' && type === 'UUID')
|
|
367
|
+
def += ' DEFAULT gen_random_uuid()';
|
|
368
|
+
else if (['timestamp', 'created_at', 'updated_at', 'executed_at'].includes(col.name))
|
|
369
|
+
def += ' DEFAULT NOW()';
|
|
370
|
+
}
|
|
371
|
+
if (col.notNull)
|
|
372
|
+
def += ' NOT NULL';
|
|
373
|
+
if (col.isUnique)
|
|
374
|
+
def += ' UNIQUE';
|
|
375
|
+
return def;
|
|
376
|
+
});
|
|
377
|
+
await db.execute(sql.raw(`CREATE TABLE IF NOT EXISTS ${config.name} (\n ${colDefs.join(',\n ')}\n);`));
|
|
802
378
|
}
|
|
803
|
-
|
|
804
|
-
await db.execute(
|
|
805
|
-
await db.execute(
|
|
806
|
-
await db.execute(
|
|
807
|
-
await db.execute(
|
|
808
|
-
await db.execute(
|
|
809
|
-
await db.execute(
|
|
810
|
-
await db.execute(
|
|
811
|
-
await db.execute(
|
|
812
|
-
await db.execute(
|
|
813
|
-
await db.execute(
|
|
814
|
-
await db.execute(
|
|
815
|
-
await db.execute(
|
|
816
|
-
await db.execute(
|
|
817
|
-
await db.execute(
|
|
818
|
-
await db.execute(
|
|
819
|
-
await db.execute(createCollectionsTable);
|
|
820
|
-
await db.execute(createRedirectsTable);
|
|
821
|
-
await db.execute(createRolesTable);
|
|
822
|
-
await db.execute(createReleasesTable);
|
|
823
|
-
await db.execute(createPresenceTable);
|
|
824
|
-
await db.execute(createLocksTable);
|
|
379
|
+
// Special handling for RLS on audit logs
|
|
380
|
+
await db.execute(sql.raw(`CREATE INDEX IF NOT EXISTS idx_audit_collection ON audit_logs(collection_name);`));
|
|
381
|
+
await db.execute(sql.raw(`CREATE INDEX IF NOT EXISTS idx_audit_timestamp ON audit_logs(timestamp);`));
|
|
382
|
+
await db.execute(sql.raw(`CREATE INDEX IF NOT EXISTS idx_audit_site ON audit_logs(site_id);`));
|
|
383
|
+
await db.execute(sql.raw(`CREATE INDEX IF NOT EXISTS idx_audit_action ON audit_logs(action);`));
|
|
384
|
+
await db.execute(sql.raw(`ALTER TABLE audit_logs ENABLE ROW LEVEL SECURITY;`));
|
|
385
|
+
await db.execute(sql.raw(`DROP POLICY IF EXISTS tenant_isolation_policy ON audit_logs;`));
|
|
386
|
+
await db.execute(sql.raw(`CREATE POLICY tenant_isolation_policy ON audit_logs FOR ALL USING (site_id = current_setting('app.site_id', true) OR current_setting('app.site_id', true) = '' OR current_setting('app.site_id', true) IS NULL OR site_id IS NULL);`));
|
|
387
|
+
await db.execute(sql.raw(`CREATE INDEX IF NOT EXISTS idx_versions_doc ON versions(document_id);`));
|
|
388
|
+
await db.execute(sql.raw(`CREATE INDEX IF NOT EXISTS idx_flows_active ON flows(active);`));
|
|
389
|
+
await db.execute(sql.raw(`CREATE INDEX IF NOT EXISTS idx_resets_token ON z_password_resets(token);`));
|
|
390
|
+
await db.execute(sql.raw(`CREATE INDEX IF NOT EXISTS idx_migrations_name ON z_migrations(name);`));
|
|
391
|
+
await db.execute(sql.raw(`CREATE INDEX IF NOT EXISTS idx_webhook_deliveries_event ON z_webhook_deliveries(event);`));
|
|
392
|
+
await db.execute(sql.raw(`CREATE INDEX IF NOT EXISTS idx_webhook_configs_url ON z_webhook_configs(url);`));
|
|
393
|
+
await db.execute(sql.raw(`CREATE INDEX IF NOT EXISTS idx_schemas_slug ON z_schemas(slug);`));
|
|
394
|
+
await db.execute(sql.raw(`CREATE INDEX IF NOT EXISTS idx_campaigns_status ON z_campaigns(status);`));
|
|
825
395
|
}
|
|
826
396
|
finally {
|
|
827
397
|
if (acquired) {
|
|
@@ -954,10 +524,14 @@ export class PostgresDrizzleAdapter {
|
|
|
954
524
|
}
|
|
955
525
|
this.tables[config.slug] = pgTable(config.slug, columns);
|
|
956
526
|
if (process.env.DISABLE_AUTO_MIGRATIONS !== 'true') {
|
|
957
|
-
|
|
527
|
+
if (process.env.ZENITH_AUTO_MIGRATE !== 'false') {
|
|
528
|
+
await this._runAutoMigrations(config, db);
|
|
529
|
+
}
|
|
958
530
|
for (const tenant of Object.values(this.tenantPools)) {
|
|
959
531
|
try {
|
|
960
|
-
|
|
532
|
+
if (process.env.ZENITH_AUTO_MIGRATE !== 'false') {
|
|
533
|
+
await this._runAutoMigrations(config, tenant.db);
|
|
534
|
+
}
|
|
961
535
|
}
|
|
962
536
|
catch (err) {
|
|
963
537
|
logger.error({ err: err.message }, `PostgresDrizzleAdapter: Tenant migration failed for ${config.slug}`);
|
|
@@ -1157,12 +731,8 @@ export class PostgresDrizzleAdapter {
|
|
|
1157
731
|
getTable(collection) {
|
|
1158
732
|
if (collection === 'flows')
|
|
1159
733
|
return this.systemTables.flows;
|
|
1160
|
-
if (collection === 'users')
|
|
1161
|
-
return this.systemTables.users;
|
|
1162
734
|
if (collection === 'z_password_resets')
|
|
1163
735
|
return this.systemTables.passwordResets;
|
|
1164
|
-
if (collection === 'z_api_keys')
|
|
1165
|
-
return this.systemTables.apiKeys;
|
|
1166
736
|
if (collection === 'z_migrations')
|
|
1167
737
|
return this.systemTables.migrations;
|
|
1168
738
|
if (collection === 'z_settings')
|
|
@@ -1187,8 +757,6 @@ export class PostgresDrizzleAdapter {
|
|
|
1187
757
|
return this.systemTables.plugins;
|
|
1188
758
|
if (collection === 'z_redirects')
|
|
1189
759
|
return this.systemTables.redirects;
|
|
1190
|
-
if (collection === 'z_roles' || collection === 'roles')
|
|
1191
|
-
return this.systemTables.roles;
|
|
1192
760
|
if (collection === 'z_releases' || collection === 'releases')
|
|
1193
761
|
return this.systemTables.releases;
|
|
1194
762
|
const table = this.tables[collection];
|
|
@@ -1344,6 +912,36 @@ export class PostgresDrizzleAdapter {
|
|
|
1344
912
|
const populated = await this._populateRelations(collection, loaded, options, [collection]);
|
|
1345
913
|
return populated[0];
|
|
1346
914
|
}
|
|
915
|
+
async findMany(collection, ids, options = {}) {
|
|
916
|
+
if (!ids || ids.length === 0)
|
|
917
|
+
return [];
|
|
918
|
+
const cacheKey = this._getCacheKey(collection, { id: { $in: ids } }, options);
|
|
919
|
+
const cached = await this.cache.get(cacheKey);
|
|
920
|
+
if (cached)
|
|
921
|
+
return cached;
|
|
922
|
+
const table = this.getTable(collection);
|
|
923
|
+
const client = this.getDbClient(options);
|
|
924
|
+
// In Postgres/Drizzle, 'inArray' needs the table.id column
|
|
925
|
+
// The exact column depends on the dynamic table creation, it should be table.id
|
|
926
|
+
const siteId = options?.siteId || options?.tenantId;
|
|
927
|
+
const dbQuery = this._selectWithColumns(client, table, collection, options);
|
|
928
|
+
let where = inArray(table.id, ids);
|
|
929
|
+
if (siteId && table.siteId) {
|
|
930
|
+
where = and(where, eq(table.siteId, siteId));
|
|
931
|
+
}
|
|
932
|
+
const result = await dbQuery.where(where).limit(Math.min(ids.length, 1000));
|
|
933
|
+
const mapped = result.map((r) => {
|
|
934
|
+
const mappedRecord = { ...r, _id: r.id };
|
|
935
|
+
if ('status' in mappedRecord) {
|
|
936
|
+
mappedRecord._status = mappedRecord.status;
|
|
937
|
+
}
|
|
938
|
+
return mappedRecord;
|
|
939
|
+
});
|
|
940
|
+
const loaded = await this._loadJunctionIds(collection, mapped);
|
|
941
|
+
const populated = await this._populateRelations(collection, loaded, options, [collection]);
|
|
942
|
+
await this.cache.set(cacheKey, populated, collection);
|
|
943
|
+
return populated;
|
|
944
|
+
}
|
|
1347
945
|
/**
|
|
1348
946
|
* Builds a Drizzle select query, optionally scoped to a subset of columns.
|
|
1349
947
|
* When options.select is populated, only those columns are fetched (plus
|
|
@@ -1367,22 +965,22 @@ export class PostgresDrizzleAdapter {
|
|
|
1367
965
|
}
|
|
1368
966
|
// Always include meta columns for the result mapper
|
|
1369
967
|
const requested = Array.isArray(options.select) ? options.select : [];
|
|
1370
|
-
const
|
|
968
|
+
const selectObject = requested
|
|
1371
969
|
.filter((col) => safeCols.has(col))
|
|
1372
|
-
.
|
|
970
|
+
.reduce((acc, col) => {
|
|
1373
971
|
const mapped = col === 'id' ? table.id
|
|
1374
972
|
: col === 'created_at' ? table.createdAt
|
|
1375
973
|
: col === 'updated_at' ? table.updatedAt
|
|
1376
974
|
: col === 'status' ? table.status
|
|
1377
975
|
: table[col];
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
976
|
+
if (mapped)
|
|
977
|
+
acc[col] = mapped;
|
|
978
|
+
return acc;
|
|
979
|
+
}, {});
|
|
980
|
+
if (Object.keys(selectObject).length === 0) {
|
|
1382
981
|
return client.select().from(table).$dynamic();
|
|
1383
982
|
}
|
|
1384
|
-
|
|
1385
|
-
return client.select(...toSelect).from(table).$dynamic();
|
|
983
|
+
return client.select(selectObject).from(table).$dynamic();
|
|
1386
984
|
}
|
|
1387
985
|
/** Maximum depth for nested relation population to prevent query explosion */
|
|
1388
986
|
static MAX_POPULATE_DEPTH = 5;
|
|
@@ -1881,7 +1479,7 @@ export class PostgresDrizzleAdapter {
|
|
|
1881
1479
|
const rows = await selectWhere;
|
|
1882
1480
|
ids = rows.map((r) => r.id);
|
|
1883
1481
|
}
|
|
1884
|
-
catch
|
|
1482
|
+
catch {
|
|
1885
1483
|
// Ignore select failure
|
|
1886
1484
|
}
|
|
1887
1485
|
if (ids.length > 0) {
|
|
@@ -1913,7 +1511,7 @@ export class PostgresDrizzleAdapter {
|
|
|
1913
1511
|
const result = await dbQuery;
|
|
1914
1512
|
return Number(result[0]?.count || 0);
|
|
1915
1513
|
}
|
|
1916
|
-
async aggregate(
|
|
1514
|
+
async aggregate(_collection, _pipeline, _options) {
|
|
1917
1515
|
throw new Error('Aggregation pipelines not natively supported in Postgres. Use native SQL.');
|
|
1918
1516
|
}
|
|
1919
1517
|
async transaction(fn) {
|