@geekmidas/db 1.0.0 → 1.0.2
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 +12 -0
- package/dist/kysely/pagination.cjs +15 -41
- package/dist/kysely/pagination.cjs.map +1 -1
- package/dist/kysely/pagination.d.cts +12 -34
- package/dist/kysely/pagination.d.cts.map +1 -1
- package/dist/kysely/pagination.d.mts +12 -34
- package/dist/kysely/pagination.d.mts.map +1 -1
- package/dist/kysely/pagination.mjs +11 -36
- package/dist/kysely/pagination.mjs.map +1 -1
- package/dist/{kysely-DUdsB0BP.d.mts → kysely-Dm1w5gAY.d.mts} +1 -1
- package/dist/{kysely-DUdsB0BP.d.mts.map → kysely-Dm1w5gAY.d.mts.map} +1 -1
- package/dist/kysely.d.mts +1 -1
- package/dist/objection/pagination.cjs +50 -0
- package/dist/objection/pagination.cjs.map +1 -0
- package/dist/objection/pagination.d.cts +51 -0
- package/dist/objection/pagination.d.cts.map +1 -0
- package/dist/objection/pagination.d.mts +51 -0
- package/dist/objection/pagination.d.mts.map +1 -0
- package/dist/objection/pagination.mjs +47 -0
- package/dist/objection/pagination.mjs.map +1 -0
- package/dist/pagination-BAFX7C1I.d.cts +32 -0
- 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-BziGl-B8.d.mts +32 -0
- package/dist/pagination-BziGl-B8.d.mts.map +1 -0
- package/dist/pagination.cjs +5 -0
- package/dist/pagination.d.cts +2 -0
- package/dist/pagination.d.mts +2 -0
- package/dist/pagination.mjs +3 -0
- package/dist/rls.d.mts +1 -1
- package/package.json +45 -10
- package/src/__tests__/rls.spec.ts +272 -0
- package/src/kysely/pagination.ts +20 -57
- package/src/objection/__tests__/pagination.integration.spec.ts +291 -0
- package/src/objection/pagination.ts +98 -0
- package/src/pagination.ts +49 -0
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { Direction, decodeCursor, encodeCursor } from "../pagination-BDLa7Yb_.mjs";
|
|
2
|
+
|
|
3
|
+
//#region src/objection/pagination.ts
|
|
4
|
+
/**
|
|
5
|
+
* Cursor-based paginated search for Objection.js models.
|
|
6
|
+
*
|
|
7
|
+
* Accepts a pre-built QueryBuilder so callers can apply filters,
|
|
8
|
+
* eager loading, and scopes before pagination is layered on top.
|
|
9
|
+
*
|
|
10
|
+
* @example
|
|
11
|
+
* ```typescript
|
|
12
|
+
* const result = await paginatedSearch({
|
|
13
|
+
* query: User.query(trx).where('orgId', orgId).withGraphFetched('roles'),
|
|
14
|
+
* cursor: previousCursor,
|
|
15
|
+
* limit: 20,
|
|
16
|
+
* cursorField: 'createdAt',
|
|
17
|
+
* cursorDirection: Direction.Desc,
|
|
18
|
+
* });
|
|
19
|
+
* ```
|
|
20
|
+
*/
|
|
21
|
+
async function paginatedSearch({ query, cursor, limit = 20, mapRow, cursorField = "id", cursorDirection = Direction.Asc }) {
|
|
22
|
+
const total = await query.resultSize();
|
|
23
|
+
let paginatedQuery = query.clone();
|
|
24
|
+
if (cursor) {
|
|
25
|
+
const operator = cursorDirection === Direction.Asc ? ">" : "<";
|
|
26
|
+
paginatedQuery = paginatedQuery.where(cursorField, operator, cursor);
|
|
27
|
+
}
|
|
28
|
+
const data = await paginatedQuery.orderBy(cursorField, cursorDirection).limit(limit + 1);
|
|
29
|
+
const hasMore = data.length > limit;
|
|
30
|
+
const rows = hasMore ? data.slice(0, limit) : data;
|
|
31
|
+
const lastRow = rows[rows.length - 1];
|
|
32
|
+
const nextCursor = hasMore && lastRow ? String(lastRow[cursorField]) : void 0;
|
|
33
|
+
const mapper = mapRow ?? ((row) => row);
|
|
34
|
+
const items = await Promise.all(rows.map(mapper));
|
|
35
|
+
return {
|
|
36
|
+
items,
|
|
37
|
+
pagination: {
|
|
38
|
+
total,
|
|
39
|
+
hasMore,
|
|
40
|
+
cursor: nextCursor
|
|
41
|
+
}
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
//#endregion
|
|
46
|
+
export { Direction, decodeCursor, encodeCursor, paginatedSearch };
|
|
47
|
+
//# sourceMappingURL=pagination.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"pagination.mjs","names":["row: TModel"],"sources":["../../src/objection/pagination.ts"],"sourcesContent":["import type { Model, QueryBuilder } from 'objection';\nimport {\n\tDirection,\n\tdecodeCursor,\n\tencodeCursor,\n\ttype PaginationResult,\n} from '../pagination';\n\nexport { Direction, decodeCursor, encodeCursor, type PaginationResult };\n\n/**\n * Options for paginated search with Objection.js models.\n */\nexport interface ObjectionPaginatedSearchOptions<\n\tTModel extends Model,\n\tTMapRow extends (row: TModel) => unknown = (row: TModel) => TModel,\n> {\n\t/** The Objection QueryBuilder to paginate (e.g. User.query(trx).where(...)) */\n\tquery: QueryBuilder<TModel>;\n\t/** Cursor value for pagination (value of cursorField from previous page) */\n\tcursor?: string;\n\t/** Maximum number of items per page (default: 20) */\n\tlimit?: number;\n\t/** Function to transform each row to the output format (default: identity) */\n\tmapRow?: TMapRow;\n\t/** Field to use for cursor pagination (default: 'id') */\n\tcursorField?: string;\n\t/** Sort direction for cursor field (default: Direction.Asc) */\n\tcursorDirection?: Direction;\n}\n\n/**\n * Cursor-based paginated search for Objection.js models.\n *\n * Accepts a pre-built QueryBuilder so callers can apply filters,\n * eager loading, and scopes before pagination is layered on top.\n *\n * @example\n * ```typescript\n * const result = await paginatedSearch({\n * query: User.query(trx).where('orgId', orgId).withGraphFetched('roles'),\n * cursor: previousCursor,\n * limit: 20,\n * cursorField: 'createdAt',\n * cursorDirection: Direction.Desc,\n * });\n * ```\n */\nexport async function paginatedSearch<\n\tTModel extends Model,\n\tTMapRow extends (row: TModel) => unknown = (row: TModel) => TModel,\n>({\n\tquery,\n\tcursor,\n\tlimit = 20,\n\tmapRow,\n\tcursorField = 'id',\n\tcursorDirection = Direction.Asc,\n}: ObjectionPaginatedSearchOptions<TModel, TMapRow>): Promise<\n\tPaginationResult<Awaited<ReturnType<TMapRow>>>\n> {\n\t// Get total count (without cursor filtering)\n\tconst total = await query.resultSize();\n\n\t// Apply cursor if provided\n\tlet paginatedQuery = query.clone();\n\tif (cursor) {\n\t\tconst operator = cursorDirection === Direction.Asc ? '>' : '<';\n\t\tpaginatedQuery = paginatedQuery.where(cursorField, operator, cursor);\n\t}\n\n\t// Fetch one extra to determine if there are more results\n\tconst data = await paginatedQuery\n\t\t.orderBy(cursorField, cursorDirection)\n\t\t.limit(limit + 1);\n\n\tconst hasMore = data.length > limit;\n\tconst rows = hasMore ? data.slice(0, limit) : data;\n\tconst lastRow = rows[rows.length - 1];\n\tconst nextCursor =\n\t\thasMore && lastRow\n\t\t\t? String((lastRow as Record<string, unknown>)[cursorField])\n\t\t\t: undefined;\n\n\tconst mapper = mapRow ?? ((row: TModel) => row);\n\tconst items = (await Promise.all(rows.map(mapper))) as Awaited<\n\t\tReturnType<TMapRow>\n\t>[];\n\n\treturn {\n\t\titems,\n\t\tpagination: {\n\t\t\ttotal,\n\t\t\thasMore,\n\t\t\tcursor: nextCursor,\n\t\t},\n\t};\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;AAgDA,eAAsB,gBAGpB,EACD,OACA,QACA,QAAQ,IACR,QACA,cAAc,MACd,kBAAkB,UAAU,KACsB,EAEjD;CAED,MAAM,QAAQ,MAAM,MAAM,YAAY;CAGtC,IAAI,iBAAiB,MAAM,OAAO;AAClC,KAAI,QAAQ;EACX,MAAM,WAAW,oBAAoB,UAAU,MAAM,MAAM;AAC3D,mBAAiB,eAAe,MAAM,aAAa,UAAU,OAAO;CACpE;CAGD,MAAM,OAAO,MAAM,eACjB,QAAQ,aAAa,gBAAgB,CACrC,MAAM,QAAQ,EAAE;CAElB,MAAM,UAAU,KAAK,SAAS;CAC9B,MAAM,OAAO,UAAU,KAAK,MAAM,GAAG,MAAM,GAAG;CAC9C,MAAM,UAAU,KAAK,KAAK,SAAS;CACnC,MAAM,aACL,WAAW,UACR,OAAQ,QAAoC,aAAa;CAG7D,MAAM,SAAS,WAAW,CAACA,QAAgB;CAC3C,MAAM,QAAS,MAAM,QAAQ,IAAI,KAAK,IAAI,OAAO,CAAC;AAIlD,QAAO;EACN;EACA,YAAY;GACX;GACA;GACA,QAAQ;EACR;CACD;AACD"}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
//#region src/pagination.d.ts
|
|
2
|
+
/**
|
|
3
|
+
* Sort direction for cursor-based pagination.
|
|
4
|
+
*/
|
|
5
|
+
declare enum Direction {
|
|
6
|
+
Asc = "asc",
|
|
7
|
+
Desc = "desc",
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* Result of a paginated query.
|
|
11
|
+
*/
|
|
12
|
+
interface PaginationResult<TItem> {
|
|
13
|
+
items: TItem[];
|
|
14
|
+
pagination: {
|
|
15
|
+
total: number;
|
|
16
|
+
hasMore: boolean;
|
|
17
|
+
cursor?: string;
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Encode a cursor value for safe URL transmission.
|
|
22
|
+
* Supports various types: string, number, Date, etc.
|
|
23
|
+
*/
|
|
24
|
+
declare function encodeCursor(value: unknown): string;
|
|
25
|
+
/**
|
|
26
|
+
* Decode a cursor string back to its original value.
|
|
27
|
+
*/
|
|
28
|
+
declare function decodeCursor(cursor: string): unknown;
|
|
29
|
+
//# sourceMappingURL=pagination.d.ts.map
|
|
30
|
+
//#endregion
|
|
31
|
+
export { Direction, PaginationResult, decodeCursor, encodeCursor };
|
|
32
|
+
//# sourceMappingURL=pagination-BAFX7C1I.d.cts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"pagination-BAFX7C1I.d.cts","names":[],"sources":["../src/pagination.ts"],"sourcesContent":[],"mappings":";;AAGA;AAQA;AAagB,aArBJ,SAAA;EAgCI,GAAA,GAAA,KAAA;;;;;;UAxBC;SACT;;;;;;;;;;;iBAYQ,YAAA;;;;iBAWA,YAAA"}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
//#region src/pagination.ts
|
|
2
|
+
/**
|
|
3
|
+
* Sort direction for cursor-based pagination.
|
|
4
|
+
*/
|
|
5
|
+
let Direction = /* @__PURE__ */ function(Direction$1) {
|
|
6
|
+
Direction$1["Asc"] = "asc";
|
|
7
|
+
Direction$1["Desc"] = "desc";
|
|
8
|
+
return Direction$1;
|
|
9
|
+
}({});
|
|
10
|
+
/**
|
|
11
|
+
* Encode a cursor value for safe URL transmission.
|
|
12
|
+
* Supports various types: string, number, Date, etc.
|
|
13
|
+
*/
|
|
14
|
+
function encodeCursor(value) {
|
|
15
|
+
const payload = {
|
|
16
|
+
v: value instanceof Date ? value.toISOString() : value,
|
|
17
|
+
t: value instanceof Date ? "date" : typeof value
|
|
18
|
+
};
|
|
19
|
+
return Buffer.from(JSON.stringify(payload)).toString("base64url");
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Decode a cursor string back to its original value.
|
|
23
|
+
*/
|
|
24
|
+
function decodeCursor(cursor) {
|
|
25
|
+
try {
|
|
26
|
+
const json = Buffer.from(cursor, "base64url").toString("utf-8");
|
|
27
|
+
const payload = JSON.parse(json);
|
|
28
|
+
if (payload.t === "date") return new Date(payload.v);
|
|
29
|
+
return payload.v;
|
|
30
|
+
} catch {
|
|
31
|
+
throw new Error("Invalid cursor format");
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
//#endregion
|
|
36
|
+
export { Direction, decodeCursor, encodeCursor };
|
|
37
|
+
//# sourceMappingURL=pagination-BDLa7Yb_.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"pagination-BDLa7Yb_.mjs","names":["value: unknown","cursor: string"],"sources":["../src/pagination.ts"],"sourcesContent":["/**\n * Sort direction for cursor-based pagination.\n */\nexport enum Direction {\n\tAsc = 'asc',\n\tDesc = 'desc',\n}\n\n/**\n * Result of a paginated query.\n */\nexport interface PaginationResult<TItem> {\n\titems: TItem[];\n\tpagination: {\n\t\ttotal: number;\n\t\thasMore: boolean;\n\t\tcursor?: string;\n\t};\n}\n\n/**\n * Encode a cursor value for safe URL transmission.\n * Supports various types: string, number, Date, etc.\n */\nexport function encodeCursor(value: unknown): string {\n\tconst payload = {\n\t\tv: value instanceof Date ? value.toISOString() : value,\n\t\tt: value instanceof Date ? 'date' : typeof value,\n\t};\n\treturn Buffer.from(JSON.stringify(payload)).toString('base64url');\n}\n\n/**\n * Decode a cursor string back to its original value.\n */\nexport function decodeCursor(cursor: string): unknown {\n\ttry {\n\t\tconst json = Buffer.from(cursor, 'base64url').toString('utf-8');\n\t\tconst payload = JSON.parse(json);\n\n\t\tif (payload.t === 'date') {\n\t\t\treturn new Date(payload.v);\n\t\t}\n\n\t\treturn payload.v;\n\t} catch {\n\t\tthrow new Error('Invalid cursor format');\n\t}\n}\n"],"mappings":";;;;AAGA,IAAY,kDAAL;AACN;AACA;;AACA;;;;;AAkBD,SAAgB,aAAaA,OAAwB;CACpD,MAAM,UAAU;EACf,GAAG,iBAAiB,OAAO,MAAM,aAAa,GAAG;EACjD,GAAG,iBAAiB,OAAO,gBAAgB;CAC3C;AACD,QAAO,OAAO,KAAK,KAAK,UAAU,QAAQ,CAAC,CAAC,SAAS,YAAY;AACjE;;;;AAKD,SAAgB,aAAaC,QAAyB;AACrD,KAAI;EACH,MAAM,OAAO,OAAO,KAAK,QAAQ,YAAY,CAAC,SAAS,QAAQ;EAC/D,MAAM,UAAU,KAAK,MAAM,KAAK;AAEhC,MAAI,QAAQ,MAAM,OACjB,QAAO,IAAI,KAAK,QAAQ;AAGzB,SAAO,QAAQ;CACf,QAAO;AACP,QAAM,IAAI,MAAM;CAChB;AACD"}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
|
|
2
|
+
//#region src/pagination.ts
|
|
3
|
+
/**
|
|
4
|
+
* Sort direction for cursor-based pagination.
|
|
5
|
+
*/
|
|
6
|
+
let Direction = /* @__PURE__ */ function(Direction$1) {
|
|
7
|
+
Direction$1["Asc"] = "asc";
|
|
8
|
+
Direction$1["Desc"] = "desc";
|
|
9
|
+
return Direction$1;
|
|
10
|
+
}({});
|
|
11
|
+
/**
|
|
12
|
+
* Encode a cursor value for safe URL transmission.
|
|
13
|
+
* Supports various types: string, number, Date, etc.
|
|
14
|
+
*/
|
|
15
|
+
function encodeCursor(value) {
|
|
16
|
+
const payload = {
|
|
17
|
+
v: value instanceof Date ? value.toISOString() : value,
|
|
18
|
+
t: value instanceof Date ? "date" : typeof value
|
|
19
|
+
};
|
|
20
|
+
return Buffer.from(JSON.stringify(payload)).toString("base64url");
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Decode a cursor string back to its original value.
|
|
24
|
+
*/
|
|
25
|
+
function decodeCursor(cursor) {
|
|
26
|
+
try {
|
|
27
|
+
const json = Buffer.from(cursor, "base64url").toString("utf-8");
|
|
28
|
+
const payload = JSON.parse(json);
|
|
29
|
+
if (payload.t === "date") return new Date(payload.v);
|
|
30
|
+
return payload.v;
|
|
31
|
+
} catch {
|
|
32
|
+
throw new Error("Invalid cursor format");
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
//#endregion
|
|
37
|
+
Object.defineProperty(exports, 'Direction', {
|
|
38
|
+
enumerable: true,
|
|
39
|
+
get: function () {
|
|
40
|
+
return Direction;
|
|
41
|
+
}
|
|
42
|
+
});
|
|
43
|
+
Object.defineProperty(exports, 'decodeCursor', {
|
|
44
|
+
enumerable: true,
|
|
45
|
+
get: function () {
|
|
46
|
+
return decodeCursor;
|
|
47
|
+
}
|
|
48
|
+
});
|
|
49
|
+
Object.defineProperty(exports, 'encodeCursor', {
|
|
50
|
+
enumerable: true,
|
|
51
|
+
get: function () {
|
|
52
|
+
return encodeCursor;
|
|
53
|
+
}
|
|
54
|
+
});
|
|
55
|
+
//# sourceMappingURL=pagination-Bdoa4PVj.cjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"pagination-Bdoa4PVj.cjs","names":["value: unknown","cursor: string"],"sources":["../src/pagination.ts"],"sourcesContent":["/**\n * Sort direction for cursor-based pagination.\n */\nexport enum Direction {\n\tAsc = 'asc',\n\tDesc = 'desc',\n}\n\n/**\n * Result of a paginated query.\n */\nexport interface PaginationResult<TItem> {\n\titems: TItem[];\n\tpagination: {\n\t\ttotal: number;\n\t\thasMore: boolean;\n\t\tcursor?: string;\n\t};\n}\n\n/**\n * Encode a cursor value for safe URL transmission.\n * Supports various types: string, number, Date, etc.\n */\nexport function encodeCursor(value: unknown): string {\n\tconst payload = {\n\t\tv: value instanceof Date ? value.toISOString() : value,\n\t\tt: value instanceof Date ? 'date' : typeof value,\n\t};\n\treturn Buffer.from(JSON.stringify(payload)).toString('base64url');\n}\n\n/**\n * Decode a cursor string back to its original value.\n */\nexport function decodeCursor(cursor: string): unknown {\n\ttry {\n\t\tconst json = Buffer.from(cursor, 'base64url').toString('utf-8');\n\t\tconst payload = JSON.parse(json);\n\n\t\tif (payload.t === 'date') {\n\t\t\treturn new Date(payload.v);\n\t\t}\n\n\t\treturn payload.v;\n\t} catch {\n\t\tthrow new Error('Invalid cursor format');\n\t}\n}\n"],"mappings":";;;;;AAGA,IAAY,kDAAL;AACN;AACA;;AACA;;;;;AAkBD,SAAgB,aAAaA,OAAwB;CACpD,MAAM,UAAU;EACf,GAAG,iBAAiB,OAAO,MAAM,aAAa,GAAG;EACjD,GAAG,iBAAiB,OAAO,gBAAgB;CAC3C;AACD,QAAO,OAAO,KAAK,KAAK,UAAU,QAAQ,CAAC,CAAC,SAAS,YAAY;AACjE;;;;AAKD,SAAgB,aAAaC,QAAyB;AACrD,KAAI;EACH,MAAM,OAAO,OAAO,KAAK,QAAQ,YAAY,CAAC,SAAS,QAAQ;EAC/D,MAAM,UAAU,KAAK,MAAM,KAAK;AAEhC,MAAI,QAAQ,MAAM,OACjB,QAAO,IAAI,KAAK,QAAQ;AAGzB,SAAO,QAAQ;CACf,QAAO;AACP,QAAM,IAAI,MAAM;CAChB;AACD"}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
//#region src/pagination.d.ts
|
|
2
|
+
/**
|
|
3
|
+
* Sort direction for cursor-based pagination.
|
|
4
|
+
*/
|
|
5
|
+
declare enum Direction {
|
|
6
|
+
Asc = "asc",
|
|
7
|
+
Desc = "desc",
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* Result of a paginated query.
|
|
11
|
+
*/
|
|
12
|
+
interface PaginationResult<TItem> {
|
|
13
|
+
items: TItem[];
|
|
14
|
+
pagination: {
|
|
15
|
+
total: number;
|
|
16
|
+
hasMore: boolean;
|
|
17
|
+
cursor?: string;
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Encode a cursor value for safe URL transmission.
|
|
22
|
+
* Supports various types: string, number, Date, etc.
|
|
23
|
+
*/
|
|
24
|
+
declare function encodeCursor(value: unknown): string;
|
|
25
|
+
/**
|
|
26
|
+
* Decode a cursor string back to its original value.
|
|
27
|
+
*/
|
|
28
|
+
declare function decodeCursor(cursor: string): unknown;
|
|
29
|
+
//# sourceMappingURL=pagination.d.ts.map
|
|
30
|
+
//#endregion
|
|
31
|
+
export { Direction, PaginationResult, decodeCursor, encodeCursor };
|
|
32
|
+
//# sourceMappingURL=pagination-BziGl-B8.d.mts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"pagination-BziGl-B8.d.mts","names":[],"sources":["../src/pagination.ts"],"sourcesContent":[],"mappings":";;AAGA;AAQA;AAagB,aArBJ,SAAA;EAgCI,GAAA,GAAA,KAAA;;;;;;UAxBC;SACT;;;;;;;;;;;iBAYQ,YAAA;;;;iBAWA,YAAA"}
|
package/dist/rls.d.mts
CHANGED
package/package.json
CHANGED
|
@@ -1,23 +1,58 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@geekmidas/db",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.2",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
7
7
|
"./kysely": {
|
|
8
|
-
"
|
|
9
|
-
|
|
10
|
-
|
|
8
|
+
"import": {
|
|
9
|
+
"types": "./dist/kysely.d.mts",
|
|
10
|
+
"default": "./dist/kysely.mjs"
|
|
11
|
+
},
|
|
12
|
+
"require": {
|
|
13
|
+
"types": "./dist/kysely.d.cts",
|
|
14
|
+
"default": "./dist/kysely.cjs"
|
|
15
|
+
}
|
|
11
16
|
},
|
|
12
17
|
"./kysely/pagination": {
|
|
13
|
-
"
|
|
14
|
-
|
|
15
|
-
|
|
18
|
+
"import": {
|
|
19
|
+
"types": "./dist/kysely/pagination.d.mts",
|
|
20
|
+
"default": "./dist/kysely/pagination.mjs"
|
|
21
|
+
},
|
|
22
|
+
"require": {
|
|
23
|
+
"types": "./dist/kysely/pagination.d.cts",
|
|
24
|
+
"default": "./dist/kysely/pagination.cjs"
|
|
25
|
+
}
|
|
26
|
+
},
|
|
27
|
+
"./pagination": {
|
|
28
|
+
"import": {
|
|
29
|
+
"types": "./dist/pagination.d.mts",
|
|
30
|
+
"default": "./dist/pagination.mjs"
|
|
31
|
+
},
|
|
32
|
+
"require": {
|
|
33
|
+
"types": "./dist/pagination.d.cts",
|
|
34
|
+
"default": "./dist/pagination.cjs"
|
|
35
|
+
}
|
|
36
|
+
},
|
|
37
|
+
"./objection/pagination": {
|
|
38
|
+
"import": {
|
|
39
|
+
"types": "./dist/objection/pagination.d.mts",
|
|
40
|
+
"default": "./dist/objection/pagination.mjs"
|
|
41
|
+
},
|
|
42
|
+
"require": {
|
|
43
|
+
"types": "./dist/objection/pagination.d.cts",
|
|
44
|
+
"default": "./dist/objection/pagination.cjs"
|
|
45
|
+
}
|
|
16
46
|
},
|
|
17
47
|
"./rls": {
|
|
18
|
-
"
|
|
19
|
-
|
|
20
|
-
|
|
48
|
+
"import": {
|
|
49
|
+
"types": "./dist/rls.d.mts",
|
|
50
|
+
"default": "./dist/rls.mjs"
|
|
51
|
+
},
|
|
52
|
+
"require": {
|
|
53
|
+
"types": "./dist/rls.d.cts",
|
|
54
|
+
"default": "./dist/rls.cjs"
|
|
55
|
+
}
|
|
21
56
|
}
|
|
22
57
|
},
|
|
23
58
|
"dependencies": {
|
|
@@ -20,6 +20,16 @@ interface TestDatabase {
|
|
|
20
20
|
};
|
|
21
21
|
}
|
|
22
22
|
|
|
23
|
+
interface RlsPolicyDatabase {
|
|
24
|
+
rlsPolicyItems: {
|
|
25
|
+
id: Generated<number>;
|
|
26
|
+
tenantId: string;
|
|
27
|
+
userId: string;
|
|
28
|
+
amount: number;
|
|
29
|
+
createdAt: Generated<Date>;
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
23
33
|
describe('RLS Utility - Integration Tests', () => {
|
|
24
34
|
let db: Kysely<TestDatabase>;
|
|
25
35
|
|
|
@@ -441,4 +451,266 @@ describe('RLS Utility - Integration Tests', () => {
|
|
|
441
451
|
expect(context.optional).toBe(undefined);
|
|
442
452
|
});
|
|
443
453
|
});
|
|
454
|
+
|
|
455
|
+
describe('RLS Policy Enforcement', () => {
|
|
456
|
+
let adminDb: Kysely<RlsPolicyDatabase>;
|
|
457
|
+
let userDb: Kysely<RlsPolicyDatabase>;
|
|
458
|
+
|
|
459
|
+
beforeAll(async () => {
|
|
460
|
+
// Admin connection (superuser bypasses RLS)
|
|
461
|
+
adminDb = new Kysely<RlsPolicyDatabase>({
|
|
462
|
+
dialect: new PostgresDialect({
|
|
463
|
+
pool: new pg.Pool({
|
|
464
|
+
...TEST_DATABASE_CONFIG,
|
|
465
|
+
database: 'postgres',
|
|
466
|
+
}),
|
|
467
|
+
}),
|
|
468
|
+
plugins: [new CamelCasePlugin()],
|
|
469
|
+
});
|
|
470
|
+
|
|
471
|
+
// Clean up from any previous failed runs
|
|
472
|
+
await sql`DROP TABLE IF EXISTS rls_policy_items CASCADE`.execute(adminDb);
|
|
473
|
+
|
|
474
|
+
// Create non-superuser role (RLS doesn't apply to superusers)
|
|
475
|
+
await sql`
|
|
476
|
+
DO $$ BEGIN
|
|
477
|
+
IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = 'rls_test_role') THEN
|
|
478
|
+
CREATE ROLE rls_test_role LOGIN PASSWORD 'rls_test_pass';
|
|
479
|
+
END IF;
|
|
480
|
+
END $$
|
|
481
|
+
`.execute(adminDb);
|
|
482
|
+
|
|
483
|
+
// Create table
|
|
484
|
+
await adminDb.schema
|
|
485
|
+
.createTable('rls_policy_items')
|
|
486
|
+
.addColumn('id', 'serial', (col) => col.primaryKey())
|
|
487
|
+
.addColumn('tenant_id', 'varchar', (col) => col.notNull())
|
|
488
|
+
.addColumn('user_id', 'varchar', (col) => col.notNull())
|
|
489
|
+
.addColumn('amount', 'numeric(10, 2)', (col) => col.notNull())
|
|
490
|
+
.addColumn('created_at', 'timestamp', (col) =>
|
|
491
|
+
col.defaultTo(sql`now()`).notNull(),
|
|
492
|
+
)
|
|
493
|
+
.execute();
|
|
494
|
+
|
|
495
|
+
// Enable RLS
|
|
496
|
+
await sql`ALTER TABLE rls_policy_items ENABLE ROW LEVEL SECURITY`.execute(
|
|
497
|
+
adminDb,
|
|
498
|
+
);
|
|
499
|
+
|
|
500
|
+
// Create tenant isolation policies
|
|
501
|
+
await sql`
|
|
502
|
+
CREATE POLICY tenant_select ON rls_policy_items
|
|
503
|
+
FOR SELECT USING (tenant_id = current_setting('app.tenant_id', true))
|
|
504
|
+
`.execute(adminDb);
|
|
505
|
+
|
|
506
|
+
await sql`
|
|
507
|
+
CREATE POLICY tenant_insert ON rls_policy_items
|
|
508
|
+
FOR INSERT WITH CHECK (tenant_id = current_setting('app.tenant_id', true))
|
|
509
|
+
`.execute(adminDb);
|
|
510
|
+
|
|
511
|
+
await sql`
|
|
512
|
+
CREATE POLICY tenant_update ON rls_policy_items
|
|
513
|
+
FOR UPDATE
|
|
514
|
+
USING (tenant_id = current_setting('app.tenant_id', true))
|
|
515
|
+
WITH CHECK (tenant_id = current_setting('app.tenant_id', true))
|
|
516
|
+
`.execute(adminDb);
|
|
517
|
+
|
|
518
|
+
await sql`
|
|
519
|
+
CREATE POLICY tenant_delete ON rls_policy_items
|
|
520
|
+
FOR DELETE USING (tenant_id = current_setting('app.tenant_id', true))
|
|
521
|
+
`.execute(adminDb);
|
|
522
|
+
|
|
523
|
+
// Grant permissions to test role
|
|
524
|
+
await sql`GRANT ALL ON rls_policy_items TO rls_test_role`.execute(
|
|
525
|
+
adminDb,
|
|
526
|
+
);
|
|
527
|
+
await sql`GRANT USAGE, SELECT ON SEQUENCE rls_policy_items_id_seq TO rls_test_role`.execute(
|
|
528
|
+
adminDb,
|
|
529
|
+
);
|
|
530
|
+
|
|
531
|
+
// Seed data as admin (superuser bypasses RLS)
|
|
532
|
+
await adminDb
|
|
533
|
+
.insertInto('rlsPolicyItems')
|
|
534
|
+
.values([
|
|
535
|
+
{ tenantId: 'tenant-a', userId: 'user-1', amount: 100 },
|
|
536
|
+
{ tenantId: 'tenant-a', userId: 'user-2', amount: 200 },
|
|
537
|
+
{ tenantId: 'tenant-b', userId: 'user-3', amount: 300 },
|
|
538
|
+
{ tenantId: 'tenant-b', userId: 'user-4', amount: 400 },
|
|
539
|
+
{ tenantId: 'tenant-c', userId: 'user-5', amount: 500 },
|
|
540
|
+
])
|
|
541
|
+
.execute();
|
|
542
|
+
|
|
543
|
+
// Create connection as non-superuser (subject to RLS policies)
|
|
544
|
+
userDb = new Kysely<RlsPolicyDatabase>({
|
|
545
|
+
dialect: new PostgresDialect({
|
|
546
|
+
pool: new pg.Pool({
|
|
547
|
+
host: TEST_DATABASE_CONFIG.host,
|
|
548
|
+
port: TEST_DATABASE_CONFIG.port,
|
|
549
|
+
user: 'rls_test_role',
|
|
550
|
+
password: 'rls_test_pass',
|
|
551
|
+
database: 'postgres',
|
|
552
|
+
}),
|
|
553
|
+
}),
|
|
554
|
+
plugins: [new CamelCasePlugin()],
|
|
555
|
+
});
|
|
556
|
+
});
|
|
557
|
+
|
|
558
|
+
afterAll(async () => {
|
|
559
|
+
await userDb.destroy();
|
|
560
|
+
await sql`DROP TABLE IF EXISTS rls_policy_items CASCADE`.execute(adminDb);
|
|
561
|
+
await sql`DROP ROLE IF EXISTS rls_test_role`.execute(adminDb);
|
|
562
|
+
await adminDb.destroy();
|
|
563
|
+
});
|
|
564
|
+
|
|
565
|
+
it('should only return rows matching the tenant context', async () => {
|
|
566
|
+
const rows = await withRlsContext(
|
|
567
|
+
userDb,
|
|
568
|
+
{ tenant_id: 'tenant-a' },
|
|
569
|
+
async (trx) => {
|
|
570
|
+
return trx.selectFrom('rlsPolicyItems').selectAll().execute();
|
|
571
|
+
},
|
|
572
|
+
);
|
|
573
|
+
|
|
574
|
+
expect(rows).toHaveLength(2);
|
|
575
|
+
expect(rows.every((r) => r.tenantId === 'tenant-a')).toBe(true);
|
|
576
|
+
});
|
|
577
|
+
|
|
578
|
+
it('should completely isolate tenants from each other', async () => {
|
|
579
|
+
const rows = await withRlsContext(
|
|
580
|
+
userDb,
|
|
581
|
+
{ tenant_id: 'tenant-b' },
|
|
582
|
+
async (trx) => {
|
|
583
|
+
return trx.selectFrom('rlsPolicyItems').selectAll().execute();
|
|
584
|
+
},
|
|
585
|
+
);
|
|
586
|
+
|
|
587
|
+
expect(rows).toHaveLength(2);
|
|
588
|
+
expect(rows.every((r) => r.tenantId === 'tenant-b')).toBe(true);
|
|
589
|
+
const userIds = rows.map((r) => r.userId);
|
|
590
|
+
expect(userIds).not.toContain('user-1');
|
|
591
|
+
expect(userIds).not.toContain('user-2');
|
|
592
|
+
});
|
|
593
|
+
|
|
594
|
+
it('should return no rows when tenant context is not set', async () => {
|
|
595
|
+
const rows = await withRlsContext(userDb, {}, async (trx) => {
|
|
596
|
+
return trx.selectFrom('rlsPolicyItems').selectAll().execute();
|
|
597
|
+
});
|
|
598
|
+
|
|
599
|
+
expect(rows).toHaveLength(0);
|
|
600
|
+
});
|
|
601
|
+
|
|
602
|
+
it('should allow inserting rows that match the tenant context', async () => {
|
|
603
|
+
const row = await withRlsContext(
|
|
604
|
+
userDb,
|
|
605
|
+
{ tenant_id: 'tenant-a' },
|
|
606
|
+
async (trx) => {
|
|
607
|
+
return trx
|
|
608
|
+
.insertInto('rlsPolicyItems')
|
|
609
|
+
.values({
|
|
610
|
+
tenantId: 'tenant-a',
|
|
611
|
+
userId: 'user-insert-test',
|
|
612
|
+
amount: 999,
|
|
613
|
+
})
|
|
614
|
+
.returningAll()
|
|
615
|
+
.executeTakeFirstOrThrow();
|
|
616
|
+
},
|
|
617
|
+
);
|
|
618
|
+
|
|
619
|
+
expect(row.tenantId).toBe('tenant-a');
|
|
620
|
+
expect(row.userId).toBe('user-insert-test');
|
|
621
|
+
|
|
622
|
+
// Clean up
|
|
623
|
+
await sql`DELETE FROM rls_policy_items WHERE user_id = 'user-insert-test'`.execute(
|
|
624
|
+
adminDb,
|
|
625
|
+
);
|
|
626
|
+
});
|
|
627
|
+
|
|
628
|
+
it('should reject inserting rows that violate the tenant policy', async () => {
|
|
629
|
+
await expect(
|
|
630
|
+
withRlsContext(userDb, { tenant_id: 'tenant-a' }, async (trx) => {
|
|
631
|
+
return trx
|
|
632
|
+
.insertInto('rlsPolicyItems')
|
|
633
|
+
.values({
|
|
634
|
+
tenantId: 'tenant-b',
|
|
635
|
+
userId: 'user-cross-tenant',
|
|
636
|
+
amount: 666,
|
|
637
|
+
})
|
|
638
|
+
.execute();
|
|
639
|
+
}),
|
|
640
|
+
).rejects.toThrow(/row-level security/i);
|
|
641
|
+
});
|
|
642
|
+
|
|
643
|
+
it('should not update rows belonging to another tenant', async () => {
|
|
644
|
+
const result = await withRlsContext(
|
|
645
|
+
userDb,
|
|
646
|
+
{ tenant_id: 'tenant-a' },
|
|
647
|
+
async (trx) => {
|
|
648
|
+
return trx
|
|
649
|
+
.updateTable('rlsPolicyItems')
|
|
650
|
+
.set({ amount: 111 })
|
|
651
|
+
.where('userId', '=', 'user-3') // belongs to tenant-b
|
|
652
|
+
.executeTakeFirst();
|
|
653
|
+
},
|
|
654
|
+
);
|
|
655
|
+
|
|
656
|
+
expect(Number(result.numUpdatedRows)).toBe(0);
|
|
657
|
+
|
|
658
|
+
// Verify row is unchanged
|
|
659
|
+
const unchanged = await sql<{ amount: string }>`
|
|
660
|
+
SELECT amount FROM rls_policy_items WHERE user_id = 'user-3'
|
|
661
|
+
`
|
|
662
|
+
.execute(adminDb)
|
|
663
|
+
.then((r) => r.rows[0]);
|
|
664
|
+
|
|
665
|
+
expect(Number(unchanged?.amount)).toBe(300);
|
|
666
|
+
});
|
|
667
|
+
|
|
668
|
+
it('should reject updates that reassign a row to another tenant', async () => {
|
|
669
|
+
await expect(
|
|
670
|
+
withRlsContext(userDb, { tenant_id: 'tenant-a' }, async (trx) => {
|
|
671
|
+
return trx
|
|
672
|
+
.updateTable('rlsPolicyItems')
|
|
673
|
+
.set({ tenantId: 'tenant-b' })
|
|
674
|
+
.where('userId', '=', 'user-1')
|
|
675
|
+
.execute();
|
|
676
|
+
}),
|
|
677
|
+
).rejects.toThrow(/row-level security/i);
|
|
678
|
+
});
|
|
679
|
+
|
|
680
|
+
it('should not delete rows belonging to another tenant', async () => {
|
|
681
|
+
const result = await withRlsContext(
|
|
682
|
+
userDb,
|
|
683
|
+
{ tenant_id: 'tenant-a' },
|
|
684
|
+
async (trx) => {
|
|
685
|
+
return trx
|
|
686
|
+
.deleteFrom('rlsPolicyItems')
|
|
687
|
+
.where('userId', '=', 'user-3') // belongs to tenant-b
|
|
688
|
+
.executeTakeFirst();
|
|
689
|
+
},
|
|
690
|
+
);
|
|
691
|
+
|
|
692
|
+
expect(Number(result.numDeletedRows)).toBe(0);
|
|
693
|
+
});
|
|
694
|
+
|
|
695
|
+
it('should allow deleting rows belonging to the current tenant', async () => {
|
|
696
|
+
// Insert a row to delete
|
|
697
|
+
await sql`
|
|
698
|
+
INSERT INTO rls_policy_items (tenant_id, user_id, amount)
|
|
699
|
+
VALUES ('tenant-a', 'user-to-delete', 999)
|
|
700
|
+
`.execute(adminDb);
|
|
701
|
+
|
|
702
|
+
const result = await withRlsContext(
|
|
703
|
+
userDb,
|
|
704
|
+
{ tenant_id: 'tenant-a' },
|
|
705
|
+
async (trx) => {
|
|
706
|
+
return trx
|
|
707
|
+
.deleteFrom('rlsPolicyItems')
|
|
708
|
+
.where('userId', '=', 'user-to-delete')
|
|
709
|
+
.executeTakeFirst();
|
|
710
|
+
},
|
|
711
|
+
);
|
|
712
|
+
|
|
713
|
+
expect(Number(result.numDeletedRows)).toBe(1);
|
|
714
|
+
});
|
|
715
|
+
});
|
|
444
716
|
});
|