ugly-app 0.1.310 → 0.1.311

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,262 +0,0 @@
1
- /**
2
- * Translates a subset of MongoDB aggregation pipeline stages into PostgreSQL SQL.
3
- *
4
- * Supported stages: $match, $sort, $limit, $skip, $count, $group
5
- * Supported $group accumulators: $sum, $avg, $min, $max, $first, $last
6
- */
7
-
8
- import { translateFilter } from './PostgresFilter.js';
9
-
10
- interface QueryParts {
11
- select: string;
12
- from: string;
13
- where: string[];
14
- groupBy: string[];
15
- orderBy: string[];
16
- limit: number | null;
17
- offset: number | null;
18
- values: unknown[];
19
- paramIdx: number;
20
- /** True after a $group stage has been applied */
21
- grouped: boolean;
22
- }
23
-
24
- export interface TranslatedPipeline {
25
- sql: string;
26
- values: unknown[];
27
- }
28
-
29
- /** Build JSONB accessor for a field in the base table */
30
- function jsonbPath(field: string, asText = true): string {
31
- if (field === '_id') return '"_id"';
32
- const parts = field.split('.');
33
- if (parts.length === 1) {
34
- return asText ? `data->>'${parts[0]}'` : `data->'${parts[0]}'`;
35
- }
36
- const chain = parts.slice(0, -1).map((p) => `'${p}'`).join('->');
37
- const last = parts[parts.length - 1];
38
- return asText ? `data->${chain}->>'${last}'` : `data->${chain}->'${last}'`;
39
- }
40
-
41
- /** Wrap current query parts into a subquery, resetting for further stages */
42
- function wrapAsSubquery(parts: QueryParts): QueryParts {
43
- const innerSql = buildSql(parts);
44
- return {
45
- select: '*',
46
- from: `(${innerSql}) AS _sub${parts.paramIdx}`,
47
- where: [],
48
- groupBy: [],
49
- orderBy: [],
50
- limit: null,
51
- offset: null,
52
- values: [...parts.values],
53
- paramIdx: parts.paramIdx,
54
- grouped: false,
55
- };
56
- }
57
-
58
- function buildSql(parts: QueryParts): string {
59
- let sql = `SELECT ${parts.select} FROM ${parts.from}`;
60
- if (parts.where.length > 0) {
61
- sql += ` WHERE ${parts.where.join(' AND ')}`;
62
- }
63
- if (parts.groupBy.length > 0) {
64
- sql += ` GROUP BY ${parts.groupBy.join(', ')}`;
65
- }
66
- if (parts.orderBy.length > 0) {
67
- sql += ` ORDER BY ${parts.orderBy.join(', ')}`;
68
- }
69
- if (parts.limit !== null) {
70
- sql += ` LIMIT ${parts.limit}`;
71
- }
72
- if (parts.offset !== null) {
73
- sql += ` OFFSET ${parts.offset}`;
74
- }
75
- return sql;
76
- }
77
-
78
- function applyMatch(parts: QueryParts, match: Record<string, unknown>): void {
79
- if (parts.grouped) {
80
- // After a $group, wrap as subquery and match against aliased columns
81
- const wrapped = wrapAsSubquery(parts);
82
- Object.assign(parts, wrapped);
83
- }
84
-
85
- const { where, values } = translateFilter(match);
86
- if (!where) return;
87
-
88
- // Rewrite param placeholders from $1-based to current paramIdx
89
- let rewritten = where;
90
- // Replace from highest to lowest to avoid $1 matching $10, $11, etc.
91
- for (let i = values.length; i >= 1; i--) {
92
- const newIdx = parts.paramIdx + i - 1;
93
- rewritten = rewritten.replace(new RegExp(`\\$${i}(?!\\d)`, 'g'), `$${newIdx}`);
94
- }
95
-
96
- parts.where.push(rewritten);
97
- parts.values.push(...values);
98
- parts.paramIdx += values.length;
99
- }
100
-
101
- function applySort(parts: QueryParts, sort: Record<string, number>): void {
102
- if (parts.grouped) {
103
- // After $group, sort uses aliased column names directly
104
- const wrapped = wrapAsSubquery(parts);
105
- Object.assign(parts, wrapped);
106
- }
107
-
108
- parts.orderBy = [];
109
- for (const [field, dir] of Object.entries(sort)) {
110
- if (parts.from.includes('_sub')) {
111
- // Sorting on a subquery — use column names directly
112
- parts.orderBy.push(`"${field}" ${dir === 1 ? 'ASC' : 'DESC'}`);
113
- } else {
114
- const accessor = field === '_id' ? '"_id"' : jsonbPath(field);
115
- parts.orderBy.push(`${accessor} ${dir === 1 ? 'ASC' : 'DESC'}`);
116
- }
117
- }
118
- }
119
-
120
- function applyLimit(parts: QueryParts, limit: number): void {
121
- parts.limit = limit;
122
- }
123
-
124
- function applySkip(parts: QueryParts, skip: number): void {
125
- parts.offset = skip;
126
- }
127
-
128
- function applyCount(parts: QueryParts, alias: string): void {
129
- const innerSql = buildSql(parts);
130
- parts.select = `COUNT(*) AS "${alias}"`;
131
- parts.from = `(${innerSql}) AS _counted`;
132
- parts.where = [];
133
- parts.groupBy = [];
134
- parts.orderBy = [];
135
- parts.limit = null;
136
- parts.offset = null;
137
- parts.grouped = false;
138
- }
139
-
140
- function resolveAccumulator(acc: Record<string, unknown>): string {
141
- const [[op, field]] = Object.entries(acc);
142
- const col = typeof field === 'string' && field.startsWith('$')
143
- ? jsonbPath(field.slice(1))
144
- : String(field);
145
-
146
- switch (op) {
147
- case '$sum':
148
- if (typeof field === 'number') return String(field);
149
- return `SUM((${col})::double precision)`;
150
- case '$avg':
151
- return `AVG((${col})::double precision)`;
152
- case '$min':
153
- return `MIN((${col})::double precision)`;
154
- case '$max':
155
- return `MAX((${col})::double precision)`;
156
- case '$first':
157
- return `(array_agg(${col}))[1]`;
158
- case '$last':
159
- return `(array_agg(${col}))[array_length(array_agg(${col}), 1)]`;
160
- default:
161
- throw new Error(`[DB] Unsupported accumulator: ${op}`);
162
- }
163
- }
164
-
165
- function applyGroup(parts: QueryParts, group: Record<string, unknown>): void {
166
- const groupId = group._id;
167
- const selectParts: string[] = [];
168
- const groupByParts: string[] = [];
169
-
170
- // _id field — the group key
171
- if (groupId === null) {
172
- selectParts.push(`NULL AS "_id"`);
173
- } else if (typeof groupId === 'string' && groupId.startsWith('$')) {
174
- const field = groupId.slice(1);
175
- const accessor = field === '_id' ? '"_id"' : jsonbPath(field);
176
- selectParts.push(`${accessor} AS "_id"`);
177
- groupByParts.push(accessor);
178
- } else {
179
- throw new Error(`[DB] Unsupported $group _id expression: ${JSON.stringify(groupId)}`);
180
- }
181
-
182
- // Accumulators
183
- for (const [alias, expr] of Object.entries(group)) {
184
- if (alias === '_id') continue;
185
- const resolved = resolveAccumulator(expr as Record<string, unknown>);
186
- selectParts.push(`${resolved} AS "${alias}"`);
187
- }
188
-
189
- parts.select = selectParts.join(', ');
190
- parts.groupBy = groupByParts;
191
- parts.grouped = true;
192
- }
193
-
194
- export function translatePipeline(
195
- collection: string,
196
- pipeline: Record<string, unknown>[],
197
- options?: { skip?: number; limit?: number },
198
- ): TranslatedPipeline {
199
- const parts: QueryParts = {
200
- select: '_id, data, created, updated, version',
201
- from: `"${collection}"`,
202
- where: [],
203
- groupBy: [],
204
- orderBy: [],
205
- limit: null,
206
- offset: null,
207
- values: [],
208
- paramIdx: 1,
209
- grouped: false,
210
- };
211
-
212
- for (const stage of pipeline) {
213
- const [[key, value]] = Object.entries(stage);
214
- switch (key) {
215
- case '$match':
216
- applyMatch(parts, value as Record<string, unknown>);
217
- break;
218
- case '$sort':
219
- applySort(parts, value as Record<string, number>);
220
- break;
221
- case '$limit':
222
- applyLimit(parts, value as number);
223
- break;
224
- case '$skip':
225
- applySkip(parts, value as number);
226
- break;
227
- case '$count':
228
- applyCount(parts, value as string);
229
- break;
230
- case '$group':
231
- applyGroup(parts, value as Record<string, unknown>);
232
- break;
233
- // Stages that are sanitized by StoreHandlers but not translatable to SQL;
234
- // treat as no-ops so queries resolve without error.
235
- case '$addFields':
236
- case '$set':
237
- case '$project':
238
- case '$unwind':
239
- case '$sample':
240
- case '$replaceRoot':
241
- case '$replaceWith':
242
- case '$bucket':
243
- case '$bucketAuto':
244
- break;
245
- default:
246
- throw new Error(`[DB] Unsupported pipeline stage: ${key}`);
247
- }
248
- }
249
-
250
- // Apply options (limit/skip) after pipeline stages
251
- if (options?.skip !== undefined) {
252
- parts.offset = options.skip;
253
- }
254
- if (options?.limit !== undefined) {
255
- parts.limit = options.limit;
256
- }
257
-
258
- return {
259
- sql: buildSql(parts),
260
- values: parts.values,
261
- };
262
- }
@@ -1,51 +0,0 @@
1
- import { query } from './Postgres.js';
2
-
3
- export async function ensureTable(collection: string): Promise<void> {
4
- // Validate collection name (alphanumeric + underscore only)
5
- if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(collection)) {
6
- throw new Error(`[DB] Invalid collection name: ${collection}`);
7
- }
8
-
9
- await query(`
10
- CREATE TABLE IF NOT EXISTS "${collection}" (
11
- _id TEXT PRIMARY KEY,
12
- data JSONB NOT NULL,
13
- created TIMESTAMPTZ NOT NULL DEFAULT now(),
14
- updated TIMESTAMPTZ NOT NULL DEFAULT now(),
15
- version INTEGER NOT NULL DEFAULT 1
16
- )
17
- `);
18
-
19
- // GIN index for JSONB queries
20
- await query(`
21
- CREATE INDEX IF NOT EXISTS "idx_${collection}_data"
22
- ON "${collection}" USING GIN (data)
23
- `);
24
- }
25
-
26
- export async function ensureSearchColumn(
27
- collection: string,
28
- fields: string[],
29
- _language = 'english',
30
- ): Promise<void> {
31
- await query(`
32
- ALTER TABLE "${collection}"
33
- ADD COLUMN IF NOT EXISTS search TSVECTOR
34
- `);
35
-
36
- await query(`
37
- CREATE INDEX IF NOT EXISTS "idx_${collection}_search"
38
- ON "${collection}" USING GIN (search)
39
- `);
40
- }
41
-
42
- export async function tableExists(collection: string): Promise<boolean> {
43
- const result = await query<{ exists: boolean }>(
44
- `SELECT EXISTS (
45
- SELECT FROM information_schema.tables
46
- WHERE table_name = $1
47
- ) as exists`,
48
- [collection],
49
- );
50
- return result.rows[0].exists;
51
- }
@@ -1,109 +0,0 @@
1
- import type { DBObject } from '../shared/DB.js';
2
- import { query } from './Postgres.js';
3
- import { translateFilter } from './PostgresFilter.js';
4
-
5
- export async function ensureSearchColumn(
6
- collection: string,
7
- fields: string[],
8
- _language = 'english',
9
- ): Promise<void> {
10
- await query(`
11
- ALTER TABLE "${collection}"
12
- ADD COLUMN IF NOT EXISTS search TSVECTOR
13
- `);
14
-
15
- await query(`
16
- CREATE INDEX IF NOT EXISTS "idx_${collection}_search"
17
- ON "${collection}" USING GIN (search)
18
- `);
19
- }
20
-
21
- interface FullRow { _id: string; data: Record<string, unknown>; created: Date; updated: Date; version: number }
22
-
23
- function toDoc<T extends DBObject>(row: FullRow): T {
24
- return {
25
- ...row.data,
26
- _id: row._id,
27
- created: row.created,
28
- updated: row.updated,
29
- version: row.version,
30
- } as unknown as T;
31
- }
32
-
33
- export async function updateSearchColumn(
34
- collection: string,
35
- id: string,
36
- fields: string[],
37
- language = 'english',
38
- ): Promise<void> {
39
- const fieldExprs = fields.map((f) => `COALESCE(data->>'${f}', '')`);
40
- const concat = fieldExprs.join(` || ' ' || `);
41
-
42
- await query(
43
- `UPDATE "${collection}"
44
- SET search = to_tsvector($1::regconfig, ${concat})
45
- WHERE _id = $2`,
46
- [language, id],
47
- );
48
- }
49
-
50
- export async function pgSearchDocs<T extends DBObject>(
51
- collection: string,
52
- searchQuery: string,
53
- options?: { limit?: number; filter?: Record<string, unknown> },
54
- ): Promise<T[]> {
55
- // Build an OR-based tsquery so that documents matching any term are returned,
56
- // while ts_rank still rewards documents that match more terms.
57
- const terms = searchQuery
58
- .trim()
59
- .split(/\s+/)
60
- .filter(Boolean)
61
- .map((t) => t.replace(/[^a-zA-Z0-9]/g, ''))
62
- .filter(Boolean);
63
-
64
- const tsqueryExpr =
65
- terms.length > 0
66
- ? terms.map((_, i) => `plainto_tsquery('english', $${i + 1})`).join(' || ')
67
- : `plainto_tsquery('english', $1)`;
68
-
69
- const tsrankExpr =
70
- terms.length > 0
71
- ? terms.map((_, i) => `plainto_tsquery('english', $${i + 1})`).join(' || ')
72
- : `plainto_tsquery('english', $1)`;
73
-
74
- const values: unknown[] = terms.length > 0 ? [...terms] : [searchQuery];
75
- let paramIdx = values.length + 1;
76
-
77
- let sql = `
78
- SELECT _id, data, created, updated, version,
79
- ts_rank(search, ${tsrankExpr}) AS rank
80
- FROM "${collection}"
81
- WHERE search @@ (${tsqueryExpr})
82
- `;
83
-
84
- if (options?.filter) {
85
- const { where, values: filterValues } = translateFilter(options.filter);
86
- if (where) {
87
- let rewritten = where;
88
- for (let i = filterValues.length; i >= 1; i--) {
89
- rewritten = rewritten.replace(
90
- new RegExp(`\\$${i}(?!\\d)`, 'g'),
91
- `$${paramIdx + i - 1}`,
92
- );
93
- }
94
- sql += ` AND ${rewritten}`;
95
- values.push(...filterValues);
96
- paramIdx += filterValues.length;
97
- }
98
- }
99
-
100
- sql += ` ORDER BY rank DESC`;
101
-
102
- if (options?.limit) {
103
- sql += ` LIMIT $${paramIdx}`;
104
- values.push(options.limit);
105
- }
106
-
107
- const result = await query<FullRow>(sql, values);
108
- return result.rows.map((row) => toDoc<T>(row));
109
- }
@@ -1,110 +0,0 @@
1
- import { createHash } from 'crypto';
2
- import { QdrantClient } from '@qdrant/js-client-rest';
3
-
4
- let _client: QdrantClient | null = null;
5
-
6
- let _collectionPrefix = '';
7
-
8
- export function setQdrantPrefix(prefix: string): void {
9
- _collectionPrefix = prefix ? `${prefix}_` : '';
10
- }
11
-
12
- function prefixed(name: string): string {
13
- return `${_collectionPrefix}${name}`;
14
- }
15
-
16
- /** Convert an arbitrary string ID to a deterministic UUID (v5-like SHA-1 based). */
17
- function toUUID(id: string): string {
18
- const hash = createHash('sha1').update(id).digest('hex');
19
- return [
20
- hash.slice(0, 8),
21
- hash.slice(8, 12),
22
- '5' + hash.slice(13, 16),
23
- ((parseInt(hash.slice(16, 18), 16) & 0x3f) | 0x80).toString(16).padStart(2, '0') + hash.slice(18, 20),
24
- hash.slice(20, 32),
25
- ].join('-');
26
- }
27
-
28
- export function initQdrant(url: string): void {
29
- _client = new QdrantClient({ url });
30
- }
31
-
32
- export function getQdrantClient(): QdrantClient {
33
- if (!_client) throw new Error('[Qdrant] Not initialized — call initQdrant first');
34
- return _client;
35
- }
36
-
37
- export function stopQdrant(): void {
38
- _client = null;
39
- }
40
-
41
- export async function ensureQdrantCollection(
42
- name: string,
43
- dimensions: number,
44
- ): Promise<void> {
45
- const client = getQdrantClient();
46
- const collections = await client.getCollections();
47
- const exists = collections.collections.some((c) => c.name === prefixed(name));
48
- if (exists) return;
49
-
50
- await client.createCollection(prefixed(name), {
51
- vectors: {
52
- size: dimensions,
53
- distance: 'Cosine',
54
- on_disk: true,
55
- },
56
- });
57
- }
58
-
59
- export async function deleteQdrantCollection(name: string): Promise<void> {
60
- const client = getQdrantClient();
61
- try {
62
- await client.deleteCollection(prefixed(name));
63
- } catch {
64
- // Collection may not exist
65
- }
66
- }
67
-
68
- export async function upsertVector(
69
- collection: string,
70
- id: string,
71
- vector: number[],
72
- payload: Record<string, unknown> = {},
73
- ): Promise<void> {
74
- const client = getQdrantClient();
75
- await client.upsert(prefixed(collection), {
76
- points: [{ id: toUUID(id), vector, payload: { ...payload, _id: id } }],
77
- });
78
- }
79
-
80
- export async function deleteVector(
81
- collection: string,
82
- id: string,
83
- ): Promise<void> {
84
- const client = getQdrantClient();
85
- await client.delete(prefixed(collection), {
86
- points: [toUUID(id)],
87
- });
88
- }
89
-
90
- export interface VectorSearchResult {
91
- id: string;
92
- score: number;
93
- }
94
-
95
- export async function searchVectors(
96
- collection: string,
97
- vector: number[],
98
- limit: number,
99
- ): Promise<VectorSearchResult[]> {
100
- const client = getQdrantClient();
101
- const results = await client.search(prefixed(collection), {
102
- vector,
103
- limit,
104
- with_payload: true,
105
- });
106
- return results.map((r) => ({
107
- id: String((r.payload as Record<string, unknown>)?._id ?? r.id),
108
- score: r.score,
109
- }));
110
- }
@@ -1,135 +0,0 @@
1
- import {
2
- CopyObjectCommand,
3
- DeleteObjectCommand,
4
- PutObjectCommand,
5
- S3Client,
6
- } from '@aws-sdk/client-s3';
7
- import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
8
-
9
- let _storagePrefix = '';
10
-
11
- export function setStoragePrefix(prefix: string): void {
12
- _storagePrefix = prefix;
13
- }
14
-
15
- function prefixKey(key: string): string {
16
- return _storagePrefix ? `${_storagePrefix}/${key}` : key;
17
- }
18
-
19
- function isDev(): boolean {
20
- return process.env.NODE_ENV !== 'production';
21
- }
22
-
23
- function getS3Client(): S3Client {
24
- // Always connect to localhost:9000 — MinIO in dev, sidecar R2 proxy in prod.
25
- // The sidecar handles R2 auth and bucket isolation transparently.
26
- return new S3Client({
27
- region: 'us-east-1',
28
- endpoint: process.env['MINIO_ENDPOINT'] ?? 'http://localhost:9000',
29
- credentials: { accessKeyId: 'minioadmin', secretAccessKey: 'minioadmin' },
30
- forcePathStyle: true,
31
- requestChecksumCalculation: 'WHEN_REQUIRED',
32
- responseChecksumValidation: 'WHEN_REQUIRED',
33
- });
34
- }
35
-
36
- function getBucketName(bucket: 'public' | 'temp'): string {
37
- return bucket;
38
- }
39
-
40
- function getBaseUrl(bucket: 'public' | 'temp'): string {
41
- if (isDev()) {
42
- const endpoint = process.env['MINIO_ENDPOINT'] ?? 'http://localhost:9000';
43
- return `${endpoint}/${bucket}/`;
44
- }
45
- // In production, the sidecar injects STORAGE_PUBLIC_URL / STORAGE_TEMP_URL
46
- const url = bucket === 'public'
47
- ? process.env['STORAGE_PUBLIC_URL']
48
- : process.env['STORAGE_TEMP_URL'];
49
- if (!url) {
50
- // Fall back to sidecar endpoint if URLs not injected
51
- const endpoint = process.env['MINIO_ENDPOINT'] ?? 'http://localhost:9000';
52
- return `${endpoint}/${bucket}/`;
53
- }
54
- return url.endsWith('/') ? url : url + '/';
55
- }
56
-
57
- export interface StorageClient {
58
- put(
59
- bucket: 'public' | 'temp',
60
- key: string,
61
- body: Buffer,
62
- contentType: string,
63
- ): Promise<string>;
64
- moveToPublic(tempKey: string, destKey: string): Promise<string>;
65
- url(bucket: 'public' | 'temp', key: string): string;
66
- presignedPut(
67
- bucket: 'temp',
68
- key: string,
69
- ): Promise<{ uploadUrl: string; resultUrl: string }>;
70
- }
71
-
72
- /**
73
- * Creates a storage client backed by MinIO (dev) or the sidecar R2 proxy (production).
74
- * Both listen on localhost:9000 — the sidecar handles R2 auth and bucket isolation.
75
- */
76
- export function createStorageClient(): StorageClient {
77
- const s3 = getS3Client();
78
-
79
- return {
80
- async put(bucket, key, body, contentType) {
81
- const effectiveKey = prefixKey(key);
82
- await s3.send(
83
- new PutObjectCommand({
84
- Bucket: getBucketName(bucket),
85
- Key: effectiveKey,
86
- Body: body,
87
- ContentType: contentType,
88
- }),
89
- );
90
- return getBaseUrl(bucket) + effectiveKey;
91
- },
92
-
93
- async moveToPublic(tempKey, destKey) {
94
- const srcBucket = getBucketName('temp');
95
- const destBucket = getBucketName('public');
96
- const effectiveTempKey = prefixKey(tempKey);
97
- const effectiveDestKey = prefixKey(destKey);
98
- await s3.send(
99
- new CopyObjectCommand({
100
- CopySource: `${srcBucket}/${effectiveTempKey}`,
101
- Bucket: destBucket,
102
- Key: effectiveDestKey,
103
- }),
104
- );
105
- await s3.send(
106
- new DeleteObjectCommand({ Bucket: srcBucket, Key: effectiveTempKey }),
107
- );
108
- return getBaseUrl('public') + effectiveDestKey;
109
- },
110
-
111
- url(bucket, key) {
112
- return getBaseUrl(bucket) + prefixKey(key);
113
- },
114
-
115
- async presignedPut(bucket, key) {
116
- const effectiveKey = prefixKey(key);
117
- const command = new PutObjectCommand({
118
- Bucket: getBucketName(bucket),
119
- Key: effectiveKey,
120
- });
121
- const uploadUrl = await getSignedUrl(s3, command, { expiresIn: 3600 });
122
-
123
- // In dev, rewrite the presigned URL to go through the Express proxy
124
- // so the browser doesn't hit a cross-origin S3 endpoint (CORS blocked).
125
- let proxiedUploadUrl = uploadUrl;
126
- if (isDev()) {
127
- const parsed = new URL(uploadUrl);
128
- proxiedUploadUrl = `/_s3${parsed.pathname}${parsed.search}`;
129
- }
130
-
131
- const resultUrl = getBaseUrl(bucket) + effectiveKey;
132
- return { uploadUrl: proxiedUploadUrl, resultUrl };
133
- },
134
- };
135
- }