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,263 +0,0 @@
1
- import type { DBObject, DocFields, FindOptions, MongoUpdateOp, TypedFilter } from '../shared/DB.js';
2
- import { query } from './Postgres.js';
3
- import { translateFilter, translateSort } from './PostgresFilter.js';
4
- import { applyUpdateOp } from './PostgresOperators.js';
5
- import { translatePipeline } from './PostgresPipeline.js';
6
-
7
- /** Merge JSONB data with top-level DB columns into a document */
8
- function toDoc<T extends DBObject>(row: {
9
- _id: string;
10
- data: Record<string, unknown>;
11
- created: Date;
12
- updated: Date;
13
- version: number;
14
- }): T {
15
- return {
16
- ...row.data,
17
- _id: row._id,
18
- created: row.created,
19
- updated: row.updated,
20
- version: row.version,
21
- } as unknown as T;
22
- }
23
-
24
- /** Strip DB metadata fields from a doc to get the JSONB data payload */
25
- function toData(doc: DBObject): Record<string, unknown> {
26
- const { _id: _, version: _v, created: _c, updated: _u, ...data } = doc as unknown as Record<string, unknown>;
27
- return data;
28
- }
29
-
30
- interface FullRow { _id: string; data: Record<string, unknown>; created: Date; updated: Date; version: number }
31
-
32
- export async function pgGetDoc<T extends DBObject>(
33
- collection: string,
34
- id: string,
35
- ): Promise<T | null> {
36
- const result = await query<FullRow>(
37
- `SELECT _id, data, created, updated, version FROM "${collection}" WHERE _id = $1`,
38
- [id],
39
- );
40
- if (result.rows.length === 0) return null;
41
- return toDoc<T>(result.rows[0]);
42
- }
43
-
44
- export async function pgSetDoc<T extends DBObject>(
45
- collection: string,
46
- doc: T,
47
- options?: { skipIfExists?: boolean },
48
- ): Promise<boolean> {
49
- const data = toData(doc);
50
-
51
- if (options?.skipIfExists) {
52
- const result = await query(
53
- `INSERT INTO "${collection}" (_id, data, created, updated, version)
54
- VALUES ($1, $2, $3, $4, $5)
55
- ON CONFLICT (_id) DO NOTHING`,
56
- [doc._id, JSON.stringify(data), doc.created, doc.updated, doc.version],
57
- );
58
- return (result.rowCount ?? 0) > 0;
59
- }
60
-
61
- await query(
62
- `INSERT INTO "${collection}" (_id, data, created, updated, version)
63
- VALUES ($1, $2, $3, $4, $5)
64
- ON CONFLICT (_id) DO UPDATE SET
65
- data = EXCLUDED.data,
66
- updated = EXCLUDED.updated,
67
- version = EXCLUDED.version`,
68
- [doc._id, JSON.stringify(data), doc.created, doc.updated, doc.version],
69
- );
70
- return true;
71
- }
72
-
73
- export async function pgSetDocFields<T extends DBObject>(
74
- collection: string,
75
- id: string,
76
- fields: DocFields<T>,
77
- ): Promise<T> {
78
- const result = await applyUpdateOp(collection, id, { $set: fields as Record<string, unknown> });
79
- if (!result) throw new Error(`[DB] Document not found: ${collection}/${id}`);
80
- return result as unknown as T;
81
- }
82
-
83
- export async function pgSetDocFieldsOrIgnore<T extends DBObject>(
84
- collection: string,
85
- id: string,
86
- fields: DocFields<T>,
87
- ): Promise<T | null> {
88
- const result = await applyUpdateOp(collection, id, { $set: fields as Record<string, unknown> });
89
- return result as unknown as T | null;
90
- }
91
-
92
- export async function pgSetDocFieldsOrCreate<T extends DBObject>(
93
- collection: string,
94
- id: string,
95
- fields: DocFields<T>,
96
- obj: T,
97
- ): Promise<T> {
98
- const existing = await pgSetDocFieldsOrIgnore<T>(collection, id, fields);
99
- if (existing) return existing;
100
- await pgSetDoc(collection, obj);
101
- return obj;
102
- }
103
-
104
- export async function pgSetDocOp<T extends DBObject>(
105
- collection: string,
106
- id: string,
107
- op: MongoUpdateOp<T>,
108
- ): Promise<T> {
109
- const result = await applyUpdateOp(collection, id, op as Record<string, unknown>);
110
- if (!result) throw new Error(`[DB] Document not found: ${collection}/${id}`);
111
- return result as unknown as T;
112
- }
113
-
114
- export async function pgSetDocOpOrIgnore<T extends DBObject>(
115
- collection: string,
116
- id: string,
117
- op: MongoUpdateOp<T>,
118
- ): Promise<T | null> {
119
- const result = await applyUpdateOp(collection, id, op as Record<string, unknown>);
120
- return result as unknown as T | null;
121
- }
122
-
123
- export async function pgDeleteDoc(
124
- collection: string,
125
- id: string,
126
- ): Promise<void> {
127
- await query(`DELETE FROM "${collection}" WHERE _id = $1`, [id]);
128
- }
129
-
130
- export async function pgGetDocs<T extends DBObject>(
131
- collection: string,
132
- filter: Record<string, unknown> = {},
133
- options: {
134
- sort?: Record<string, 1 | -1>;
135
- limit?: number;
136
- skip?: number;
137
- } = {},
138
- ): Promise<T[]> {
139
- const { where, values } = translateFilter(filter);
140
- let sql = `SELECT _id, data, created, updated, version FROM "${collection}"`;
141
- if (where) sql += ` WHERE ${where}`;
142
- if (options.sort) sql += ` ORDER BY ${translateSort(options.sort)}`;
143
- if (options.limit) {
144
- values.push(options.limit);
145
- sql += ` LIMIT $${values.length}`;
146
- }
147
- if (options.skip) {
148
- values.push(options.skip);
149
- sql += ` OFFSET $${values.length}`;
150
- }
151
-
152
- const result = await query<FullRow>(sql, values);
153
- return result.rows.map((row) => toDoc<T>(row));
154
- }
155
-
156
- export async function pgGetQuery<T>(
157
- collection: string,
158
- pipeline: Record<string, unknown>[],
159
- options?: { skip?: number; limit?: number },
160
- ): Promise<T[]> {
161
- const { sql, values } = translatePipeline(collection, pipeline, options);
162
- const result = await query(sql, values);
163
- return result.rows.map((row) => {
164
- if (row.data && row._id !== undefined && row.created !== undefined) {
165
- return toDoc(row as FullRow) as unknown as T;
166
- }
167
- return row as unknown as T;
168
- });
169
- }
170
-
171
- export async function pgGetQueryCount(
172
- collection: string,
173
- pipeline: Record<string, unknown>[],
174
- ): Promise<number> {
175
- const { sql, values } = translatePipeline(collection, [...pipeline, { $count: 'count' }]);
176
- const result = await query<{ count: string }>(sql, values);
177
- return Number(result.rows[0]?.count ?? 0);
178
- }
179
-
180
- export async function pgGetQueryRaw<T>(
181
- collection: string,
182
- pipeline: Record<string, unknown>[],
183
- options?: { skip?: number; limit?: number },
184
- ): Promise<T[]> {
185
- const { sql, values } = translatePipeline(collection, pipeline, options);
186
- const result = await query(sql, values);
187
- return result.rows as unknown as T[];
188
- }
189
-
190
- export async function pgDeleteDocs(
191
- collection: string,
192
- filter: Record<string, unknown>,
193
- ): Promise<string[]> {
194
- const { where, values } = translateFilter(filter);
195
- let sql = `DELETE FROM "${collection}"`;
196
- if (where) sql += ` WHERE ${where}`;
197
- sql += ` RETURNING _id`;
198
-
199
- const result = await query<{ _id: string }>(sql, values);
200
- return result.rows.map((r) => r._id);
201
- }
202
-
203
- // ---------------------------------------------------------------------------
204
- // Typed SQL-native query functions
205
- // ---------------------------------------------------------------------------
206
-
207
- export async function pgFind<T extends DBObject>(
208
- collection: string,
209
- filter: TypedFilter<T> = {} as TypedFilter<T>,
210
- options: FindOptions<T> = {} as FindOptions<T>,
211
- ): Promise<T[]> {
212
- const { where, values } = translateFilter(filter as Record<string, unknown>);
213
- let sql = `SELECT _id, data, created, updated, version FROM "${collection}"`;
214
- if (where) sql += ` WHERE ${where}`;
215
- if (options.sort) sql += ` ORDER BY ${translateSort(options.sort as Record<string, 1 | -1>)}`;
216
- if (options.limit) {
217
- values.push(options.limit);
218
- sql += ` LIMIT $${values.length}`;
219
- }
220
- if (options.skip) {
221
- values.push(options.skip);
222
- sql += ` OFFSET $${values.length}`;
223
- }
224
- const result = await query<FullRow>(sql, values);
225
- return result.rows.map((row) => toDoc<T>(row));
226
- }
227
-
228
- export async function pgFindCount<T>(
229
- collection: string,
230
- filter: TypedFilter<T> = {} as TypedFilter<T>,
231
- ): Promise<number> {
232
- const { where, values } = translateFilter(filter as Record<string, unknown>);
233
- let sql = `SELECT COUNT(*)::int AS count FROM "${collection}"`;
234
- if (where) sql += ` WHERE ${where}`;
235
- const result = await query<{ count: number }>(sql, values);
236
- return result.rows[0]?.count ?? 0;
237
- }
238
-
239
- export async function pgFindRandom<T extends DBObject>(
240
- collection: string,
241
- filter: TypedFilter<T> = {} as TypedFilter<T>,
242
- limit = 100,
243
- ): Promise<T[]> {
244
- const { where, values } = translateFilter(filter as Record<string, unknown>);
245
- let sql = `SELECT _id, data, created, updated, version FROM "${collection}"`;
246
- if (where) sql += ` WHERE ${where}`;
247
- values.push(limit);
248
- sql += ` ORDER BY RANDOM() LIMIT $${values.length}`;
249
- const result = await query<FullRow>(sql, values);
250
- return result.rows.map((row) => toDoc<T>(row));
251
- }
252
-
253
- export async function pgDeleteWhere<T>(
254
- collection: string,
255
- filter: TypedFilter<T>,
256
- ): Promise<string[]> {
257
- const { where, values } = translateFilter(filter as Record<string, unknown>);
258
- let sql = `DELETE FROM "${collection}"`;
259
- if (where) sql += ` WHERE ${where}`;
260
- sql += ` RETURNING _id`;
261
- const result = await query<{ _id: string }>(sql, values);
262
- return result.rows.map((r) => r._id);
263
- }
@@ -1,162 +0,0 @@
1
- /**
2
- * Translates MongoDB-style filter objects into PostgreSQL JSONB WHERE clauses.
3
- *
4
- * Supports: equality, $in, $nin, $gt, $gte, $lt, $lte, $ne, $exists.
5
- * Fields are accessed via JSONB operators (data->>'field' for text, casts for numbers/booleans).
6
- * Dot-notation paths (e.g. 'user.profile.name') are translated to -> chains.
7
- */
8
-
9
- export interface TranslatedFilter {
10
- where: string;
11
- values: unknown[];
12
- }
13
-
14
- /** Fields stored as top-level columns rather than inside the JSONB data column. */
15
- const TOP_LEVEL_COLUMNS = new Set(['_id', 'created', 'updated', 'version']);
16
-
17
- /** Top-level columns that store timestamps (TIMESTAMPTZ). Numeric values must be converted to Date. */
18
- const TIMESTAMP_COLUMNS = new Set(['created', 'updated']);
19
-
20
- /** Convert a value to a Date if it's a numeric epoch for a timestamp column. */
21
- function coerceTimestamp(field: string, value: unknown): unknown {
22
- if (TIMESTAMP_COLUMNS.has(field) && typeof value === 'number') {
23
- return new Date(value);
24
- }
25
- return value;
26
- }
27
-
28
- /** Build the JSONB accessor for a field path. Last segment uses ->> (text extraction). */
29
- function jsonbPath(field: string, asText = true): string {
30
- if (TOP_LEVEL_COLUMNS.has(field)) return `"${field}"`;
31
- const parts = field.split('.');
32
- if (parts.length === 1) {
33
- return asText ? `data->>'${parts[0]}'` : `data->'${parts[0]}'`;
34
- }
35
- const chain = parts.slice(0, -1).map((p) => `'${p}'`).join('->');
36
- const last = parts[parts.length - 1];
37
- return asText ? `data->${chain}->>'${last}'` : `data->${chain}->'${last}'`;
38
- }
39
-
40
- function isOperatorObject(value: unknown): value is Record<string, unknown> {
41
- if (typeof value !== 'object' || value === null || Array.isArray(value)) return false;
42
- return Object.keys(value).some((k) => k.startsWith('$'));
43
- }
44
-
45
- export function translateFilter(filter: Record<string, unknown>): TranslatedFilter {
46
- const clauses: string[] = [];
47
- const values: unknown[] = [];
48
- let paramIdx = 1;
49
-
50
- for (const [field, value] of Object.entries(filter)) {
51
- if (isOperatorObject(value)) {
52
- for (const [op, operand] of Object.entries(value)) {
53
- switch (op) {
54
- case '$in':
55
- // MongoDB: {field: {$in: [vals]}} matches if field is scalar and equals any val,
56
- // OR if field is an array and contains any val.
57
- // Top-level columns (_id, etc.) are plain TEXT — skip the JSONB overlap check.
58
- if (TOP_LEVEL_COLUMNS.has(field)) {
59
- clauses.push(`${jsonbPath(field)} = ANY($${paramIdx})`);
60
- } else {
61
- clauses.push(`(${jsonbPath(field)} = ANY($${paramIdx}) OR ${jsonbPath(field, false)} ?| $${paramIdx})`);
62
- }
63
- values.push(operand);
64
- paramIdx++;
65
- break;
66
- case '$nin':
67
- // Inverse: scalar not in ANY, AND array doesn't overlap
68
- if (TOP_LEVEL_COLUMNS.has(field)) {
69
- clauses.push(`${jsonbPath(field)} != ALL($${paramIdx})`);
70
- } else {
71
- clauses.push(`(${jsonbPath(field)} != ALL($${paramIdx}) AND NOT (${jsonbPath(field, false)} ?| $${paramIdx}))`);
72
- }
73
- values.push(operand);
74
- paramIdx++;
75
- break;
76
- case '$gt': {
77
- const acc = jsonbPath(field);
78
- const cast = TOP_LEVEL_COLUMNS.has(field) ? acc : `(${acc})::numeric`;
79
- clauses.push(`${cast} > $${paramIdx}`);
80
- values.push(coerceTimestamp(field, operand));
81
- paramIdx++;
82
- break;
83
- }
84
- case '$gte': {
85
- const acc = jsonbPath(field);
86
- const cast = TOP_LEVEL_COLUMNS.has(field) ? acc : `(${acc})::numeric`;
87
- clauses.push(`${cast} >= $${paramIdx}`);
88
- values.push(coerceTimestamp(field, operand));
89
- paramIdx++;
90
- break;
91
- }
92
- case '$lt': {
93
- const acc = jsonbPath(field);
94
- const cast = TOP_LEVEL_COLUMNS.has(field) ? acc : `(${acc})::numeric`;
95
- clauses.push(`${cast} < $${paramIdx}`);
96
- values.push(coerceTimestamp(field, operand));
97
- paramIdx++;
98
- break;
99
- }
100
- case '$lte': {
101
- const acc = jsonbPath(field);
102
- const cast = TOP_LEVEL_COLUMNS.has(field) ? acc : `(${acc})::numeric`;
103
- clauses.push(`${cast} <= $${paramIdx}`);
104
- values.push(coerceTimestamp(field, operand));
105
- paramIdx++;
106
- break;
107
- }
108
- case '$ne':
109
- clauses.push(`${jsonbPath(field)} != $${paramIdx}`);
110
- values.push(coerceTimestamp(field, operand));
111
- paramIdx++;
112
- break;
113
- case '$exists':
114
- if (operand) {
115
- clauses.push(`data ? '${field}'`);
116
- } else {
117
- clauses.push(`NOT (data ? '${field}')`);
118
- }
119
- break;
120
- default:
121
- throw new Error(`[DB] Unsupported filter operator: ${op}`);
122
- }
123
- }
124
- } else if (value === null) {
125
- clauses.push(`${jsonbPath(field, false)} IS NULL OR ${jsonbPath(field)} = 'null'`);
126
- } else if (typeof value === 'number') {
127
- if (TIMESTAMP_COLUMNS.has(field)) {
128
- clauses.push(`${jsonbPath(field)} = $${paramIdx}`);
129
- values.push(new Date(value));
130
- } else {
131
- clauses.push(`(${jsonbPath(field)})::numeric = $${paramIdx}`);
132
- values.push(value);
133
- }
134
- paramIdx++;
135
- } else if (typeof value === 'boolean') {
136
- clauses.push(`(${jsonbPath(field)})::boolean = $${paramIdx}`);
137
- values.push(value);
138
- paramIdx++;
139
- } else {
140
- // String or other — use text equality
141
- const accessor = field === '_id' ? '_id' : jsonbPath(field);
142
- clauses.push(`${accessor} = $${paramIdx}`);
143
- values.push(value);
144
- paramIdx++;
145
- }
146
- }
147
-
148
- return {
149
- where: clauses.join(' AND '),
150
- values,
151
- };
152
- }
153
-
154
- /** Translate MongoDB sort to ORDER BY clause. */
155
- export function translateSort(sort: Record<string, 1 | -1>): string {
156
- return Object.entries(sort)
157
- .map(([field, dir]) => {
158
- const accessor = TOP_LEVEL_COLUMNS.has(field) ? `"${field}"` : jsonbPath(field);
159
- return `${accessor} ${dir === 1 ? 'ASC' : 'DESC'}`;
160
- })
161
- .join(', ');
162
- }
@@ -1,136 +0,0 @@
1
- import { query } from './Postgres.js';
2
-
3
- /**
4
- * Translates MongoDB update operators ($set, $unset, $inc, $addToSet, $pull)
5
- * into a single PostgreSQL UPDATE statement using JSONB functions.
6
- *
7
- * Returns the updated document's data, or null if the document doesn't exist.
8
- */
9
-
10
- interface UpdateOp {
11
- $set?: Record<string, unknown>;
12
- $unset?: Record<string, string>;
13
- $inc?: Record<string, number>;
14
- $addToSet?: Record<string, unknown>;
15
- $pull?: Record<string, unknown>;
16
- }
17
-
18
- /** Convert a dot-notation path to a PostgreSQL path array: 'a.b.c' → '{a,b,c}' */
19
- function toPathArray(field: string): string {
20
- return `{${field.split('.').join(',')}}`;
21
- }
22
-
23
- /**
24
- * Build a SQL expression that applies all operators to the data column.
25
- * Each operator wraps the previous expression, building up the transformation.
26
- */
27
- function buildUpdateExpression(op: UpdateOp): { expr: string; values: unknown[]; paramStart: number } {
28
- let expr = 'data';
29
- const values: unknown[] = [];
30
- let paramIdx = 2; // $1 is always the _id
31
-
32
- // $set: jsonb_set for each field
33
- // For nested paths (e.g. 'buttons.botId'), ensure intermediate objects exist.
34
- // PostgreSQL's jsonb_set only creates the leaf key, not intermediate parents.
35
- // If the parent is null or missing, jsonb_set returns NULL which violates NOT NULL.
36
- if (op.$set) {
37
- for (const [field, value] of Object.entries(op.$set)) {
38
- const parts = field.split('.');
39
- if (parts.length > 1) {
40
- // Ensure each intermediate path exists as an object
41
- for (let i = 1; i < parts.length; i++) {
42
- const parentPath = toPathArray(parts.slice(0, i).join('.'));
43
- expr = `jsonb_set(${expr}, '${parentPath}', COALESCE(${expr}#>'${parentPath}', '{}'::jsonb))`;
44
- }
45
- }
46
- expr = `jsonb_set(${expr}, '${toPathArray(field)}', $${paramIdx}::jsonb)`;
47
- values.push(JSON.stringify(value));
48
- paramIdx++;
49
- }
50
- }
51
-
52
- // $unset: remove keys
53
- if (op.$unset) {
54
- for (const field of Object.keys(op.$unset)) {
55
- const parts = field.split('.');
56
- if (parts.length === 1) {
57
- expr = `(${expr}) - '${field}'`;
58
- } else {
59
- // For nested: remove the last key from the parent path.
60
- // COALESCE guards against NULL when the parent path doesn't exist,
61
- // which would cause jsonb_set to return NULL and violate NOT NULL.
62
- const parentPath = toPathArray(parts.slice(0, -1).join('.'));
63
- const lastKey = parts[parts.length - 1];
64
- expr = `jsonb_set(${expr}, '${parentPath}', COALESCE((${expr}#>'${parentPath}') - '${lastKey}', '{}'::jsonb))`;
65
- }
66
- }
67
- }
68
-
69
- // $inc: increment numeric values
70
- if (op.$inc) {
71
- for (const [field, amount] of Object.entries(op.$inc)) {
72
- const path = toPathArray(field);
73
- expr = `jsonb_set(${expr}, '${path}', to_jsonb(COALESCE((${expr}#>>'{${field.split('.').join(',')}}')::numeric, 0) + $${paramIdx}))`;
74
- values.push(amount);
75
- paramIdx++;
76
- }
77
- }
78
-
79
- // $addToSet: add to array if not present
80
- if (op.$addToSet) {
81
- for (const [field, value] of Object.entries(op.$addToSet)) {
82
- const path = toPathArray(field);
83
- const jsonVal = `$${paramIdx}::jsonb`;
84
- expr = `jsonb_set(${expr}, '${path}', CASE WHEN (${expr}#>'${path}') @> ${jsonVal} THEN (${expr}#>'${path}') ELSE (${expr}#>'${path}') || ${jsonVal} END)`;
85
- values.push(JSON.stringify(value));
86
- paramIdx++;
87
- }
88
- }
89
-
90
- // $pull: remove element from array
91
- if (op.$pull) {
92
- for (const [field, value] of Object.entries(op.$pull)) {
93
- const path = toPathArray(field);
94
- const jsonVal = `$${paramIdx}::jsonb`;
95
- expr = `jsonb_set(${expr}, '${path}', (SELECT COALESCE(jsonb_agg(elem), '[]'::jsonb) FROM jsonb_array_elements(${expr}#>'${path}') elem WHERE elem != ${jsonVal}))`;
96
- values.push(JSON.stringify(value));
97
- paramIdx++;
98
- }
99
- }
100
-
101
- return { expr, values, paramStart: paramIdx };
102
- }
103
-
104
- export async function applyUpdateOp(
105
- collection: string,
106
- id: string,
107
- op: UpdateOp,
108
- ): Promise<Record<string, unknown> | null> {
109
- const { expr, values } = buildUpdateExpression(op);
110
-
111
- const sql = `
112
- UPDATE "${collection}"
113
- SET data = ${expr}, updated = now(), version = version + 1
114
- WHERE _id = $1
115
- RETURNING data, _id, created, updated, version
116
- `;
117
-
118
- const result = await query<{
119
- data: Record<string, unknown>;
120
- _id: string;
121
- created: Date;
122
- updated: Date;
123
- version: number;
124
- }>(sql, [id, ...values]);
125
-
126
- if (result.rows.length === 0) return null;
127
-
128
- const row = result.rows[0];
129
- return {
130
- ...row.data,
131
- _id: row._id,
132
- created: row.created,
133
- updated: row.updated,
134
- version: row.version,
135
- };
136
- }