@geekmidas/db 10.0.0-alpha.98 → 10.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/CHANGELOG.md +67 -0
- package/README.md +0 -17
- package/dist/kysely/pagination.cjs +9 -8
- package/dist/kysely/pagination.cjs.map +1 -1
- package/dist/kysely/pagination.d.cts +14 -4
- package/dist/kysely/pagination.d.cts.map +1 -1
- package/dist/kysely/pagination.d.mts +14 -4
- package/dist/kysely/pagination.d.mts.map +1 -1
- package/dist/kysely/pagination.mjs +10 -7
- package/dist/kysely/pagination.mjs.map +1 -1
- package/dist/kysely-8WPSKCZG.cjs +17 -0
- package/dist/kysely-8WPSKCZG.cjs.map +1 -0
- package/dist/kysely-D3eeuId4.d.cts +13 -0
- package/dist/kysely-D3eeuId4.d.cts.map +1 -0
- package/dist/kysely-Dm1w5gAY.d.mts +13 -0
- package/dist/kysely-Dm1w5gAY.d.mts.map +1 -0
- package/dist/kysely-DmfA94RY.mjs +11 -0
- package/dist/kysely-DmfA94RY.mjs.map +1 -0
- package/dist/kysely.cjs +2 -11
- package/dist/kysely.d.cts +2 -9
- package/dist/kysely.d.mts +2 -9
- package/dist/kysely.mjs +2 -10
- package/dist/objection/pagination.cjs +7 -7
- package/dist/objection/pagination.cjs.map +1 -1
- package/dist/objection/pagination.d.cts +14 -4
- package/dist/objection/pagination.d.cts.map +1 -1
- package/dist/objection/pagination.d.mts +14 -4
- package/dist/objection/pagination.d.mts.map +1 -1
- package/dist/objection/pagination.mjs +8 -6
- package/dist/objection/pagination.mjs.map +1 -1
- package/dist/{pagination-CrU89125.d.cts → pagination-BAFX7C1I.d.cts} +4 -14
- package/dist/pagination-BAFX7C1I.d.cts.map +1 -0
- package/dist/pagination-BDLa7Yb_.mjs +37 -0
- package/dist/pagination-BDLa7Yb_.mjs.map +1 -0
- package/dist/pagination-Bdoa4PVj.cjs +55 -0
- package/dist/pagination-Bdoa4PVj.cjs.map +1 -0
- package/dist/{pagination-CrU89125.d.mts → pagination-BziGl-B8.d.mts} +4 -14
- package/dist/pagination-BziGl-B8.d.mts.map +1 -0
- package/dist/pagination.cjs +4 -55
- package/dist/pagination.d.cts +2 -2
- package/dist/pagination.d.mts +2 -2
- package/dist/pagination.mjs +2 -51
- package/dist/rls.cjs +29 -5
- package/dist/rls.cjs.map +1 -1
- package/dist/rls.d.cts +10 -6
- package/dist/rls.d.cts.map +1 -1
- package/dist/rls.d.mts +10 -6
- package/dist/rls.d.mts.map +1 -1
- package/dist/rls.mjs +5 -3
- package/dist/rls.mjs.map +1 -1
- package/package.json +9 -53
- package/src/__tests__/kysely.integration.spec.ts +741 -0
- package/src/__tests__/kysely.spec.ts +394 -0
- package/src/__tests__/rls.spec.ts +716 -0
- package/src/kysely/__tests__/pagination.integration.spec.ts +341 -0
- package/src/kysely/pagination.ts +113 -0
- package/src/kysely.ts +33 -0
- package/src/objection/__tests__/pagination.integration.spec.ts +291 -0
- package/src/objection/pagination.ts +98 -0
- package/src/pagination.ts +49 -0
- package/src/rls.ts +90 -0
- package/tsconfig.json +9 -0
- package/dist/errors.cjs +0 -73
- package/dist/errors.cjs.map +0 -1
- package/dist/errors.d.cts +0 -51
- package/dist/errors.d.cts.map +0 -1
- package/dist/errors.d.mts +0 -51
- package/dist/errors.d.mts.map +0 -1
- package/dist/errors.mjs +0 -69
- package/dist/errors.mjs.map +0 -1
- package/dist/introspect/DataBrowser.cjs +0 -117
- package/dist/introspect/DataBrowser.cjs.map +0 -1
- package/dist/introspect/DataBrowser.d.cts +0 -51
- package/dist/introspect/DataBrowser.d.cts.map +0 -1
- package/dist/introspect/DataBrowser.d.mts +0 -51
- package/dist/introspect/DataBrowser.d.mts.map +0 -1
- package/dist/introspect/DataBrowser.mjs +0 -116
- package/dist/introspect/DataBrowser.mjs.map +0 -1
- package/dist/introspect/errors.cjs +0 -48
- package/dist/introspect/errors.cjs.map +0 -1
- package/dist/introspect/errors.d.cts +0 -27
- package/dist/introspect/errors.d.cts.map +0 -1
- package/dist/introspect/errors.d.mts +0 -27
- package/dist/introspect/errors.d.mts.map +0 -1
- package/dist/introspect/errors.mjs +0 -45
- package/dist/introspect/errors.mjs.map +0 -1
- package/dist/introspect/filtering.cjs +0 -101
- package/dist/introspect/filtering.cjs.map +0 -1
- package/dist/introspect/filtering.d.cts +0 -29
- package/dist/introspect/filtering.d.cts.map +0 -1
- package/dist/introspect/filtering.d.mts +0 -29
- package/dist/introspect/filtering.d.mts.map +0 -1
- package/dist/introspect/filtering.mjs +0 -98
- package/dist/introspect/filtering.mjs.map +0 -1
- package/dist/introspect/handler.cjs +0 -159
- package/dist/introspect/handler.cjs.map +0 -1
- package/dist/introspect/handler.d.cts +0 -43
- package/dist/introspect/handler.d.cts.map +0 -1
- package/dist/introspect/handler.d.mts +0 -43
- package/dist/introspect/handler.d.mts.map +0 -1
- package/dist/introspect/handler.mjs +0 -158
- package/dist/introspect/handler.mjs.map +0 -1
- package/dist/introspect/index.cjs +0 -22
- package/dist/introspect/index.d.cts +0 -8
- package/dist/introspect/index.d.mts +0 -8
- package/dist/introspect/index.mjs +0 -8
- package/dist/introspect/introspection.cjs +0 -149
- package/dist/introspect/introspection.cjs.map +0 -1
- package/dist/introspect/introspection.d.cts +0 -25
- package/dist/introspect/introspection.d.cts.map +0 -1
- package/dist/introspect/introspection.d.mts +0 -25
- package/dist/introspect/introspection.d.mts.map +0 -1
- package/dist/introspect/introspection.mjs +0 -146
- package/dist/introspect/introspection.mjs.map +0 -1
- package/dist/introspect/types.cjs +0 -24
- package/dist/introspect/types.cjs.map +0 -1
- package/dist/introspect/types.d.cts +0 -113
- package/dist/introspect/types.d.cts.map +0 -1
- package/dist/introspect/types.d.mts +0 -113
- package/dist/introspect/types.d.mts.map +0 -1
- package/dist/introspect/types.mjs +0 -23
- package/dist/introspect/types.mjs.map +0 -1
- package/dist/kysely.cjs.map +0 -1
- package/dist/kysely.d.cts.map +0 -1
- package/dist/kysely.d.mts.map +0 -1
- package/dist/kysely.mjs.map +0 -1
- package/dist/pagination-CrU89125.d.cts.map +0 -1
- package/dist/pagination-CrU89125.d.mts.map +0 -1
- package/dist/pagination.cjs.map +0 -1
- package/dist/pagination.mjs.map +0 -1
- package/dist/pg/roles.cjs +0 -177
- package/dist/pg/roles.cjs.map +0 -1
- package/dist/pg/roles.d.cts +0 -90
- package/dist/pg/roles.d.cts.map +0 -1
- package/dist/pg/roles.d.mts +0 -90
- package/dist/pg/roles.d.mts.map +0 -1
- package/dist/pg/roles.mjs +0 -173
- package/dist/pg/roles.mjs.map +0 -1
- package/dist/pg/url.cjs +0 -48
- package/dist/pg/url.cjs.map +0 -1
- package/dist/pg/url.d.cts +0 -45
- package/dist/pg/url.d.cts.map +0 -1
- package/dist/pg/url.d.mts +0 -45
- package/dist/pg/url.d.mts.map +0 -1
- package/dist/pg/url.mjs +0 -47
- package/dist/pg/url.mjs.map +0 -1
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
import knex, { type Knex } from 'knex';
|
|
2
|
+
import { Model } from 'objection';
|
|
3
|
+
import {
|
|
4
|
+
afterAll,
|
|
5
|
+
afterEach,
|
|
6
|
+
beforeAll,
|
|
7
|
+
beforeEach,
|
|
8
|
+
describe,
|
|
9
|
+
expect,
|
|
10
|
+
it,
|
|
11
|
+
} from 'vitest';
|
|
12
|
+
import { TEST_DATABASE_CONFIG } from '../../../../testkit/test/globalSetup';
|
|
13
|
+
import { Direction, paginatedSearch } from '../pagination';
|
|
14
|
+
|
|
15
|
+
class PaginationTestItem extends Model {
|
|
16
|
+
static tableName = 'objection_pagination_test_items';
|
|
17
|
+
|
|
18
|
+
id!: number;
|
|
19
|
+
name!: string;
|
|
20
|
+
category!: string;
|
|
21
|
+
price!: number;
|
|
22
|
+
createdAt!: Date;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
describe('Objection Pagination Integration Tests', () => {
|
|
26
|
+
let db: Knex;
|
|
27
|
+
|
|
28
|
+
beforeAll(async () => {
|
|
29
|
+
db = knex({
|
|
30
|
+
client: 'pg',
|
|
31
|
+
connection: {
|
|
32
|
+
...TEST_DATABASE_CONFIG,
|
|
33
|
+
database: 'postgres',
|
|
34
|
+
},
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
Model.knex(db);
|
|
38
|
+
|
|
39
|
+
await db.schema.createTableIfNotExists(
|
|
40
|
+
'objection_pagination_test_items',
|
|
41
|
+
(table) => {
|
|
42
|
+
table.increments('id').primary();
|
|
43
|
+
table.string('name', 255).notNullable();
|
|
44
|
+
table.string('category', 100).notNullable();
|
|
45
|
+
table.decimal('price', 10, 2).notNullable();
|
|
46
|
+
table.timestamp('created_at').defaultTo(db.fn.now()).notNullable();
|
|
47
|
+
},
|
|
48
|
+
);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
beforeEach(async () => {
|
|
52
|
+
const items = [];
|
|
53
|
+
for (let i = 1; i <= 25; i++) {
|
|
54
|
+
items.push({
|
|
55
|
+
name: `Item ${String(i).padStart(2, '0')}`,
|
|
56
|
+
category:
|
|
57
|
+
i <= 10 ? 'category-a' : i <= 20 ? 'category-b' : 'category-c',
|
|
58
|
+
price: i * 10,
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
await PaginationTestItem.query().insert(items);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
afterEach(async () => {
|
|
65
|
+
await PaginationTestItem.query().delete();
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
afterAll(async () => {
|
|
69
|
+
await db.schema.dropTableIfExists('objection_pagination_test_items');
|
|
70
|
+
await db.destroy();
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
describe('paginatedSearch', () => {
|
|
74
|
+
it('should return first page of results with default settings', async () => {
|
|
75
|
+
const result = await paginatedSearch({
|
|
76
|
+
query: PaginationTestItem.query(),
|
|
77
|
+
limit: 10,
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
expect(result.items).toHaveLength(10);
|
|
81
|
+
expect(result.pagination.total).toBe(25);
|
|
82
|
+
expect(result.pagination.hasMore).toBe(true);
|
|
83
|
+
expect(result.pagination.cursor).toBeDefined();
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('should return model instances when no mapRow is provided', async () => {
|
|
87
|
+
const result = await paginatedSearch({
|
|
88
|
+
query: PaginationTestItem.query(),
|
|
89
|
+
limit: 5,
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
expect(result.items[0]).toBeInstanceOf(PaginationTestItem);
|
|
93
|
+
expect(result.items[0].name).toBeDefined();
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it('should return second page using cursor', async () => {
|
|
97
|
+
const firstPage = await paginatedSearch({
|
|
98
|
+
query: PaginationTestItem.query(),
|
|
99
|
+
limit: 10,
|
|
100
|
+
cursorDirection: Direction.Asc,
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
expect(firstPage.pagination.cursor).toBeDefined();
|
|
104
|
+
|
|
105
|
+
const secondPage = await paginatedSearch({
|
|
106
|
+
query: PaginationTestItem.query(),
|
|
107
|
+
cursor: firstPage.pagination.cursor,
|
|
108
|
+
limit: 10,
|
|
109
|
+
cursorDirection: Direction.Asc,
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
expect(secondPage.items).toHaveLength(10);
|
|
113
|
+
expect(secondPage.pagination.total).toBe(25);
|
|
114
|
+
expect(secondPage.pagination.hasMore).toBe(true);
|
|
115
|
+
|
|
116
|
+
const firstIds = firstPage.items.map((i) => i.id);
|
|
117
|
+
const secondIds = secondPage.items.map((i) => i.id);
|
|
118
|
+
expect(firstIds.every((id) => !secondIds.includes(id))).toBe(true);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it('should return last page with hasMore false', async () => {
|
|
122
|
+
let cursor: string | undefined;
|
|
123
|
+
const pages: PaginationTestItem[][] = [];
|
|
124
|
+
|
|
125
|
+
do {
|
|
126
|
+
const page = await paginatedSearch({
|
|
127
|
+
query: PaginationTestItem.query(),
|
|
128
|
+
cursor,
|
|
129
|
+
limit: 10,
|
|
130
|
+
cursorDirection: Direction.Asc,
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
pages.push(page.items);
|
|
134
|
+
cursor = page.pagination.cursor;
|
|
135
|
+
|
|
136
|
+
if (!page.pagination.hasMore) break;
|
|
137
|
+
} while (cursor);
|
|
138
|
+
|
|
139
|
+
expect(pages).toHaveLength(3);
|
|
140
|
+
expect(pages[0]).toHaveLength(10);
|
|
141
|
+
expect(pages[1]).toHaveLength(10);
|
|
142
|
+
expect(pages[2]).toHaveLength(5);
|
|
143
|
+
|
|
144
|
+
const allItems = pages.flat();
|
|
145
|
+
expect(allItems).toHaveLength(25);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it('should paginate in descending order', async () => {
|
|
149
|
+
const result = await paginatedSearch({
|
|
150
|
+
query: PaginationTestItem.query(),
|
|
151
|
+
limit: 5,
|
|
152
|
+
cursorDirection: Direction.Desc,
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
const ids = result.items.map((i) => i.id);
|
|
156
|
+
for (let i = 1; i < ids.length; i++) {
|
|
157
|
+
expect(ids[i]).toBeLessThan(ids[i - 1]);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const secondPage = await paginatedSearch({
|
|
161
|
+
query: PaginationTestItem.query(),
|
|
162
|
+
cursor: result.pagination.cursor,
|
|
163
|
+
limit: 5,
|
|
164
|
+
cursorDirection: Direction.Desc,
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
const maxSecondPage = Math.max(...secondPage.items.map((i) => i.id));
|
|
168
|
+
const minFirstPage = Math.min(...ids);
|
|
169
|
+
expect(maxSecondPage).toBeLessThan(minFirstPage);
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it('should work with custom cursor field', async () => {
|
|
173
|
+
const result = await paginatedSearch({
|
|
174
|
+
query: PaginationTestItem.query(),
|
|
175
|
+
limit: 5,
|
|
176
|
+
mapRow: (row) => ({ id: row.id, name: row.name, price: row.price }),
|
|
177
|
+
cursorField: 'price',
|
|
178
|
+
cursorDirection: Direction.Asc,
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
const prices = result.items.map((i) => Number(i.price));
|
|
182
|
+
for (let i = 1; i < prices.length; i++) {
|
|
183
|
+
expect(prices[i]).toBeGreaterThan(prices[i - 1]);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
expect(Number(result.pagination.cursor)).toBe(prices[prices.length - 1]);
|
|
187
|
+
|
|
188
|
+
const secondPage = await paginatedSearch({
|
|
189
|
+
query: PaginationTestItem.query(),
|
|
190
|
+
cursor: result.pagination.cursor,
|
|
191
|
+
limit: 5,
|
|
192
|
+
mapRow: (row) => ({ id: row.id, name: row.name, price: row.price }),
|
|
193
|
+
cursorField: 'price',
|
|
194
|
+
cursorDirection: Direction.Asc,
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
const minSecondPage = Math.min(
|
|
198
|
+
...secondPage.items.map((i) => Number(i.price)),
|
|
199
|
+
);
|
|
200
|
+
expect(minSecondPage).toBeGreaterThan(prices[prices.length - 1]);
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
it('should use default limit of 20', async () => {
|
|
204
|
+
const result = await paginatedSearch({
|
|
205
|
+
query: PaginationTestItem.query(),
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
expect(result.items).toHaveLength(20);
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
it('should work with filtered queries', async () => {
|
|
212
|
+
const result = await paginatedSearch({
|
|
213
|
+
query: PaginationTestItem.query().where('category', 'category-a'),
|
|
214
|
+
limit: 5,
|
|
215
|
+
mapRow: (row) => ({
|
|
216
|
+
id: row.id,
|
|
217
|
+
name: row.name,
|
|
218
|
+
category: row.category,
|
|
219
|
+
}),
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
expect(result.pagination.total).toBe(10);
|
|
223
|
+
expect(result.items).toHaveLength(5);
|
|
224
|
+
for (const item of result.items) {
|
|
225
|
+
expect(item.category).toBe('category-a');
|
|
226
|
+
}
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
it('should handle empty result set', async () => {
|
|
230
|
+
const result = await paginatedSearch({
|
|
231
|
+
query: PaginationTestItem.query().where('category', 'nonexistent'),
|
|
232
|
+
limit: 10,
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
expect(result.items).toHaveLength(0);
|
|
236
|
+
expect(result.pagination.total).toBe(0);
|
|
237
|
+
expect(result.pagination.hasMore).toBe(false);
|
|
238
|
+
expect(result.pagination.cursor).toBeUndefined();
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
it('should work with mapRow transformation', async () => {
|
|
242
|
+
const result = await paginatedSearch({
|
|
243
|
+
query: PaginationTestItem.query(),
|
|
244
|
+
limit: 5,
|
|
245
|
+
mapRow: (row) => ({
|
|
246
|
+
id: row.id,
|
|
247
|
+
displayName: `Product: ${row.name}`,
|
|
248
|
+
priceFormatted: `$${Number(row.price).toFixed(2)}`,
|
|
249
|
+
}),
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
expect(result.items).toHaveLength(5);
|
|
253
|
+
for (const item of result.items) {
|
|
254
|
+
expect(item.displayName).toMatch(/^Product: Item \d+$/);
|
|
255
|
+
expect(item.priceFormatted).toMatch(/^\$\d+\.\d{2}$/);
|
|
256
|
+
}
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
it('should work with eager loading', async () => {
|
|
260
|
+
// withGraphFetched doesn't error even without relations defined;
|
|
261
|
+
// this validates the query builder composition works end-to-end
|
|
262
|
+
const result = await paginatedSearch({
|
|
263
|
+
query: PaginationTestItem.query().select('id', 'name'),
|
|
264
|
+
limit: 5,
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
expect(result.items).toHaveLength(5);
|
|
268
|
+
expect(result.items[0].id).toBeDefined();
|
|
269
|
+
expect(result.items[0].name).toBeDefined();
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
it('should work with async mapRow function', async () => {
|
|
273
|
+
const result = await paginatedSearch({
|
|
274
|
+
query: PaginationTestItem.query(),
|
|
275
|
+
limit: 5,
|
|
276
|
+
mapRow: async (row) => {
|
|
277
|
+
await new Promise((resolve) => setTimeout(resolve, 1));
|
|
278
|
+
return {
|
|
279
|
+
id: row.id,
|
|
280
|
+
displayName: `Product: ${row.name}`,
|
|
281
|
+
};
|
|
282
|
+
},
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
expect(result.items).toHaveLength(5);
|
|
286
|
+
for (const item of result.items) {
|
|
287
|
+
expect(item.displayName).toMatch(/^Product: Item \d+$/);
|
|
288
|
+
}
|
|
289
|
+
});
|
|
290
|
+
});
|
|
291
|
+
});
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import type { Model, QueryBuilder } from 'objection';
|
|
2
|
+
import {
|
|
3
|
+
Direction,
|
|
4
|
+
decodeCursor,
|
|
5
|
+
encodeCursor,
|
|
6
|
+
type PaginationResult,
|
|
7
|
+
} from '../pagination';
|
|
8
|
+
|
|
9
|
+
export { Direction, decodeCursor, encodeCursor, type PaginationResult };
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Options for paginated search with Objection.js models.
|
|
13
|
+
*/
|
|
14
|
+
export interface ObjectionPaginatedSearchOptions<
|
|
15
|
+
TModel extends Model,
|
|
16
|
+
TMapRow extends (row: TModel) => unknown = (row: TModel) => TModel,
|
|
17
|
+
> {
|
|
18
|
+
/** The Objection QueryBuilder to paginate (e.g. User.query(trx).where(...)) */
|
|
19
|
+
query: QueryBuilder<TModel>;
|
|
20
|
+
/** Cursor value for pagination (value of cursorField from previous page) */
|
|
21
|
+
cursor?: string;
|
|
22
|
+
/** Maximum number of items per page (default: 20) */
|
|
23
|
+
limit?: number;
|
|
24
|
+
/** Function to transform each row to the output format (default: identity) */
|
|
25
|
+
mapRow?: TMapRow;
|
|
26
|
+
/** Field to use for cursor pagination (default: 'id') */
|
|
27
|
+
cursorField?: string;
|
|
28
|
+
/** Sort direction for cursor field (default: Direction.Asc) */
|
|
29
|
+
cursorDirection?: Direction;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Cursor-based paginated search for Objection.js models.
|
|
34
|
+
*
|
|
35
|
+
* Accepts a pre-built QueryBuilder so callers can apply filters,
|
|
36
|
+
* eager loading, and scopes before pagination is layered on top.
|
|
37
|
+
*
|
|
38
|
+
* @example
|
|
39
|
+
* ```typescript
|
|
40
|
+
* const result = await paginatedSearch({
|
|
41
|
+
* query: User.query(trx).where('orgId', orgId).withGraphFetched('roles'),
|
|
42
|
+
* cursor: previousCursor,
|
|
43
|
+
* limit: 20,
|
|
44
|
+
* cursorField: 'createdAt',
|
|
45
|
+
* cursorDirection: Direction.Desc,
|
|
46
|
+
* });
|
|
47
|
+
* ```
|
|
48
|
+
*/
|
|
49
|
+
export async function paginatedSearch<
|
|
50
|
+
TModel extends Model,
|
|
51
|
+
TMapRow extends (row: TModel) => unknown = (row: TModel) => TModel,
|
|
52
|
+
>({
|
|
53
|
+
query,
|
|
54
|
+
cursor,
|
|
55
|
+
limit = 20,
|
|
56
|
+
mapRow,
|
|
57
|
+
cursorField = 'id',
|
|
58
|
+
cursorDirection = Direction.Asc,
|
|
59
|
+
}: ObjectionPaginatedSearchOptions<TModel, TMapRow>): Promise<
|
|
60
|
+
PaginationResult<Awaited<ReturnType<TMapRow>>>
|
|
61
|
+
> {
|
|
62
|
+
// Get total count (without cursor filtering)
|
|
63
|
+
const total = await query.resultSize();
|
|
64
|
+
|
|
65
|
+
// Apply cursor if provided
|
|
66
|
+
let paginatedQuery = query.clone();
|
|
67
|
+
if (cursor) {
|
|
68
|
+
const operator = cursorDirection === Direction.Asc ? '>' : '<';
|
|
69
|
+
paginatedQuery = paginatedQuery.where(cursorField, operator, cursor);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Fetch one extra to determine if there are more results
|
|
73
|
+
const data = await paginatedQuery
|
|
74
|
+
.orderBy(cursorField, cursorDirection)
|
|
75
|
+
.limit(limit + 1);
|
|
76
|
+
|
|
77
|
+
const hasMore = data.length > limit;
|
|
78
|
+
const rows = hasMore ? data.slice(0, limit) : data;
|
|
79
|
+
const lastRow = rows[rows.length - 1];
|
|
80
|
+
const nextCursor =
|
|
81
|
+
hasMore && lastRow
|
|
82
|
+
? String((lastRow as Record<string, unknown>)[cursorField])
|
|
83
|
+
: undefined;
|
|
84
|
+
|
|
85
|
+
const mapper = mapRow ?? ((row: TModel) => row);
|
|
86
|
+
const items = (await Promise.all(rows.map(mapper))) as Awaited<
|
|
87
|
+
ReturnType<TMapRow>
|
|
88
|
+
>[];
|
|
89
|
+
|
|
90
|
+
return {
|
|
91
|
+
items,
|
|
92
|
+
pagination: {
|
|
93
|
+
total,
|
|
94
|
+
hasMore,
|
|
95
|
+
cursor: nextCursor,
|
|
96
|
+
},
|
|
97
|
+
};
|
|
98
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sort direction for cursor-based pagination.
|
|
3
|
+
*/
|
|
4
|
+
export enum Direction {
|
|
5
|
+
Asc = 'asc',
|
|
6
|
+
Desc = 'desc',
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Result of a paginated query.
|
|
11
|
+
*/
|
|
12
|
+
export interface PaginationResult<TItem> {
|
|
13
|
+
items: TItem[];
|
|
14
|
+
pagination: {
|
|
15
|
+
total: number;
|
|
16
|
+
hasMore: boolean;
|
|
17
|
+
cursor?: string;
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Encode a cursor value for safe URL transmission.
|
|
23
|
+
* Supports various types: string, number, Date, etc.
|
|
24
|
+
*/
|
|
25
|
+
export function encodeCursor(value: unknown): string {
|
|
26
|
+
const payload = {
|
|
27
|
+
v: value instanceof Date ? value.toISOString() : value,
|
|
28
|
+
t: value instanceof Date ? 'date' : typeof value,
|
|
29
|
+
};
|
|
30
|
+
return Buffer.from(JSON.stringify(payload)).toString('base64url');
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Decode a cursor string back to its original value.
|
|
35
|
+
*/
|
|
36
|
+
export function decodeCursor(cursor: string): unknown {
|
|
37
|
+
try {
|
|
38
|
+
const json = Buffer.from(cursor, 'base64url').toString('utf-8');
|
|
39
|
+
const payload = JSON.parse(json);
|
|
40
|
+
|
|
41
|
+
if (payload.t === 'date') {
|
|
42
|
+
return new Date(payload.v);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return payload.v;
|
|
46
|
+
} catch {
|
|
47
|
+
throw new Error('Invalid cursor format');
|
|
48
|
+
}
|
|
49
|
+
}
|
package/src/rls.ts
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import type { Transaction } from 'kysely';
|
|
2
|
+
import { sql } from 'kysely';
|
|
3
|
+
import {
|
|
4
|
+
type DatabaseConnection,
|
|
5
|
+
type TransactionSettings,
|
|
6
|
+
withTransaction,
|
|
7
|
+
} from './kysely';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* RLS context - key-value pairs to set as PostgreSQL session variables.
|
|
11
|
+
* Keys become `prefix.key` (e.g., `app.user_id`).
|
|
12
|
+
*/
|
|
13
|
+
export interface RlsContext {
|
|
14
|
+
[key: string]: string | number | boolean | null | undefined;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Options for withRlsContext function.
|
|
19
|
+
*/
|
|
20
|
+
export interface WithRlsContextOptions {
|
|
21
|
+
/** Prefix for PostgreSQL session variables (default: 'app') */
|
|
22
|
+
prefix?: string;
|
|
23
|
+
/** Transaction settings (isolation level) */
|
|
24
|
+
settings?: TransactionSettings;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Execute a callback within a transaction with RLS context variables set.
|
|
29
|
+
*
|
|
30
|
+
* Sets PostgreSQL session variables using `SET LOCAL` which scopes them to the
|
|
31
|
+
* current transaction. Variables are automatically cleared when the transaction
|
|
32
|
+
* ends (commit or rollback).
|
|
33
|
+
*
|
|
34
|
+
* @example
|
|
35
|
+
* ```ts
|
|
36
|
+
* await withRlsContext(
|
|
37
|
+
* db,
|
|
38
|
+
* { user_id: session.userId, tenant_id: session.tenantId },
|
|
39
|
+
* async (trx) => {
|
|
40
|
+
* // RLS policies can now use current_setting('app.user_id')
|
|
41
|
+
* return trx.selectFrom('orders').selectAll().execute();
|
|
42
|
+
* }
|
|
43
|
+
* );
|
|
44
|
+
* ```
|
|
45
|
+
*
|
|
46
|
+
* @param db - Database connection (Kysely, Transaction, or ControlledTransaction)
|
|
47
|
+
* @param context - Key-value pairs to set as session variables
|
|
48
|
+
* @param callback - Function to execute within the RLS context
|
|
49
|
+
* @param options - Optional prefix and transaction settings
|
|
50
|
+
*/
|
|
51
|
+
export async function withRlsContext<DB, T>(
|
|
52
|
+
db: DatabaseConnection<DB>,
|
|
53
|
+
context: RlsContext,
|
|
54
|
+
callback: (trx: Transaction<DB>) => Promise<T>,
|
|
55
|
+
options?: WithRlsContextOptions,
|
|
56
|
+
): Promise<T> {
|
|
57
|
+
const prefix = options?.prefix ?? 'app';
|
|
58
|
+
|
|
59
|
+
return withTransaction(
|
|
60
|
+
db,
|
|
61
|
+
async (trx) => {
|
|
62
|
+
// Set each context variable using SET LOCAL (scoped to transaction)
|
|
63
|
+
for (const [key, value] of Object.entries(context)) {
|
|
64
|
+
if (value === null || value === undefined) continue;
|
|
65
|
+
|
|
66
|
+
const settingName = `${prefix}.${key}`;
|
|
67
|
+
const settingValue = String(value);
|
|
68
|
+
|
|
69
|
+
// Use raw SQL for SET LOCAL with proper escaping
|
|
70
|
+
// The setting name is an identifier, value is a string literal
|
|
71
|
+
await sql`SELECT set_config(${settingName}, ${settingValue}, true)`.execute(
|
|
72
|
+
trx,
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return callback(trx);
|
|
77
|
+
},
|
|
78
|
+
options?.settings,
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Bypass marker symbol for explicitly skipping RLS context.
|
|
84
|
+
*/
|
|
85
|
+
export const RLS_BYPASS = Symbol.for('geekmidas.rls.bypass');
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Type for RLS bypass marker.
|
|
89
|
+
*/
|
|
90
|
+
export type RlsBypass = typeof RLS_BYPASS;
|
package/tsconfig.json
ADDED
package/dist/errors.cjs
DELETED
|
@@ -1,73 +0,0 @@
|
|
|
1
|
-
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
2
|
-
//#region src/errors.ts
|
|
3
|
-
/**
|
|
4
|
-
* Database URL errors and redaction — engine-neutral.
|
|
5
|
-
*
|
|
6
|
-
* Nothing here is Postgres-specific: every engine addresses itself as a URL
|
|
7
|
-
* carrying a password, and "must name a host" holds for all of them. The parts
|
|
8
|
-
* that differ — libpq's `options=-c search_path=…`, which no other engine
|
|
9
|
-
* accepts — live under the driver they belong to.
|
|
10
|
-
*
|
|
11
|
-
* Messages state the rule, which is constant; the offending value is a field.
|
|
12
|
-
* An interpolated message cannot be matched on and carries user input into
|
|
13
|
-
* every log line that touches it.
|
|
14
|
-
*/
|
|
15
|
-
/**
|
|
16
|
-
* Replace the password in a connection string with `***`.
|
|
17
|
-
*
|
|
18
|
-
* A connection string is a secret, and the places it is most likely to be
|
|
19
|
-
* written down — a log line, an error, a crash report shipped to a third party
|
|
20
|
-
* — are the places nobody is reading carefully. This exists so that redacting
|
|
21
|
-
* is the easy thing to do at each of them.
|
|
22
|
-
*
|
|
23
|
-
* Safe on input that is not a URL: it returns a constant rather than throwing,
|
|
24
|
-
* because this runs on paths that must not fail. Anything unparseable is
|
|
25
|
-
* treated as potentially secret and withheld entirely rather than echoed.
|
|
26
|
-
*/
|
|
27
|
-
function redactDatabaseUrl(url) {
|
|
28
|
-
try {
|
|
29
|
-
const parsed = new URL(url);
|
|
30
|
-
if (!parsed.password) return url;
|
|
31
|
-
parsed.password = "***";
|
|
32
|
-
return parsed.toString();
|
|
33
|
-
} catch {
|
|
34
|
-
return "<unparseable url>";
|
|
35
|
-
}
|
|
36
|
-
}
|
|
37
|
-
/**
|
|
38
|
-
* Base for an address that cannot become a connection string.
|
|
39
|
-
*
|
|
40
|
-
* Deliberately carries no URL field: these are thrown while *composing* one, so
|
|
41
|
-
* there is no URL yet — only the parts, which include the password.
|
|
42
|
-
*/
|
|
43
|
-
var DatabaseAddressError = class extends Error {
|
|
44
|
-
constructor(message) {
|
|
45
|
-
super(message);
|
|
46
|
-
this.name = new.target.name;
|
|
47
|
-
}
|
|
48
|
-
};
|
|
49
|
-
/** An address with no host to connect to. */
|
|
50
|
-
var MissingDatabaseHost = class extends DatabaseAddressError {
|
|
51
|
-
constructor() {
|
|
52
|
-
super("A database address must name a host");
|
|
53
|
-
}
|
|
54
|
-
};
|
|
55
|
-
/**
|
|
56
|
-
* An address with no database name.
|
|
57
|
-
*
|
|
58
|
-
* Postgres silently falls back to the connecting role's name, so the app
|
|
59
|
-
* connects successfully to the wrong database and fails later with confusing
|
|
60
|
-
* "relation does not exist" errors. Rejecting it here is much cheaper.
|
|
61
|
-
*/
|
|
62
|
-
var MissingDatabaseName = class extends DatabaseAddressError {
|
|
63
|
-
constructor() {
|
|
64
|
-
super("A database address must name a database");
|
|
65
|
-
}
|
|
66
|
-
};
|
|
67
|
-
//#endregion
|
|
68
|
-
exports.DatabaseAddressError = DatabaseAddressError;
|
|
69
|
-
exports.MissingDatabaseHost = MissingDatabaseHost;
|
|
70
|
-
exports.MissingDatabaseName = MissingDatabaseName;
|
|
71
|
-
exports.redactDatabaseUrl = redactDatabaseUrl;
|
|
72
|
-
|
|
73
|
-
//# sourceMappingURL=errors.cjs.map
|
package/dist/errors.cjs.map
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"errors.cjs","names":[],"sources":["../src/errors.ts"],"sourcesContent":["/**\n * Database URL errors and redaction — engine-neutral.\n *\n * Nothing here is Postgres-specific: every engine addresses itself as a URL\n * carrying a password, and \"must name a host\" holds for all of them. The parts\n * that differ — libpq's `options=-c search_path=…`, which no other engine\n * accepts — live under the driver they belong to.\n *\n * Messages state the rule, which is constant; the offending value is a field.\n * An interpolated message cannot be matched on and carries user input into\n * every log line that touches it.\n */\n\n/**\n * Replace the password in a connection string with `***`.\n *\n * A connection string is a secret, and the places it is most likely to be\n * written down — a log line, an error, a crash report shipped to a third party\n * — are the places nobody is reading carefully. This exists so that redacting\n * is the easy thing to do at each of them.\n *\n * Safe on input that is not a URL: it returns a constant rather than throwing,\n * because this runs on paths that must not fail. Anything unparseable is\n * treated as potentially secret and withheld entirely rather than echoed.\n */\nexport function redactDatabaseUrl(url: string): string {\n\ttry {\n\t\tconst parsed = new URL(url);\n\t\tif (!parsed.password) return url;\n\t\tparsed.password = '***';\n\t\treturn parsed.toString();\n\t} catch {\n\t\treturn '<unparseable url>';\n\t}\n}\n\n/**\n * Base for an address that cannot become a connection string.\n *\n * Deliberately carries no URL field: these are thrown while *composing* one, so\n * there is no URL yet — only the parts, which include the password.\n */\nexport abstract class DatabaseAddressError extends Error {\n\tconstructor(message: string) {\n\t\tsuper(message);\n\t\tthis.name = new.target.name;\n\t}\n}\n\n/** An address with no host to connect to. */\nexport class MissingDatabaseHost extends DatabaseAddressError {\n\tconstructor() {\n\t\tsuper('A database address must name a host');\n\t}\n}\n\n/**\n * An address with no database name.\n *\n * Postgres silently falls back to the connecting role's name, so the app\n * connects successfully to the wrong database and fails later with confusing\n * \"relation does not exist\" errors. Rejecting it here is much cheaper.\n */\nexport class MissingDatabaseName extends DatabaseAddressError {\n\tconstructor() {\n\t\tsuper('A database address must name a database');\n\t}\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;AAyBA,SAAgB,kBAAkB,KAAqB;CACtD,IAAI;EACH,MAAM,SAAS,IAAI,IAAI,GAAG;EAC1B,IAAI,CAAC,OAAO,UAAU,OAAO;EAC7B,OAAO,WAAW;EAClB,OAAO,OAAO,SAAS;CACxB,QAAQ;EACP,OAAO;CACR;AACD;;;;;;;AAQA,IAAsB,uBAAtB,cAAmD,MAAM;CACxD,YAAY,SAAiB;EAC5B,MAAM,OAAO;EACb,KAAK,OAAO,WAAW;CACxB;AACD;;AAGA,IAAa,sBAAb,cAAyC,qBAAqB;CAC7D,cAAc;EACb,MAAM,qCAAqC;CAC5C;AACD;;;;;;;;AASA,IAAa,sBAAb,cAAyC,qBAAqB;CAC7D,cAAc;EACb,MAAM,yCAAyC;CAChD;AACD"}
|
package/dist/errors.d.cts
DELETED
|
@@ -1,51 +0,0 @@
|
|
|
1
|
-
//#region src/errors.d.ts
|
|
2
|
-
/**
|
|
3
|
-
* Database URL errors and redaction — engine-neutral.
|
|
4
|
-
*
|
|
5
|
-
* Nothing here is Postgres-specific: every engine addresses itself as a URL
|
|
6
|
-
* carrying a password, and "must name a host" holds for all of them. The parts
|
|
7
|
-
* that differ — libpq's `options=-c search_path=…`, which no other engine
|
|
8
|
-
* accepts — live under the driver they belong to.
|
|
9
|
-
*
|
|
10
|
-
* Messages state the rule, which is constant; the offending value is a field.
|
|
11
|
-
* An interpolated message cannot be matched on and carries user input into
|
|
12
|
-
* every log line that touches it.
|
|
13
|
-
*/
|
|
14
|
-
/**
|
|
15
|
-
* Replace the password in a connection string with `***`.
|
|
16
|
-
*
|
|
17
|
-
* A connection string is a secret, and the places it is most likely to be
|
|
18
|
-
* written down — a log line, an error, a crash report shipped to a third party
|
|
19
|
-
* — are the places nobody is reading carefully. This exists so that redacting
|
|
20
|
-
* is the easy thing to do at each of them.
|
|
21
|
-
*
|
|
22
|
-
* Safe on input that is not a URL: it returns a constant rather than throwing,
|
|
23
|
-
* because this runs on paths that must not fail. Anything unparseable is
|
|
24
|
-
* treated as potentially secret and withheld entirely rather than echoed.
|
|
25
|
-
*/
|
|
26
|
-
export declare function redactDatabaseUrl(url: string): string;
|
|
27
|
-
/**
|
|
28
|
-
* Base for an address that cannot become a connection string.
|
|
29
|
-
*
|
|
30
|
-
* Deliberately carries no URL field: these are thrown while *composing* one, so
|
|
31
|
-
* there is no URL yet — only the parts, which include the password.
|
|
32
|
-
*/
|
|
33
|
-
export declare abstract class DatabaseAddressError extends Error {
|
|
34
|
-
constructor(message: string);
|
|
35
|
-
}
|
|
36
|
-
/** An address with no host to connect to. */
|
|
37
|
-
export declare class MissingDatabaseHost extends DatabaseAddressError {
|
|
38
|
-
constructor();
|
|
39
|
-
}
|
|
40
|
-
/**
|
|
41
|
-
* An address with no database name.
|
|
42
|
-
*
|
|
43
|
-
* Postgres silently falls back to the connecting role's name, so the app
|
|
44
|
-
* connects successfully to the wrong database and fails later with confusing
|
|
45
|
-
* "relation does not exist" errors. Rejecting it here is much cheaper.
|
|
46
|
-
*/
|
|
47
|
-
export declare class MissingDatabaseName extends DatabaseAddressError {
|
|
48
|
-
constructor();
|
|
49
|
-
}
|
|
50
|
-
//#endregion
|
|
51
|
-
//# sourceMappingURL=errors.d.cts.map
|
package/dist/errors.d.cts.map
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"errors.d.cts","names":[],"sources":["../src/errors.ts"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;wBAyBgB,kBAAkB;;;;;;;8BAiBZ,6BAA6B;EAClD,YAAY;;;qBAOA,4BAA4B;EACxC;;;;;;;;;qBAYY,4BAA4B;EACxC"}
|