@bernierllc/database-adapter-postgresql 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +456 -0
- package/dist/PostgreSQLAdapter.d.ts +65 -0
- package/dist/PostgreSQLAdapter.js +252 -0
- package/dist/PostgreSQLTransaction.d.ts +46 -0
- package/dist/PostgreSQLTransaction.js +199 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.js +29 -0
- package/dist/migrations/001_initial_schema.d.ts +2 -0
- package/dist/migrations/001_initial_schema.js +91 -0
- package/dist/realtime/RealtimeManager.d.ts +76 -0
- package/dist/realtime/RealtimeManager.js +243 -0
- package/dist/rls/ClerkIntegration.d.ts +41 -0
- package/dist/rls/ClerkIntegration.js +161 -0
- package/dist/rls/RLSPolicyGenerator.d.ts +38 -0
- package/dist/rls/RLSPolicyGenerator.js +212 -0
- package/dist/search/FullTextSearchManager.d.ts +61 -0
- package/dist/search/FullTextSearchManager.js +265 -0
- package/package.json +65 -0
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/*
|
|
3
|
+
Copyright (c) 2025 Bernier LLC
|
|
4
|
+
|
|
5
|
+
This file is licensed to the client under a limited-use license.
|
|
6
|
+
The client may use and modify this code *only within the scope of the project it was delivered for*.
|
|
7
|
+
Redistribution or use in other products or commercial offerings is not permitted without written consent from Bernier LLC.
|
|
8
|
+
*/
|
|
9
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
10
|
+
exports.RLSPolicyGenerator = void 0;
|
|
11
|
+
/**
|
|
12
|
+
* RLS policy generator for Supabase
|
|
13
|
+
* Creates policies for multi-tenant security with Clerk integration
|
|
14
|
+
*/
|
|
15
|
+
class RLSPolicyGenerator {
|
|
16
|
+
/**
|
|
17
|
+
* Generate RLS policy for user-scoped access
|
|
18
|
+
*/
|
|
19
|
+
static generateUserPolicy(table) {
|
|
20
|
+
return `
|
|
21
|
+
-- Enable RLS
|
|
22
|
+
ALTER TABLE ${table} ENABLE ROW LEVEL SECURITY;
|
|
23
|
+
|
|
24
|
+
-- Users can view own records
|
|
25
|
+
CREATE POLICY "Users can view own records"
|
|
26
|
+
ON ${table}
|
|
27
|
+
FOR SELECT
|
|
28
|
+
USING (
|
|
29
|
+
user_id = (
|
|
30
|
+
SELECT id FROM users
|
|
31
|
+
WHERE clerk_id = current_setting('request.jwt.claims', true)::json->>'sub'
|
|
32
|
+
)
|
|
33
|
+
);
|
|
34
|
+
|
|
35
|
+
-- Users can insert own records
|
|
36
|
+
CREATE POLICY "Users can insert own records"
|
|
37
|
+
ON ${table}
|
|
38
|
+
FOR INSERT
|
|
39
|
+
WITH CHECK (
|
|
40
|
+
user_id = (
|
|
41
|
+
SELECT id FROM users
|
|
42
|
+
WHERE clerk_id = current_setting('request.jwt.claims', true)::json->>'sub'
|
|
43
|
+
)
|
|
44
|
+
);
|
|
45
|
+
|
|
46
|
+
-- Users can update own records
|
|
47
|
+
CREATE POLICY "Users can update own records"
|
|
48
|
+
ON ${table}
|
|
49
|
+
FOR UPDATE
|
|
50
|
+
USING (
|
|
51
|
+
user_id = (
|
|
52
|
+
SELECT id FROM users
|
|
53
|
+
WHERE clerk_id = current_setting('request.jwt.claims', true)::json->>'sub'
|
|
54
|
+
)
|
|
55
|
+
);
|
|
56
|
+
|
|
57
|
+
-- Users can delete own records (soft delete only)
|
|
58
|
+
CREATE POLICY "Users can delete own records"
|
|
59
|
+
ON ${table}
|
|
60
|
+
FOR UPDATE
|
|
61
|
+
USING (
|
|
62
|
+
user_id = (
|
|
63
|
+
SELECT id FROM users
|
|
64
|
+
WHERE clerk_id = current_setting('request.jwt.claims', true)::json->>'sub'
|
|
65
|
+
)
|
|
66
|
+
)
|
|
67
|
+
WITH CHECK (deleted_at IS NOT NULL);
|
|
68
|
+
`;
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Generate admin bypass policy
|
|
72
|
+
*/
|
|
73
|
+
static generateAdminPolicy(table) {
|
|
74
|
+
return `
|
|
75
|
+
-- Admins can do anything
|
|
76
|
+
CREATE POLICY "Admins have full access"
|
|
77
|
+
ON ${table}
|
|
78
|
+
FOR ALL
|
|
79
|
+
USING (
|
|
80
|
+
(SELECT role FROM users
|
|
81
|
+
WHERE clerk_id = current_setting('request.jwt.claims', true)::json->>'sub'
|
|
82
|
+
) = 'admin'
|
|
83
|
+
);
|
|
84
|
+
`;
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Generate multi-tenant policy
|
|
88
|
+
*/
|
|
89
|
+
static generateTenantPolicy(table) {
|
|
90
|
+
return `
|
|
91
|
+
-- Tenant isolation
|
|
92
|
+
CREATE POLICY "Tenant isolation"
|
|
93
|
+
ON ${table}
|
|
94
|
+
FOR ALL
|
|
95
|
+
USING (
|
|
96
|
+
tenant_id = current_setting('app.tenant_id', true)::uuid
|
|
97
|
+
);
|
|
98
|
+
`;
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Generate read-only policy for public data
|
|
102
|
+
*/
|
|
103
|
+
static generatePublicReadPolicy(table, condition) {
|
|
104
|
+
const whereClause = condition ? `AND ${condition}` : '';
|
|
105
|
+
return `
|
|
106
|
+
-- Public read access
|
|
107
|
+
CREATE POLICY "Public can view published records"
|
|
108
|
+
ON ${table}
|
|
109
|
+
FOR SELECT
|
|
110
|
+
USING (
|
|
111
|
+
status = 'published'
|
|
112
|
+
AND deleted_at IS NULL
|
|
113
|
+
${whereClause}
|
|
114
|
+
);
|
|
115
|
+
`;
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* Generate policy for shared resources
|
|
119
|
+
*/
|
|
120
|
+
static generateSharedResourcePolicy(table, sharingTable = 'shared_access') {
|
|
121
|
+
return `
|
|
122
|
+
-- Shared resource access
|
|
123
|
+
CREATE POLICY "Users can access shared resources"
|
|
124
|
+
ON ${table}
|
|
125
|
+
FOR SELECT
|
|
126
|
+
USING (
|
|
127
|
+
-- Owner access
|
|
128
|
+
user_id = (
|
|
129
|
+
SELECT id FROM users
|
|
130
|
+
WHERE clerk_id = current_setting('request.jwt.claims', true)::json->>'sub'
|
|
131
|
+
)
|
|
132
|
+
OR
|
|
133
|
+
-- Shared access
|
|
134
|
+
id IN (
|
|
135
|
+
SELECT resource_id FROM ${sharingTable}
|
|
136
|
+
WHERE shared_with_user_id = (
|
|
137
|
+
SELECT id FROM users
|
|
138
|
+
WHERE clerk_id = current_setting('request.jwt.claims', true)::json->>'sub'
|
|
139
|
+
)
|
|
140
|
+
)
|
|
141
|
+
);
|
|
142
|
+
`;
|
|
143
|
+
}
|
|
144
|
+
/**
|
|
145
|
+
* Generate policy for role-based access
|
|
146
|
+
*/
|
|
147
|
+
static generateRoleBasedPolicy(table, allowedRoles) {
|
|
148
|
+
const rolesArray = allowedRoles.map(r => `'${r}'`).join(', ');
|
|
149
|
+
return `
|
|
150
|
+
-- Role-based access
|
|
151
|
+
CREATE POLICY "Role-based access"
|
|
152
|
+
ON ${table}
|
|
153
|
+
FOR ALL
|
|
154
|
+
USING (
|
|
155
|
+
(SELECT role FROM users
|
|
156
|
+
WHERE clerk_id = current_setting('request.jwt.claims', true)::json->>'sub'
|
|
157
|
+
) IN (${rolesArray})
|
|
158
|
+
);
|
|
159
|
+
`;
|
|
160
|
+
}
|
|
161
|
+
/**
|
|
162
|
+
* Disable RLS on a table (use with caution)
|
|
163
|
+
*/
|
|
164
|
+
static disableRLS(table) {
|
|
165
|
+
return `
|
|
166
|
+
-- Disable RLS (use with caution)
|
|
167
|
+
ALTER TABLE ${table} DISABLE ROW LEVEL SECURITY;
|
|
168
|
+
|
|
169
|
+
-- Drop all policies
|
|
170
|
+
DO $$
|
|
171
|
+
DECLARE
|
|
172
|
+
pol RECORD;
|
|
173
|
+
BEGIN
|
|
174
|
+
FOR pol IN
|
|
175
|
+
SELECT policyname
|
|
176
|
+
FROM pg_policies
|
|
177
|
+
WHERE tablename = '${table}'
|
|
178
|
+
LOOP
|
|
179
|
+
EXECUTE format('DROP POLICY IF EXISTS %I ON ${table}', pol.policyname);
|
|
180
|
+
END LOOP;
|
|
181
|
+
END $$;
|
|
182
|
+
`;
|
|
183
|
+
}
|
|
184
|
+
/**
|
|
185
|
+
* Generate policy for time-based access (e.g., scheduled content)
|
|
186
|
+
*/
|
|
187
|
+
static generateTimeBasedPolicy(table) {
|
|
188
|
+
return `
|
|
189
|
+
-- Time-based access for scheduled content
|
|
190
|
+
CREATE POLICY "Scheduled content visibility"
|
|
191
|
+
ON ${table}
|
|
192
|
+
FOR SELECT
|
|
193
|
+
USING (
|
|
194
|
+
(
|
|
195
|
+
-- Owner can always see
|
|
196
|
+
user_id = (
|
|
197
|
+
SELECT id FROM users
|
|
198
|
+
WHERE clerk_id = current_setting('request.jwt.claims', true)::json->>'sub'
|
|
199
|
+
)
|
|
200
|
+
)
|
|
201
|
+
OR
|
|
202
|
+
(
|
|
203
|
+
-- Public can see if published and scheduled time has passed
|
|
204
|
+
status = 'published'
|
|
205
|
+
AND deleted_at IS NULL
|
|
206
|
+
AND (scheduled_for IS NULL OR scheduled_for <= NOW())
|
|
207
|
+
)
|
|
208
|
+
);
|
|
209
|
+
`;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
exports.RLSPolicyGenerator = RLSPolicyGenerator;
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { PostgreSQLAdapter } from '../PostgreSQLAdapter';
|
|
2
|
+
/**
|
|
3
|
+
* Search result with similarity scores
|
|
4
|
+
*/
|
|
5
|
+
export interface SearchResult {
|
|
6
|
+
id: string;
|
|
7
|
+
title: string;
|
|
8
|
+
excerpt?: string;
|
|
9
|
+
body?: string;
|
|
10
|
+
titleSimilarity: number;
|
|
11
|
+
bodySimilarity: number;
|
|
12
|
+
totalSimilarity: number;
|
|
13
|
+
[key: string]: unknown;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Search options
|
|
17
|
+
*/
|
|
18
|
+
export interface SearchOptions {
|
|
19
|
+
limit?: number;
|
|
20
|
+
offset?: number;
|
|
21
|
+
minSimilarity?: number;
|
|
22
|
+
fields?: string[];
|
|
23
|
+
orderBy?: 'relevance' | 'date' | 'title';
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* PostgreSQL full-text search with pg_trgm
|
|
27
|
+
* Enables fuzzy matching and typo-tolerance
|
|
28
|
+
*/
|
|
29
|
+
export declare class FullTextSearchManager {
|
|
30
|
+
private adapter;
|
|
31
|
+
private logger?;
|
|
32
|
+
constructor(adapter: PostgreSQLAdapter);
|
|
33
|
+
/**
|
|
34
|
+
* Search content with full-text search and fuzzy matching
|
|
35
|
+
*/
|
|
36
|
+
searchContent(query: string, options?: SearchOptions): Promise<SearchResult[]>;
|
|
37
|
+
/**
|
|
38
|
+
* Search using PostgreSQL full-text search vectors (ts_vector)
|
|
39
|
+
*/
|
|
40
|
+
searchWithTsVector(query: string, options?: SearchOptions): Promise<SearchResult[]>;
|
|
41
|
+
/**
|
|
42
|
+
* Combined search using both pg_trgm fuzzy matching and ts_vector
|
|
43
|
+
*/
|
|
44
|
+
searchCombined(query: string, options?: SearchOptions): Promise<SearchResult[]>;
|
|
45
|
+
/**
|
|
46
|
+
* Create full-text search indexes
|
|
47
|
+
*/
|
|
48
|
+
createIndexes(): Promise<void>;
|
|
49
|
+
/**
|
|
50
|
+
* Drop full-text search indexes
|
|
51
|
+
*/
|
|
52
|
+
dropIndexes(): Promise<void>;
|
|
53
|
+
/**
|
|
54
|
+
* Get search statistics
|
|
55
|
+
*/
|
|
56
|
+
getSearchStats(): Promise<{
|
|
57
|
+
indexesExist: boolean;
|
|
58
|
+
extensionEnabled: boolean;
|
|
59
|
+
searchVectorExists: boolean;
|
|
60
|
+
}>;
|
|
61
|
+
}
|
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/*
|
|
3
|
+
Copyright (c) 2025 Bernier LLC
|
|
4
|
+
|
|
5
|
+
This file is licensed to the client under a limited-use license.
|
|
6
|
+
The client may use and modify this code *only within the scope of the project it was delivered for*.
|
|
7
|
+
Redistribution or use in other products or commercial offerings is not permitted without written consent from Bernier LLC.
|
|
8
|
+
*/
|
|
9
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
10
|
+
exports.FullTextSearchManager = void 0;
|
|
11
|
+
/**
|
|
12
|
+
* PostgreSQL full-text search with pg_trgm
|
|
13
|
+
* Enables fuzzy matching and typo-tolerance
|
|
14
|
+
*/
|
|
15
|
+
class FullTextSearchManager {
|
|
16
|
+
constructor(adapter) {
|
|
17
|
+
this.adapter = adapter;
|
|
18
|
+
// Access logger from adapter (it's a public property but typed generically)
|
|
19
|
+
this.logger = adapter.logger;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Search content with full-text search and fuzzy matching
|
|
23
|
+
*/
|
|
24
|
+
async searchContent(query, options = {}) {
|
|
25
|
+
const { limit = 20, offset = 0, minSimilarity = 0.3, fields = ['title', 'body', 'excerpt'], orderBy = 'relevance' } = options;
|
|
26
|
+
if (this.logger) {
|
|
27
|
+
this.logger.debug('Performing full-text search', {
|
|
28
|
+
query,
|
|
29
|
+
limit,
|
|
30
|
+
offset,
|
|
31
|
+
minSimilarity,
|
|
32
|
+
orderBy
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
// Build field similarity calculations
|
|
36
|
+
const titleSimilarity = fields.includes('title')
|
|
37
|
+
? 'similarity(title, $1)'
|
|
38
|
+
: '0';
|
|
39
|
+
const bodySimilarity = fields.includes('body')
|
|
40
|
+
? 'similarity(body, $1)'
|
|
41
|
+
: '0';
|
|
42
|
+
const excerptSimilarity = fields.includes('excerpt')
|
|
43
|
+
? 'similarity(excerpt, $1)'
|
|
44
|
+
: '0';
|
|
45
|
+
// Calculate total similarity with weights (title > excerpt > body)
|
|
46
|
+
const totalSimilarity = `(
|
|
47
|
+
(${titleSimilarity} * 3) +
|
|
48
|
+
(${excerptSimilarity} * 2) +
|
|
49
|
+
(${bodySimilarity} * 1)
|
|
50
|
+
) / 6`;
|
|
51
|
+
// Build WHERE clause
|
|
52
|
+
const whereConditions = [];
|
|
53
|
+
if (fields.includes('title')) {
|
|
54
|
+
whereConditions.push(`${titleSimilarity} > $2`);
|
|
55
|
+
}
|
|
56
|
+
if (fields.includes('body')) {
|
|
57
|
+
whereConditions.push(`${bodySimilarity} > $2`);
|
|
58
|
+
}
|
|
59
|
+
if (fields.includes('excerpt')) {
|
|
60
|
+
whereConditions.push(`${excerptSimilarity} > $2`);
|
|
61
|
+
}
|
|
62
|
+
const whereClause = whereConditions.length > 0
|
|
63
|
+
? `WHERE (${whereConditions.join(' OR ')})`
|
|
64
|
+
: '';
|
|
65
|
+
// Build ORDER BY clause
|
|
66
|
+
let orderByClause = '';
|
|
67
|
+
switch (orderBy) {
|
|
68
|
+
case 'relevance':
|
|
69
|
+
orderByClause = 'ORDER BY total_similarity DESC';
|
|
70
|
+
break;
|
|
71
|
+
case 'date':
|
|
72
|
+
orderByClause = 'ORDER BY created_at DESC';
|
|
73
|
+
break;
|
|
74
|
+
case 'title':
|
|
75
|
+
orderByClause = 'ORDER BY title ASC';
|
|
76
|
+
break;
|
|
77
|
+
}
|
|
78
|
+
const sql = `
|
|
79
|
+
SELECT
|
|
80
|
+
id,
|
|
81
|
+
title,
|
|
82
|
+
excerpt,
|
|
83
|
+
${titleSimilarity} as title_similarity,
|
|
84
|
+
${bodySimilarity} as body_similarity,
|
|
85
|
+
${totalSimilarity} as total_similarity,
|
|
86
|
+
created_at,
|
|
87
|
+
updated_at
|
|
88
|
+
FROM content
|
|
89
|
+
${whereClause}
|
|
90
|
+
${orderByClause}
|
|
91
|
+
LIMIT $3 OFFSET $4
|
|
92
|
+
`;
|
|
93
|
+
const result = await this.adapter.query(sql, [query, minSimilarity, limit, offset]);
|
|
94
|
+
if (this.logger) {
|
|
95
|
+
this.logger.debug('Full-text search completed', {
|
|
96
|
+
query,
|
|
97
|
+
resultsCount: result.rowCount
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
return result.rows;
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* Search using PostgreSQL full-text search vectors (ts_vector)
|
|
104
|
+
*/
|
|
105
|
+
async searchWithTsVector(query, options = {}) {
|
|
106
|
+
const { limit = 20, offset = 0 } = options;
|
|
107
|
+
if (this.logger) {
|
|
108
|
+
this.logger.debug('Performing ts_vector search', { query, limit, offset });
|
|
109
|
+
}
|
|
110
|
+
const sql = `
|
|
111
|
+
SELECT
|
|
112
|
+
id,
|
|
113
|
+
title,
|
|
114
|
+
excerpt,
|
|
115
|
+
ts_rank(search_vector, plainto_tsquery('english', $1)) as rank
|
|
116
|
+
FROM content
|
|
117
|
+
WHERE search_vector @@ plainto_tsquery('english', $1)
|
|
118
|
+
AND deleted_at IS NULL
|
|
119
|
+
ORDER BY rank DESC
|
|
120
|
+
LIMIT $2 OFFSET $3
|
|
121
|
+
`;
|
|
122
|
+
const result = await this.adapter.query(sql, [query, limit, offset]);
|
|
123
|
+
if (this.logger) {
|
|
124
|
+
this.logger.debug('ts_vector search completed', {
|
|
125
|
+
query,
|
|
126
|
+
resultsCount: result.rowCount
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
return result.rows;
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* Combined search using both pg_trgm fuzzy matching and ts_vector
|
|
133
|
+
*/
|
|
134
|
+
async searchCombined(query, options = {}) {
|
|
135
|
+
const { limit = 20, offset = 0, minSimilarity = 0.2 } = options;
|
|
136
|
+
if (this.logger) {
|
|
137
|
+
this.logger.debug('Performing combined search', { query, limit, offset });
|
|
138
|
+
}
|
|
139
|
+
const sql = `
|
|
140
|
+
SELECT DISTINCT
|
|
141
|
+
id,
|
|
142
|
+
title,
|
|
143
|
+
excerpt,
|
|
144
|
+
similarity(title, $1) as title_similarity,
|
|
145
|
+
similarity(body, $1) as body_similarity,
|
|
146
|
+
COALESCE(ts_rank(search_vector, plainto_tsquery('english', $1)), 0) as ts_rank,
|
|
147
|
+
(
|
|
148
|
+
(similarity(title, $1) * 3) +
|
|
149
|
+
(similarity(body, $1) * 1) +
|
|
150
|
+
(COALESCE(ts_rank(search_vector, plainto_tsquery('english', $1)), 0) * 2)
|
|
151
|
+
) / 6 as total_similarity,
|
|
152
|
+
created_at,
|
|
153
|
+
updated_at
|
|
154
|
+
FROM content
|
|
155
|
+
WHERE
|
|
156
|
+
(
|
|
157
|
+
similarity(title, $1) > $2
|
|
158
|
+
OR similarity(body, $1) > $2
|
|
159
|
+
OR search_vector @@ plainto_tsquery('english', $1)
|
|
160
|
+
)
|
|
161
|
+
AND deleted_at IS NULL
|
|
162
|
+
ORDER BY total_similarity DESC
|
|
163
|
+
LIMIT $3 OFFSET $4
|
|
164
|
+
`;
|
|
165
|
+
const result = await this.adapter.query(sql, [query, minSimilarity, limit, offset]);
|
|
166
|
+
if (this.logger) {
|
|
167
|
+
this.logger.debug('Combined search completed', {
|
|
168
|
+
query,
|
|
169
|
+
resultsCount: result.rowCount
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
return result.rows;
|
|
173
|
+
}
|
|
174
|
+
/**
|
|
175
|
+
* Create full-text search indexes
|
|
176
|
+
*/
|
|
177
|
+
async createIndexes() {
|
|
178
|
+
if (this.logger) {
|
|
179
|
+
this.logger.info('Creating full-text search indexes');
|
|
180
|
+
}
|
|
181
|
+
await this.adapter.query(`
|
|
182
|
+
-- Enable pg_trgm extension
|
|
183
|
+
CREATE EXTENSION IF NOT EXISTS pg_trgm;
|
|
184
|
+
|
|
185
|
+
-- Create GIN indexes for fuzzy matching
|
|
186
|
+
CREATE INDEX IF NOT EXISTS idx_content_title_trgm
|
|
187
|
+
ON content USING gin (title gin_trgm_ops);
|
|
188
|
+
|
|
189
|
+
CREATE INDEX IF NOT EXISTS idx_content_body_trgm
|
|
190
|
+
ON content USING gin (body gin_trgm_ops);
|
|
191
|
+
|
|
192
|
+
CREATE INDEX IF NOT EXISTS idx_content_excerpt_trgm
|
|
193
|
+
ON content USING gin (excerpt gin_trgm_ops);
|
|
194
|
+
|
|
195
|
+
-- Create full-text search vector if not exists
|
|
196
|
+
DO $$
|
|
197
|
+
BEGIN
|
|
198
|
+
IF NOT EXISTS (
|
|
199
|
+
SELECT 1 FROM information_schema.columns
|
|
200
|
+
WHERE table_name = 'content' AND column_name = 'search_vector'
|
|
201
|
+
) THEN
|
|
202
|
+
ALTER TABLE content ADD COLUMN search_vector tsvector
|
|
203
|
+
GENERATED ALWAYS AS (
|
|
204
|
+
setweight(to_tsvector('english', coalesce(title, '')), 'A') ||
|
|
205
|
+
setweight(to_tsvector('english', coalesce(excerpt, '')), 'B') ||
|
|
206
|
+
setweight(to_tsvector('english', coalesce(body, '')), 'C')
|
|
207
|
+
) STORED;
|
|
208
|
+
END IF;
|
|
209
|
+
END $$;
|
|
210
|
+
|
|
211
|
+
-- Create GIN index on search vector
|
|
212
|
+
CREATE INDEX IF NOT EXISTS idx_content_search_vector
|
|
213
|
+
ON content USING gin (search_vector);
|
|
214
|
+
`);
|
|
215
|
+
if (this.logger) {
|
|
216
|
+
this.logger.info('Full-text search indexes created successfully');
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
/**
|
|
220
|
+
* Drop full-text search indexes
|
|
221
|
+
*/
|
|
222
|
+
async dropIndexes() {
|
|
223
|
+
if (this.logger) {
|
|
224
|
+
this.logger.info('Dropping full-text search indexes');
|
|
225
|
+
}
|
|
226
|
+
await this.adapter.query(`
|
|
227
|
+
DROP INDEX IF EXISTS idx_content_title_trgm;
|
|
228
|
+
DROP INDEX IF EXISTS idx_content_body_trgm;
|
|
229
|
+
DROP INDEX IF EXISTS idx_content_excerpt_trgm;
|
|
230
|
+
DROP INDEX IF EXISTS idx_content_search_vector;
|
|
231
|
+
|
|
232
|
+
-- Drop search_vector column
|
|
233
|
+
ALTER TABLE content DROP COLUMN IF EXISTS search_vector;
|
|
234
|
+
`);
|
|
235
|
+
if (this.logger) {
|
|
236
|
+
this.logger.info('Full-text search indexes dropped successfully');
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
/**
|
|
240
|
+
* Get search statistics
|
|
241
|
+
*/
|
|
242
|
+
async getSearchStats() {
|
|
243
|
+
const result = await this.adapter.query(`
|
|
244
|
+
SELECT
|
|
245
|
+
EXISTS (
|
|
246
|
+
SELECT 1 FROM pg_extension WHERE extname = 'pg_trgm'
|
|
247
|
+
) as extension_enabled,
|
|
248
|
+
EXISTS (
|
|
249
|
+
SELECT 1 FROM pg_indexes
|
|
250
|
+
WHERE indexname IN ('idx_content_title_trgm', 'idx_content_search_vector')
|
|
251
|
+
) as indexes_exist,
|
|
252
|
+
EXISTS (
|
|
253
|
+
SELECT 1 FROM information_schema.columns
|
|
254
|
+
WHERE table_name = 'content' AND column_name = 'search_vector'
|
|
255
|
+
) as search_vector_exists
|
|
256
|
+
`);
|
|
257
|
+
const row = result.rows[0];
|
|
258
|
+
return {
|
|
259
|
+
extensionEnabled: row.extension_enabled,
|
|
260
|
+
indexesExist: row.indexes_exist,
|
|
261
|
+
searchVectorExists: row.search_vector_exists
|
|
262
|
+
};
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
exports.FullTextSearchManager = FullTextSearchManager;
|
package/package.json
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@bernierllc/database-adapter-postgresql",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "PostgreSQL/Supabase Database Adapter Implementation with RLS, real-time subscriptions, and full-text search",
|
|
5
|
+
"main": "dist/index.js",
|
|
6
|
+
"types": "dist/index.d.ts",
|
|
7
|
+
"keywords": [
|
|
8
|
+
"postgresql",
|
|
9
|
+
"supabase",
|
|
10
|
+
"database",
|
|
11
|
+
"adapter",
|
|
12
|
+
"rls",
|
|
13
|
+
"realtime",
|
|
14
|
+
"clerk",
|
|
15
|
+
"connection-pooling"
|
|
16
|
+
],
|
|
17
|
+
"author": "Bernier LLC",
|
|
18
|
+
"license": "SEE LICENSE IN LICENSE",
|
|
19
|
+
"dependencies": {
|
|
20
|
+
"pg": "^8.11.0",
|
|
21
|
+
"@supabase/supabase-js": "^2.38.0",
|
|
22
|
+
"@bernierllc/database-adapter-core": "1.0.0",
|
|
23
|
+
"@bernierllc/logger": "1.0.3",
|
|
24
|
+
"@bernierllc/crypto-utils": "1.0.2",
|
|
25
|
+
"@bernierllc/retry-policy": "0.1.6"
|
|
26
|
+
},
|
|
27
|
+
"devDependencies": {
|
|
28
|
+
"@types/pg": "^8.10.0",
|
|
29
|
+
"@types/node": "^20.0.0",
|
|
30
|
+
"typescript": "^5.0.0",
|
|
31
|
+
"jest": "^29.0.0",
|
|
32
|
+
"ts-jest": "^29.0.0",
|
|
33
|
+
"@types/jest": "^29.0.0",
|
|
34
|
+
"eslint": "^8.0.0",
|
|
35
|
+
"@typescript-eslint/eslint-plugin": "^6.0.0",
|
|
36
|
+
"@typescript-eslint/parser": "^6.0.0",
|
|
37
|
+
"testcontainers": "^10.0.0"
|
|
38
|
+
},
|
|
39
|
+
"files": [
|
|
40
|
+
"dist",
|
|
41
|
+
"README.md",
|
|
42
|
+
"LICENSE"
|
|
43
|
+
],
|
|
44
|
+
"bernierllc": {
|
|
45
|
+
"category": "core",
|
|
46
|
+
"integration": {
|
|
47
|
+
"logger": "required",
|
|
48
|
+
"docs-suite": "ready",
|
|
49
|
+
"neverhub": "optional"
|
|
50
|
+
}
|
|
51
|
+
},
|
|
52
|
+
"publishConfig": {
|
|
53
|
+
"access": "public",
|
|
54
|
+
"registry": "https://registry.npmjs.org/"
|
|
55
|
+
},
|
|
56
|
+
"scripts": {
|
|
57
|
+
"build": "tsc",
|
|
58
|
+
"clean": "rm -rf dist",
|
|
59
|
+
"test": "jest",
|
|
60
|
+
"test:run": "jest",
|
|
61
|
+
"test:coverage": "jest --coverage",
|
|
62
|
+
"lint": "eslint src --ext .ts",
|
|
63
|
+
"lint:fix": "eslint src --ext .ts --fix"
|
|
64
|
+
}
|
|
65
|
+
}
|