prisma-pg-toolkit 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.MD ADDED
@@ -0,0 +1,88 @@
1
+ # prisma-pg-toolkit
2
+
3
+ Joins (`INNER` / `LEFT` / `RIGHT` / `FULL OUTER` / `CROSS`) and locking (pessimistic / optimistic) helpers for **Prisma + PostgreSQL**.
4
+
5
+ Prisma's client only performs `LEFT JOIN`-style relation loading internally, and has no built-in row locking. This library fills both gaps with safe, config-driven APIs — no raw SQL required from consumers.
6
+
7
+ > **Postgres only.** Join syntax (`FULL OUTER JOIN`, `RIGHT JOIN`) and locking (`FOR UPDATE`) differ across databases — this library targets Postgres specifically.
8
+
9
+ ## Install
10
+
11
+ ```bash
12
+ npm install prisma-pg-toolkit
13
+ ```
14
+
15
+ Requires `@prisma/client` v5+ as a peer dependency.
16
+
17
+ ## Setup
18
+
19
+ ```ts
20
+ import { PrismaClient } from '@prisma/client';
21
+ import { withToolKit } from 'prisma-pg-toolkit';
22
+
23
+ const base = new PrismaClient();
24
+ const prisma = withToolKit(base);
25
+ ```
26
+
27
+ ## Joins
28
+
29
+ ```ts
30
+ const result = await prisma.$join({
31
+ from: 'User',
32
+ select: ['User.email', 'Post.title'],
33
+ join: {
34
+ type: 'LEFT', // 'INNER' | 'LEFT' | 'RIGHT' | 'FULL OUTER' | 'CROSS'
35
+ table: 'Post',
36
+ on: { fromColumn: 'id', toColumn: 'userId' },
37
+ },
38
+ where: { column: 'published', op: '=', value: true }, // optional
39
+ });
40
+ ```
41
+
42
+ `CROSS` joins don't need an `on` clause.
43
+
44
+ ## Pessimistic locking
45
+
46
+ Locks a row (`SELECT ... FOR UPDATE`) inside a transaction. Other callers requesting the same row **wait** until the transaction commits or rolls back.
47
+
48
+ ```ts
49
+ await prisma.$lock.pessimistic(
50
+ { table: 'User', id: 1, mode: 'FOR UPDATE' }, // or 'FOR SHARE'
51
+ async (tx, row) => {
52
+ return tx.user.update({ where: { id: row.id }, data: { name: 'Updated' } });
53
+ }
54
+ );
55
+ ```
56
+
57
+ ## Optimistic locking
58
+
59
+ Requires a `version: Int` column on the model. Update succeeds only if `expectedVersion` still matches the current row; otherwise throws `OptimisticLockError`, and the caller should re-fetch and retry.
60
+
61
+ ```ts
62
+ import { OptimisticLockError } from 'prisma-pg-toolkit';
63
+
64
+ try {
65
+ await prisma.$lock.optimistic({
66
+ model: 'user', // matches your Prisma model accessor, lowercase
67
+ id: 1,
68
+ expectedVersion: 3,
69
+ data: { name: 'New Name' },
70
+ });
71
+ } catch (err) {
72
+ if (err instanceof OptimisticLockError) {
73
+ // row changed since you read it — re-fetch and retry
74
+ }
75
+ }
76
+ ```
77
+
78
+ ## Security
79
+
80
+ Table and column names are validated against a strict identifier pattern before being spliced into SQL — only letters, digits, and underscores, matching Postgres' identifier rules. Values are always passed as parameterized bindings via Prisma's tagged-template `$queryRaw`, never string-concatenated.
81
+
82
+ ## Testing
83
+
84
+ Manual test scripts demonstrating all join types and both locking strategies against seeded data are in [`src/testing`](./src/testing) on GitHub — useful as runnable examples.
85
+
86
+ ## License
87
+
88
+ MIT
package/dist/index.cjs ADDED
@@ -0,0 +1,159 @@
1
+ "use strict";
2
+ var __defProp = Object.defineProperty;
3
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
4
+ var __getOwnPropNames = Object.getOwnPropertyNames;
5
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
6
+ var __export = (target, all) => {
7
+ for (var name in all)
8
+ __defProp(target, name, { get: all[name], enumerable: true });
9
+ };
10
+ var __copyProps = (to, from, except, desc) => {
11
+ if (from && typeof from === "object" || typeof from === "function") {
12
+ for (let key of __getOwnPropNames(from))
13
+ if (!__hasOwnProp.call(to, key) && key !== except)
14
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
15
+ }
16
+ return to;
17
+ };
18
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
19
+
20
+ // src/index.ts
21
+ var index_exports = {};
22
+ __export(index_exports, {
23
+ OptimisticLockError: () => OptimisticLockError,
24
+ buildJoinQuery: () => buildJoinQuery,
25
+ optimisticLockUpdate: () => optimisticLockUpdate,
26
+ pessimisticLock: () => pessimisticLock,
27
+ withToolKit: () => withToolKit
28
+ });
29
+ module.exports = __toCommonJS(index_exports);
30
+
31
+ // src/joins/JoinBuilder.ts
32
+ var import_client = require("@prisma/client");
33
+
34
+ // src/security/security.ts
35
+ var IDENT_RE = /^[a-zA-Z_][a-zA-Z0-9_]*$/;
36
+ var MAX_IDENTIFIER_LENGTH = 63;
37
+ function validateSegment(segment) {
38
+ if (segment.length === 0) {
39
+ throw new Error("Identifier segment cannot be empty");
40
+ }
41
+ if (segment.length > MAX_IDENTIFIER_LENGTH) {
42
+ throw new Error(`Identifier "${segment}" exceeds ${MAX_IDENTIFIER_LENGTH} character limit(Postgres will silently truncate it)`);
43
+ }
44
+ if (!IDENT_RE.test(segment)) {
45
+ throw new Error(`Unsafe or invalid identifier: "${segment}"`);
46
+ }
47
+ }
48
+ function assertSafeIdentifier(name) {
49
+ validateSegment(name);
50
+ return name;
51
+ }
52
+ function assertSafeQualifiedIdentifier(name) {
53
+ const [core, ...rest] = name.split(/\s+as\s+/i);
54
+ if (rest.length > 1) {
55
+ throw new Error(`Invalid identifier expression: "${name}"`);
56
+ }
57
+ const segment = core.trim().split(".");
58
+ if (segment.length > 2) {
59
+ throw new Error(`Invalid identifier expression: "${name}"`);
60
+ }
61
+ segment.forEach(validateSegment);
62
+ if (rest.length === 1) {
63
+ validateSegment(rest[0].trim());
64
+ }
65
+ return name;
66
+ }
67
+ function quoteIdentifier(name) {
68
+ return `"${name}"`;
69
+ }
70
+
71
+ // src/joins/JoinBuilder.ts
72
+ function buildJoinQuery(config) {
73
+ const fromTable = assertSafeIdentifier(config.from);
74
+ const selectCols = (config.select ?? ["*"]).map((c) => c === "*" ? c : assertSafeQualifiedIdentifier(c)).join(", ");
75
+ let query = import_client.Prisma.sql`SELECT ${import_client.Prisma.raw(selectCols)} FROM ${import_client.Prisma.raw(`"${quoteIdentifier(fromTable)}"`)}`;
76
+ if (config.join) {
77
+ const { type, table, on } = config.join;
78
+ const joinTable = assertSafeIdentifier(table);
79
+ if (type === "CROSS") {
80
+ query = import_client.Prisma.sql`${query} CROSS JOIN ${import_client.Prisma.raw(`"${quoteIdentifier(joinTable)}"`)}`;
81
+ } else {
82
+ if (!on) throw new Error(`on Condition is required for ${type} JOIN`);
83
+ const fromCol = assertSafeIdentifier(on.fromColumn);
84
+ const toCol = assertSafeIdentifier(on.toColumn);
85
+ query = import_client.Prisma.sql`${query} ${import_client.Prisma.raw(type)} JOIN ${import_client.Prisma.raw(`"${quoteIdentifier(joinTable)}"`)} ON ${import_client.Prisma.raw(`"${quoteIdentifier(fromTable)}"."${quoteIdentifier(fromCol)}"`)} = ${import_client.Prisma.raw(`"${quoteIdentifier(joinTable)}"."${quoteIdentifier(toCol)}"`)}`;
86
+ }
87
+ }
88
+ if (config.where) {
89
+ const col = assertSafeIdentifier(config.where.column);
90
+ const op = config.where.op;
91
+ query = import_client.Prisma.sql`${query} WHERE ${import_client.Prisma.raw(`"${quoteIdentifier(col)}"`)} ${import_client.Prisma.raw(op)} ${config.where.value}`;
92
+ }
93
+ return query;
94
+ }
95
+
96
+ // src/locking/passimisticLocking.ts
97
+ var import_client2 = require("@prisma/client");
98
+ async function pessimisticLock(prisma, config, fn) {
99
+ const table = assertSafeIdentifier(config.table);
100
+ const mode = config.mode ?? "FOR UPDATE";
101
+ return prisma.$transaction(
102
+ async (tx) => {
103
+ const rows = await tx.$queryRaw(
104
+ import_client2.Prisma.sql`SELECT * FROM ${import_client2.Prisma.raw(`${quoteIdentifier(table)}`)} WHERE id = ${config.id} ${import_client2.Prisma.raw(mode)}`
105
+ );
106
+ if (!rows.length) throw new Error(`Row not found in ${table} with id ${config.id}`);
107
+ return fn(tx, rows[0]);
108
+ },
109
+ { maxWait: config.maxWait ?? 5e3, timeout: config.timeout ?? 1e4 }
110
+ );
111
+ }
112
+
113
+ // src/locking/optimisticLocking.ts
114
+ var OptimisticLockError = class extends Error {
115
+ constructor(message = "Row was modified by another transaction") {
116
+ super(message);
117
+ this.name = "OptimisticLockError";
118
+ }
119
+ };
120
+ async function optimisticLockUpdate(prisma, config) {
121
+ const client = prisma;
122
+ if (!client[config.model]) {
123
+ throw new Error(`Model "${config.model}" not found on Prisma client`);
124
+ }
125
+ const result = await client[config.model].updateMany({
126
+ where: { id: config.id, version: config.expectedVersion },
127
+ data: { ...config.data, version: { increment: 1 } }
128
+ });
129
+ if (result.count === 0) {
130
+ throw new OptimisticLockError();
131
+ }
132
+ return result;
133
+ }
134
+
135
+ // src/index.ts
136
+ function withToolKit(prisma) {
137
+ return prisma.$extends({
138
+ name: "prisma-toolkit",
139
+ client: {
140
+ async $join(config) {
141
+ const sql = buildJoinQuery(config);
142
+ return prisma.$queryRaw(sql);
143
+ },
144
+ $lock: {
145
+ pessimistic: (config, fn) => pessimisticLock(prisma, config, fn),
146
+ optimistic: (config) => optimisticLockUpdate(prisma, config)
147
+ }
148
+ }
149
+ });
150
+ }
151
+ // Annotate the CommonJS export names for ESM import in node:
152
+ 0 && (module.exports = {
153
+ OptimisticLockError,
154
+ buildJoinQuery,
155
+ optimisticLockUpdate,
156
+ pessimisticLock,
157
+ withToolKit
158
+ });
159
+ //# sourceMappingURL=index.cjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/index.ts","../src/joins/JoinBuilder.ts","../src/security/security.ts","../src/locking/passimisticLocking.ts","../src/locking/optimisticLocking.ts"],"sourcesContent":["import { PrismaClient, Prisma } from \"@prisma/client\"\r\nimport { buildJoinQuery } from \"./joins/JoinBuilder\"\r\nimport { pessimisticLock } from \"./locking/passimisticLocking\"\r\nimport { optimisticLockUpdate } from \"./locking/optimisticLocking\"\r\n\r\n\r\nexport function withToolKit(\r\n prisma: PrismaClient\r\n) {\r\n return prisma.$extends({\r\n name: \"prisma-toolkit\",\r\n client: {\r\n async $join(config: Parameters<typeof buildJoinQuery>[0]) {\r\n const sql = buildJoinQuery(config)\r\n return prisma.$queryRaw(sql)\r\n },\r\n $lock: {\r\n pessimistic: (config: Parameters<typeof pessimisticLock>[1], fn: Parameters<typeof pessimisticLock>[2]) => pessimisticLock(prisma, config, fn),\r\n optimistic: (config: Parameters<typeof optimisticLockUpdate>[1]) => optimisticLockUpdate(prisma, config)\r\n }\r\n }\r\n })\r\n}\r\n\r\nexport { buildJoinQuery } from \"./joins/JoinBuilder\"\r\nexport { pessimisticLock } from \"./locking/passimisticLocking\"\r\nexport { optimisticLockUpdate, OptimisticLockError } from \"./locking/optimisticLocking\"\r\nexport type { JoinType } from \"./joins/JoinBuilder\"","import { Prisma } from \"@prisma/client\"\r\nimport { assertSafeIdentifier, assertSafeQualifiedIdentifier, quoteIdentifier } from \"../security/security\"\r\n\r\nexport type JoinType = \"INNER\" | \"LEFT\" | \"RIGHT\" | \"FULL OUTER\" | \"CROSS\"\r\n\r\ninterface JoinCondition {\r\n fromColumn: string\r\n toColumn: string\r\n}\r\n\r\ninterface JoinConfig {\r\n from: string\r\n select?: string[]\r\n join?: {\r\n type: JoinType,\r\n table: string,\r\n on?: JoinCondition\r\n }\r\n where?: {\r\n column: string\r\n op: '=' | '!=' | '>' | '<' | '>=' | '<=';\r\n value: any\r\n }\r\n}\r\n\r\n\r\nexport function buildJoinQuery(config: JoinConfig): Prisma.Sql {\r\n const fromTable = assertSafeIdentifier(config.from)\r\n const selectCols = (config.select ?? ['*']).map((c) => (c === '*' ? c : assertSafeQualifiedIdentifier(c))).join(', ')\r\n let query = Prisma.sql`SELECT ${Prisma.raw(selectCols)} FROM ${Prisma.raw(`\"${quoteIdentifier(fromTable)}\"`)}`;\r\n if (config.join) {\r\n const { type, table, on } = config.join\r\n const joinTable = assertSafeIdentifier(table)\r\n if (type === \"CROSS\") {\r\n query = Prisma.sql`${query} CROSS JOIN ${Prisma.raw(`\"${quoteIdentifier(joinTable)}\"`)}`;\r\n }\r\n else {\r\n if (!on) throw new Error(`on Condition is required for ${type} JOIN`)\r\n const fromCol = assertSafeIdentifier(on.fromColumn)\r\n const toCol = assertSafeIdentifier(on.toColumn)\r\n query = Prisma.sql`${query} ${Prisma.raw(type)} JOIN ${Prisma.raw(`\"${quoteIdentifier(joinTable)}\"`)} ON ${Prisma.raw(`\"${quoteIdentifier(fromTable)}\".\"${quoteIdentifier(fromCol)}\"`)} = ${Prisma.raw(`\"${quoteIdentifier(joinTable)}\".\"${quoteIdentifier(toCol)}\"`)}`;\r\n }\r\n }\r\n if (config.where) {\r\n const col = assertSafeIdentifier(config.where.column)\r\n const op = config.where.op\r\n query = Prisma.sql`${query} WHERE ${Prisma.raw(`\"${quoteIdentifier(col)}\"`)} ${Prisma.raw(op)} ${config.where.value}`\r\n }\r\n return query\r\n}\r\n\r\n\r\n","const IDENT_RE = /^[a-zA-Z_][a-zA-Z0-9_]*$/;\r\nconst MAX_IDENTIFIER_LENGTH = 63\r\n\r\nfunction validateSegment(segment: string): void {\r\n if (segment.length === 0) {\r\n throw new Error(\"Identifier segment cannot be empty\")\r\n }\r\n if (segment.length > MAX_IDENTIFIER_LENGTH) {\r\n throw new Error(`Identifier \"${segment}\" exceeds ${MAX_IDENTIFIER_LENGTH} character limit(Postgres will silently truncate it)`)\r\n }\r\n if (!IDENT_RE.test(segment)) {\r\n throw new Error(`Unsafe or invalid identifier: \"${segment}\"`)\r\n }\r\n}\r\n\r\nexport function assertSafeIdentifier(name: string): string {\r\n validateSegment(name)\r\n return name\r\n}\r\n\r\nexport function assertSafeQualifiedIdentifier(name: string): string {\r\n const [core, ...rest] = name.split(/\\s+as\\s+/i)\r\n if (rest.length > 1) {\r\n throw new Error(`Invalid identifier expression: \"${name}\"`)\r\n }\r\n const segment = core.trim().split(\".\")\r\n if (segment.length > 2) {\r\n throw new Error(`Invalid identifier expression: \"${name}\"`)\r\n }\r\n segment.forEach(validateSegment)\r\n\r\n if (rest.length === 1) {\r\n validateSegment(rest[0].trim())\r\n }\r\n return name\r\n}\r\n\r\nexport function quoteIdentifier(name: string): string {\r\n return `\"${name}\"`\r\n}","import { Prisma, PrismaClient } from \"@prisma/client\"\r\nimport { assertSafeIdentifier, quoteIdentifier } from \"../security/security\"\r\n\r\ninterface PessimisticLockConfig {\r\n table: string\r\n id: string | number\r\n mode?: \"FOR UPDATE\" | \"FOR SHARE\"\r\n maxWait?: number\r\n timeout?: number\r\n\r\n}\r\n\r\nexport async function pessimisticLock<T>(\r\n prisma: PrismaClient,\r\n config: PessimisticLockConfig,\r\n fn: (tx: Prisma.TransactionClient, row: any) => Promise<T>\r\n): Promise<T> {\r\n const table = assertSafeIdentifier(config.table)\r\n const mode = config.mode ?? \"FOR UPDATE\"\r\n return prisma.$transaction(async (tx) => {\r\n const rows: any[] = await tx.$queryRaw(\r\n Prisma.sql`SELECT * FROM ${Prisma.raw(`${quoteIdentifier(table)}`)} WHERE id = ${config.id} ${Prisma.raw(mode)}`\r\n )\r\n if (!rows.length) throw new Error(`Row not found in ${table} with id ${config.id}`)\r\n return fn(tx, rows[0])\r\n },\r\n { maxWait: config.maxWait ?? 5000, timeout: config.timeout ?? 10000 }\r\n )\r\n}","import { PrismaClient } from \"@prisma/client\";\r\n\r\nexport class OptimisticLockError extends Error {\r\n constructor(message = \"Row was modified by another transaction\") {\r\n super(message)\r\n this.name = \"OptimisticLockError\"\r\n }\r\n}\r\n\r\ninterface OptimisticLockConfig {\r\n model: string\r\n id: number\r\n expectedVersion: number\r\n data: Record<string, any>\r\n}\r\n\r\nexport async function optimisticLockUpdate(\r\n prisma: PrismaClient,\r\n config: OptimisticLockConfig\r\n) {\r\n const client = prisma as any\r\n if (!client[config.model]) {\r\n throw new Error(`Model \"${config.model}\" not found on Prisma client`)\r\n }\r\n const result = await client[config.model].updateMany({\r\n where: { id: config.id, version: config.expectedVersion },\r\n data: { ...config.data, version: { increment: 1 } }\r\n })\r\n if (result.count === 0) {\r\n throw new OptimisticLockError()\r\n }\r\n return result\r\n}"],"mappings":"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","names":["import_client"]}
@@ -0,0 +1,69 @@
1
+ import * as _prisma_client_runtime_library from '@prisma/client/runtime/library';
2
+ import { Prisma, PrismaClient } from '@prisma/client';
3
+
4
+ type JoinType = "INNER" | "LEFT" | "RIGHT" | "FULL OUTER" | "CROSS";
5
+ interface JoinCondition {
6
+ fromColumn: string;
7
+ toColumn: string;
8
+ }
9
+ interface JoinConfig {
10
+ from: string;
11
+ select?: string[];
12
+ join?: {
13
+ type: JoinType;
14
+ table: string;
15
+ on?: JoinCondition;
16
+ };
17
+ where?: {
18
+ column: string;
19
+ op: '=' | '!=' | '>' | '<' | '>=' | '<=';
20
+ value: any;
21
+ };
22
+ }
23
+ declare function buildJoinQuery(config: JoinConfig): Prisma.Sql;
24
+
25
+ interface PessimisticLockConfig {
26
+ table: string;
27
+ id: string | number;
28
+ mode?: "FOR UPDATE" | "FOR SHARE";
29
+ maxWait?: number;
30
+ timeout?: number;
31
+ }
32
+ declare function pessimisticLock<T>(prisma: PrismaClient, config: PessimisticLockConfig, fn: (tx: Prisma.TransactionClient, row: any) => Promise<T>): Promise<T>;
33
+
34
+ declare class OptimisticLockError extends Error {
35
+ constructor(message?: string);
36
+ }
37
+ interface OptimisticLockConfig {
38
+ model: string;
39
+ id: number;
40
+ expectedVersion: number;
41
+ data: Record<string, any>;
42
+ }
43
+ declare function optimisticLockUpdate(prisma: PrismaClient, config: OptimisticLockConfig): Promise<any>;
44
+
45
+ declare function withToolKit(prisma: PrismaClient): _prisma_client_runtime_library.DynamicClientExtensionThis<Prisma.TypeMap<_prisma_client_runtime_library.InternalArgs & {
46
+ result: {};
47
+ model: {};
48
+ query: {};
49
+ client: {
50
+ $join: () => (config: Parameters<typeof buildJoinQuery>[0]) => Promise<unknown>;
51
+ $lock: () => {
52
+ pessimistic: (config: Parameters<typeof pessimisticLock>[1], fn: Parameters<typeof pessimisticLock>[2]) => Promise<unknown>;
53
+ optimistic: (config: Parameters<typeof optimisticLockUpdate>[1]) => Promise<any>;
54
+ };
55
+ };
56
+ }, {}>, Prisma.TypeMapCb<Prisma.PrismaClientOptions>, {
57
+ result: {};
58
+ model: {};
59
+ query: {};
60
+ client: {
61
+ $join: () => (config: Parameters<typeof buildJoinQuery>[0]) => Promise<unknown>;
62
+ $lock: () => {
63
+ pessimistic: (config: Parameters<typeof pessimisticLock>[1], fn: Parameters<typeof pessimisticLock>[2]) => Promise<unknown>;
64
+ optimistic: (config: Parameters<typeof optimisticLockUpdate>[1]) => Promise<any>;
65
+ };
66
+ };
67
+ }>;
68
+
69
+ export { type JoinType, OptimisticLockError, buildJoinQuery, optimisticLockUpdate, pessimisticLock, withToolKit };
@@ -0,0 +1,69 @@
1
+ import * as _prisma_client_runtime_library from '@prisma/client/runtime/library';
2
+ import { Prisma, PrismaClient } from '@prisma/client';
3
+
4
+ type JoinType = "INNER" | "LEFT" | "RIGHT" | "FULL OUTER" | "CROSS";
5
+ interface JoinCondition {
6
+ fromColumn: string;
7
+ toColumn: string;
8
+ }
9
+ interface JoinConfig {
10
+ from: string;
11
+ select?: string[];
12
+ join?: {
13
+ type: JoinType;
14
+ table: string;
15
+ on?: JoinCondition;
16
+ };
17
+ where?: {
18
+ column: string;
19
+ op: '=' | '!=' | '>' | '<' | '>=' | '<=';
20
+ value: any;
21
+ };
22
+ }
23
+ declare function buildJoinQuery(config: JoinConfig): Prisma.Sql;
24
+
25
+ interface PessimisticLockConfig {
26
+ table: string;
27
+ id: string | number;
28
+ mode?: "FOR UPDATE" | "FOR SHARE";
29
+ maxWait?: number;
30
+ timeout?: number;
31
+ }
32
+ declare function pessimisticLock<T>(prisma: PrismaClient, config: PessimisticLockConfig, fn: (tx: Prisma.TransactionClient, row: any) => Promise<T>): Promise<T>;
33
+
34
+ declare class OptimisticLockError extends Error {
35
+ constructor(message?: string);
36
+ }
37
+ interface OptimisticLockConfig {
38
+ model: string;
39
+ id: number;
40
+ expectedVersion: number;
41
+ data: Record<string, any>;
42
+ }
43
+ declare function optimisticLockUpdate(prisma: PrismaClient, config: OptimisticLockConfig): Promise<any>;
44
+
45
+ declare function withToolKit(prisma: PrismaClient): _prisma_client_runtime_library.DynamicClientExtensionThis<Prisma.TypeMap<_prisma_client_runtime_library.InternalArgs & {
46
+ result: {};
47
+ model: {};
48
+ query: {};
49
+ client: {
50
+ $join: () => (config: Parameters<typeof buildJoinQuery>[0]) => Promise<unknown>;
51
+ $lock: () => {
52
+ pessimistic: (config: Parameters<typeof pessimisticLock>[1], fn: Parameters<typeof pessimisticLock>[2]) => Promise<unknown>;
53
+ optimistic: (config: Parameters<typeof optimisticLockUpdate>[1]) => Promise<any>;
54
+ };
55
+ };
56
+ }, {}>, Prisma.TypeMapCb<Prisma.PrismaClientOptions>, {
57
+ result: {};
58
+ model: {};
59
+ query: {};
60
+ client: {
61
+ $join: () => (config: Parameters<typeof buildJoinQuery>[0]) => Promise<unknown>;
62
+ $lock: () => {
63
+ pessimistic: (config: Parameters<typeof pessimisticLock>[1], fn: Parameters<typeof pessimisticLock>[2]) => Promise<unknown>;
64
+ optimistic: (config: Parameters<typeof optimisticLockUpdate>[1]) => Promise<any>;
65
+ };
66
+ };
67
+ }>;
68
+
69
+ export { type JoinType, OptimisticLockError, buildJoinQuery, optimisticLockUpdate, pessimisticLock, withToolKit };
package/dist/index.js ADDED
@@ -0,0 +1,128 @@
1
+ // src/joins/JoinBuilder.ts
2
+ import { Prisma } from "@prisma/client";
3
+
4
+ // src/security/security.ts
5
+ var IDENT_RE = /^[a-zA-Z_][a-zA-Z0-9_]*$/;
6
+ var MAX_IDENTIFIER_LENGTH = 63;
7
+ function validateSegment(segment) {
8
+ if (segment.length === 0) {
9
+ throw new Error("Identifier segment cannot be empty");
10
+ }
11
+ if (segment.length > MAX_IDENTIFIER_LENGTH) {
12
+ throw new Error(`Identifier "${segment}" exceeds ${MAX_IDENTIFIER_LENGTH} character limit(Postgres will silently truncate it)`);
13
+ }
14
+ if (!IDENT_RE.test(segment)) {
15
+ throw new Error(`Unsafe or invalid identifier: "${segment}"`);
16
+ }
17
+ }
18
+ function assertSafeIdentifier(name) {
19
+ validateSegment(name);
20
+ return name;
21
+ }
22
+ function assertSafeQualifiedIdentifier(name) {
23
+ const [core, ...rest] = name.split(/\s+as\s+/i);
24
+ if (rest.length > 1) {
25
+ throw new Error(`Invalid identifier expression: "${name}"`);
26
+ }
27
+ const segment = core.trim().split(".");
28
+ if (segment.length > 2) {
29
+ throw new Error(`Invalid identifier expression: "${name}"`);
30
+ }
31
+ segment.forEach(validateSegment);
32
+ if (rest.length === 1) {
33
+ validateSegment(rest[0].trim());
34
+ }
35
+ return name;
36
+ }
37
+ function quoteIdentifier(name) {
38
+ return `"${name}"`;
39
+ }
40
+
41
+ // src/joins/JoinBuilder.ts
42
+ function buildJoinQuery(config) {
43
+ const fromTable = assertSafeIdentifier(config.from);
44
+ const selectCols = (config.select ?? ["*"]).map((c) => c === "*" ? c : assertSafeQualifiedIdentifier(c)).join(", ");
45
+ let query = Prisma.sql`SELECT ${Prisma.raw(selectCols)} FROM ${Prisma.raw(`"${quoteIdentifier(fromTable)}"`)}`;
46
+ if (config.join) {
47
+ const { type, table, on } = config.join;
48
+ const joinTable = assertSafeIdentifier(table);
49
+ if (type === "CROSS") {
50
+ query = Prisma.sql`${query} CROSS JOIN ${Prisma.raw(`"${quoteIdentifier(joinTable)}"`)}`;
51
+ } else {
52
+ if (!on) throw new Error(`on Condition is required for ${type} JOIN`);
53
+ const fromCol = assertSafeIdentifier(on.fromColumn);
54
+ const toCol = assertSafeIdentifier(on.toColumn);
55
+ query = Prisma.sql`${query} ${Prisma.raw(type)} JOIN ${Prisma.raw(`"${quoteIdentifier(joinTable)}"`)} ON ${Prisma.raw(`"${quoteIdentifier(fromTable)}"."${quoteIdentifier(fromCol)}"`)} = ${Prisma.raw(`"${quoteIdentifier(joinTable)}"."${quoteIdentifier(toCol)}"`)}`;
56
+ }
57
+ }
58
+ if (config.where) {
59
+ const col = assertSafeIdentifier(config.where.column);
60
+ const op = config.where.op;
61
+ query = Prisma.sql`${query} WHERE ${Prisma.raw(`"${quoteIdentifier(col)}"`)} ${Prisma.raw(op)} ${config.where.value}`;
62
+ }
63
+ return query;
64
+ }
65
+
66
+ // src/locking/passimisticLocking.ts
67
+ import { Prisma as Prisma2 } from "@prisma/client";
68
+ async function pessimisticLock(prisma, config, fn) {
69
+ const table = assertSafeIdentifier(config.table);
70
+ const mode = config.mode ?? "FOR UPDATE";
71
+ return prisma.$transaction(
72
+ async (tx) => {
73
+ const rows = await tx.$queryRaw(
74
+ Prisma2.sql`SELECT * FROM ${Prisma2.raw(`${quoteIdentifier(table)}`)} WHERE id = ${config.id} ${Prisma2.raw(mode)}`
75
+ );
76
+ if (!rows.length) throw new Error(`Row not found in ${table} with id ${config.id}`);
77
+ return fn(tx, rows[0]);
78
+ },
79
+ { maxWait: config.maxWait ?? 5e3, timeout: config.timeout ?? 1e4 }
80
+ );
81
+ }
82
+
83
+ // src/locking/optimisticLocking.ts
84
+ var OptimisticLockError = class extends Error {
85
+ constructor(message = "Row was modified by another transaction") {
86
+ super(message);
87
+ this.name = "OptimisticLockError";
88
+ }
89
+ };
90
+ async function optimisticLockUpdate(prisma, config) {
91
+ const client = prisma;
92
+ if (!client[config.model]) {
93
+ throw new Error(`Model "${config.model}" not found on Prisma client`);
94
+ }
95
+ const result = await client[config.model].updateMany({
96
+ where: { id: config.id, version: config.expectedVersion },
97
+ data: { ...config.data, version: { increment: 1 } }
98
+ });
99
+ if (result.count === 0) {
100
+ throw new OptimisticLockError();
101
+ }
102
+ return result;
103
+ }
104
+
105
+ // src/index.ts
106
+ function withToolKit(prisma) {
107
+ return prisma.$extends({
108
+ name: "prisma-toolkit",
109
+ client: {
110
+ async $join(config) {
111
+ const sql = buildJoinQuery(config);
112
+ return prisma.$queryRaw(sql);
113
+ },
114
+ $lock: {
115
+ pessimistic: (config, fn) => pessimisticLock(prisma, config, fn),
116
+ optimistic: (config) => optimisticLockUpdate(prisma, config)
117
+ }
118
+ }
119
+ });
120
+ }
121
+ export {
122
+ OptimisticLockError,
123
+ buildJoinQuery,
124
+ optimisticLockUpdate,
125
+ pessimisticLock,
126
+ withToolKit
127
+ };
128
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/joins/JoinBuilder.ts","../src/security/security.ts","../src/locking/passimisticLocking.ts","../src/locking/optimisticLocking.ts","../src/index.ts"],"sourcesContent":["import { Prisma } from \"@prisma/client\"\r\nimport { assertSafeIdentifier, assertSafeQualifiedIdentifier, quoteIdentifier } from \"../security/security\"\r\n\r\nexport type JoinType = \"INNER\" | \"LEFT\" | \"RIGHT\" | \"FULL OUTER\" | \"CROSS\"\r\n\r\ninterface JoinCondition {\r\n fromColumn: string\r\n toColumn: string\r\n}\r\n\r\ninterface JoinConfig {\r\n from: string\r\n select?: string[]\r\n join?: {\r\n type: JoinType,\r\n table: string,\r\n on?: JoinCondition\r\n }\r\n where?: {\r\n column: string\r\n op: '=' | '!=' | '>' | '<' | '>=' | '<=';\r\n value: any\r\n }\r\n}\r\n\r\n\r\nexport function buildJoinQuery(config: JoinConfig): Prisma.Sql {\r\n const fromTable = assertSafeIdentifier(config.from)\r\n const selectCols = (config.select ?? ['*']).map((c) => (c === '*' ? c : assertSafeQualifiedIdentifier(c))).join(', ')\r\n let query = Prisma.sql`SELECT ${Prisma.raw(selectCols)} FROM ${Prisma.raw(`\"${quoteIdentifier(fromTable)}\"`)}`;\r\n if (config.join) {\r\n const { type, table, on } = config.join\r\n const joinTable = assertSafeIdentifier(table)\r\n if (type === \"CROSS\") {\r\n query = Prisma.sql`${query} CROSS JOIN ${Prisma.raw(`\"${quoteIdentifier(joinTable)}\"`)}`;\r\n }\r\n else {\r\n if (!on) throw new Error(`on Condition is required for ${type} JOIN`)\r\n const fromCol = assertSafeIdentifier(on.fromColumn)\r\n const toCol = assertSafeIdentifier(on.toColumn)\r\n query = Prisma.sql`${query} ${Prisma.raw(type)} JOIN ${Prisma.raw(`\"${quoteIdentifier(joinTable)}\"`)} ON ${Prisma.raw(`\"${quoteIdentifier(fromTable)}\".\"${quoteIdentifier(fromCol)}\"`)} = ${Prisma.raw(`\"${quoteIdentifier(joinTable)}\".\"${quoteIdentifier(toCol)}\"`)}`;\r\n }\r\n }\r\n if (config.where) {\r\n const col = assertSafeIdentifier(config.where.column)\r\n const op = config.where.op\r\n query = Prisma.sql`${query} WHERE ${Prisma.raw(`\"${quoteIdentifier(col)}\"`)} ${Prisma.raw(op)} ${config.where.value}`\r\n }\r\n return query\r\n}\r\n\r\n\r\n","const IDENT_RE = /^[a-zA-Z_][a-zA-Z0-9_]*$/;\r\nconst MAX_IDENTIFIER_LENGTH = 63\r\n\r\nfunction validateSegment(segment: string): void {\r\n if (segment.length === 0) {\r\n throw new Error(\"Identifier segment cannot be empty\")\r\n }\r\n if (segment.length > MAX_IDENTIFIER_LENGTH) {\r\n throw new Error(`Identifier \"${segment}\" exceeds ${MAX_IDENTIFIER_LENGTH} character limit(Postgres will silently truncate it)`)\r\n }\r\n if (!IDENT_RE.test(segment)) {\r\n throw new Error(`Unsafe or invalid identifier: \"${segment}\"`)\r\n }\r\n}\r\n\r\nexport function assertSafeIdentifier(name: string): string {\r\n validateSegment(name)\r\n return name\r\n}\r\n\r\nexport function assertSafeQualifiedIdentifier(name: string): string {\r\n const [core, ...rest] = name.split(/\\s+as\\s+/i)\r\n if (rest.length > 1) {\r\n throw new Error(`Invalid identifier expression: \"${name}\"`)\r\n }\r\n const segment = core.trim().split(\".\")\r\n if (segment.length > 2) {\r\n throw new Error(`Invalid identifier expression: \"${name}\"`)\r\n }\r\n segment.forEach(validateSegment)\r\n\r\n if (rest.length === 1) {\r\n validateSegment(rest[0].trim())\r\n }\r\n return name\r\n}\r\n\r\nexport function quoteIdentifier(name: string): string {\r\n return `\"${name}\"`\r\n}","import { Prisma, PrismaClient } from \"@prisma/client\"\r\nimport { assertSafeIdentifier, quoteIdentifier } from \"../security/security\"\r\n\r\ninterface PessimisticLockConfig {\r\n table: string\r\n id: string | number\r\n mode?: \"FOR UPDATE\" | \"FOR SHARE\"\r\n maxWait?: number\r\n timeout?: number\r\n\r\n}\r\n\r\nexport async function pessimisticLock<T>(\r\n prisma: PrismaClient,\r\n config: PessimisticLockConfig,\r\n fn: (tx: Prisma.TransactionClient, row: any) => Promise<T>\r\n): Promise<T> {\r\n const table = assertSafeIdentifier(config.table)\r\n const mode = config.mode ?? \"FOR UPDATE\"\r\n return prisma.$transaction(async (tx) => {\r\n const rows: any[] = await tx.$queryRaw(\r\n Prisma.sql`SELECT * FROM ${Prisma.raw(`${quoteIdentifier(table)}`)} WHERE id = ${config.id} ${Prisma.raw(mode)}`\r\n )\r\n if (!rows.length) throw new Error(`Row not found in ${table} with id ${config.id}`)\r\n return fn(tx, rows[0])\r\n },\r\n { maxWait: config.maxWait ?? 5000, timeout: config.timeout ?? 10000 }\r\n )\r\n}","import { PrismaClient } from \"@prisma/client\";\r\n\r\nexport class OptimisticLockError extends Error {\r\n constructor(message = \"Row was modified by another transaction\") {\r\n super(message)\r\n this.name = \"OptimisticLockError\"\r\n }\r\n}\r\n\r\ninterface OptimisticLockConfig {\r\n model: string\r\n id: number\r\n expectedVersion: number\r\n data: Record<string, any>\r\n}\r\n\r\nexport async function optimisticLockUpdate(\r\n prisma: PrismaClient,\r\n config: OptimisticLockConfig\r\n) {\r\n const client = prisma as any\r\n if (!client[config.model]) {\r\n throw new Error(`Model \"${config.model}\" not found on Prisma client`)\r\n }\r\n const result = await client[config.model].updateMany({\r\n where: { id: config.id, version: config.expectedVersion },\r\n data: { ...config.data, version: { increment: 1 } }\r\n })\r\n if (result.count === 0) {\r\n throw new OptimisticLockError()\r\n }\r\n return result\r\n}","import { PrismaClient, Prisma } from \"@prisma/client\"\r\nimport { buildJoinQuery } from \"./joins/JoinBuilder\"\r\nimport { pessimisticLock } from \"./locking/passimisticLocking\"\r\nimport { optimisticLockUpdate } from \"./locking/optimisticLocking\"\r\n\r\n\r\nexport function withToolKit(\r\n prisma: PrismaClient\r\n) {\r\n return prisma.$extends({\r\n name: \"prisma-toolkit\",\r\n client: {\r\n async $join(config: Parameters<typeof buildJoinQuery>[0]) {\r\n const sql = buildJoinQuery(config)\r\n return prisma.$queryRaw(sql)\r\n },\r\n $lock: {\r\n pessimistic: (config: Parameters<typeof pessimisticLock>[1], fn: Parameters<typeof pessimisticLock>[2]) => pessimisticLock(prisma, config, fn),\r\n optimistic: (config: Parameters<typeof optimisticLockUpdate>[1]) => optimisticLockUpdate(prisma, config)\r\n }\r\n }\r\n })\r\n}\r\n\r\nexport { buildJoinQuery } from \"./joins/JoinBuilder\"\r\nexport { pessimisticLock } from \"./locking/passimisticLocking\"\r\nexport { optimisticLockUpdate, OptimisticLockError } from \"./locking/optimisticLocking\"\r\nexport type { JoinType } from \"./joins/JoinBuilder\""],"mappings":"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","names":["Prisma","Prisma"]}
package/package.json ADDED
@@ -0,0 +1,50 @@
1
+ {
2
+ "name": "prisma-pg-toolkit",
3
+ "version": "1.0.0",
4
+ "description": "Joins (INNER/LEFT/RIGHT/FULL OUTER/CROSS) and locking (pessimistic/optimistic) helpers for Prisma",
5
+ "type": "module",
6
+ "main": "dist/index.cjs",
7
+ "module": "dist/index.js",
8
+ "types": "dist/index.d.ts",
9
+ "exports": {
10
+ ".": {
11
+ "types": "./dist/index.d.ts",
12
+ "import": "./dist/index.js",
13
+ "require": "./dist/index.cjs"
14
+ }
15
+ },
16
+ "files": [
17
+ "dist",
18
+ "src"
19
+ ],
20
+ "scripts": {
21
+ "dev": "tsx src/testing/test-joins.ts",
22
+ "prisma:migrate": "npx prisma migrate dev --name init",
23
+ "prisma:generate": "npx prisma generate",
24
+ "seed": "tsx prisma/seed.ts",
25
+ "build": "tsup",
26
+ "prepublishOnly": "npm run build"
27
+ },
28
+ "keywords": [
29
+ "prisma",
30
+ "joins",
31
+ "locking",
32
+ "optimistic-locking",
33
+ "pessimistic-locking"
34
+ ],
35
+ "author": "",
36
+ "license": "MIT",
37
+ "peerDependencies": {
38
+ "@prisma/client": ">=5.0.0"
39
+ },
40
+ "devDependencies": {
41
+ "@types/node": "^22.0.0",
42
+ "@prisma/client": "^6.19.3",
43
+ "prisma": "^6.19.3",
44
+ "ts-node": "^10.9.2",
45
+ "ts-node-dev": "^2.0.0",
46
+ "tsup": "^8.5.1",
47
+ "tsx": "^4.23.0",
48
+ "typescript": "^5.7.0"
49
+ }
50
+ }
package/src/index.ts ADDED
@@ -0,0 +1,28 @@
1
+ import { PrismaClient, Prisma } from "@prisma/client"
2
+ import { buildJoinQuery } from "./joins/JoinBuilder"
3
+ import { pessimisticLock } from "./locking/passimisticLocking"
4
+ import { optimisticLockUpdate } from "./locking/optimisticLocking"
5
+
6
+
7
+ export function withToolKit(
8
+ prisma: PrismaClient
9
+ ) {
10
+ return prisma.$extends({
11
+ name: "prisma-toolkit",
12
+ client: {
13
+ async $join(config: Parameters<typeof buildJoinQuery>[0]) {
14
+ const sql = buildJoinQuery(config)
15
+ return prisma.$queryRaw(sql)
16
+ },
17
+ $lock: {
18
+ pessimistic: (config: Parameters<typeof pessimisticLock>[1], fn: Parameters<typeof pessimisticLock>[2]) => pessimisticLock(prisma, config, fn),
19
+ optimistic: (config: Parameters<typeof optimisticLockUpdate>[1]) => optimisticLockUpdate(prisma, config)
20
+ }
21
+ }
22
+ })
23
+ }
24
+
25
+ export { buildJoinQuery } from "./joins/JoinBuilder"
26
+ export { pessimisticLock } from "./locking/passimisticLocking"
27
+ export { optimisticLockUpdate, OptimisticLockError } from "./locking/optimisticLocking"
28
+ export type { JoinType } from "./joins/JoinBuilder"
@@ -0,0 +1,52 @@
1
+ import { Prisma } from "@prisma/client"
2
+ import { assertSafeIdentifier, assertSafeQualifiedIdentifier, quoteIdentifier } from "../security/security"
3
+
4
+ export type JoinType = "INNER" | "LEFT" | "RIGHT" | "FULL OUTER" | "CROSS"
5
+
6
+ interface JoinCondition {
7
+ fromColumn: string
8
+ toColumn: string
9
+ }
10
+
11
+ interface JoinConfig {
12
+ from: string
13
+ select?: string[]
14
+ join?: {
15
+ type: JoinType,
16
+ table: string,
17
+ on?: JoinCondition
18
+ }
19
+ where?: {
20
+ column: string
21
+ op: '=' | '!=' | '>' | '<' | '>=' | '<=';
22
+ value: any
23
+ }
24
+ }
25
+
26
+
27
+ export function buildJoinQuery(config: JoinConfig): Prisma.Sql {
28
+ const fromTable = assertSafeIdentifier(config.from)
29
+ const selectCols = (config.select ?? ['*']).map((c) => (c === '*' ? c : assertSafeQualifiedIdentifier(c))).join(', ')
30
+ let query = Prisma.sql`SELECT ${Prisma.raw(selectCols)} FROM ${Prisma.raw(`"${quoteIdentifier(fromTable)}"`)}`;
31
+ if (config.join) {
32
+ const { type, table, on } = config.join
33
+ const joinTable = assertSafeIdentifier(table)
34
+ if (type === "CROSS") {
35
+ query = Prisma.sql`${query} CROSS JOIN ${Prisma.raw(`"${quoteIdentifier(joinTable)}"`)}`;
36
+ }
37
+ else {
38
+ if (!on) throw new Error(`on Condition is required for ${type} JOIN`)
39
+ const fromCol = assertSafeIdentifier(on.fromColumn)
40
+ const toCol = assertSafeIdentifier(on.toColumn)
41
+ query = Prisma.sql`${query} ${Prisma.raw(type)} JOIN ${Prisma.raw(`"${quoteIdentifier(joinTable)}"`)} ON ${Prisma.raw(`"${quoteIdentifier(fromTable)}"."${quoteIdentifier(fromCol)}"`)} = ${Prisma.raw(`"${quoteIdentifier(joinTable)}"."${quoteIdentifier(toCol)}"`)}`;
42
+ }
43
+ }
44
+ if (config.where) {
45
+ const col = assertSafeIdentifier(config.where.column)
46
+ const op = config.where.op
47
+ query = Prisma.sql`${query} WHERE ${Prisma.raw(`"${quoteIdentifier(col)}"`)} ${Prisma.raw(op)} ${config.where.value}`
48
+ }
49
+ return query
50
+ }
51
+
52
+
@@ -0,0 +1,33 @@
1
+ import { PrismaClient } from "@prisma/client";
2
+
3
+ export class OptimisticLockError extends Error {
4
+ constructor(message = "Row was modified by another transaction") {
5
+ super(message)
6
+ this.name = "OptimisticLockError"
7
+ }
8
+ }
9
+
10
+ interface OptimisticLockConfig {
11
+ model: string
12
+ id: number
13
+ expectedVersion: number
14
+ data: Record<string, any>
15
+ }
16
+
17
+ export async function optimisticLockUpdate(
18
+ prisma: PrismaClient,
19
+ config: OptimisticLockConfig
20
+ ) {
21
+ const client = prisma as any
22
+ if (!client[config.model]) {
23
+ throw new Error(`Model "${config.model}" not found on Prisma client`)
24
+ }
25
+ const result = await client[config.model].updateMany({
26
+ where: { id: config.id, version: config.expectedVersion },
27
+ data: { ...config.data, version: { increment: 1 } }
28
+ })
29
+ if (result.count === 0) {
30
+ throw new OptimisticLockError()
31
+ }
32
+ return result
33
+ }
@@ -0,0 +1,29 @@
1
+ import { Prisma, PrismaClient } from "@prisma/client"
2
+ import { assertSafeIdentifier, quoteIdentifier } from "../security/security"
3
+
4
+ interface PessimisticLockConfig {
5
+ table: string
6
+ id: string | number
7
+ mode?: "FOR UPDATE" | "FOR SHARE"
8
+ maxWait?: number
9
+ timeout?: number
10
+
11
+ }
12
+
13
+ export async function pessimisticLock<T>(
14
+ prisma: PrismaClient,
15
+ config: PessimisticLockConfig,
16
+ fn: (tx: Prisma.TransactionClient, row: any) => Promise<T>
17
+ ): Promise<T> {
18
+ const table = assertSafeIdentifier(config.table)
19
+ const mode = config.mode ?? "FOR UPDATE"
20
+ return prisma.$transaction(async (tx) => {
21
+ const rows: any[] = await tx.$queryRaw(
22
+ Prisma.sql`SELECT * FROM ${Prisma.raw(`${quoteIdentifier(table)}`)} WHERE id = ${config.id} ${Prisma.raw(mode)}`
23
+ )
24
+ if (!rows.length) throw new Error(`Row not found in ${table} with id ${config.id}`)
25
+ return fn(tx, rows[0])
26
+ },
27
+ { maxWait: config.maxWait ?? 5000, timeout: config.timeout ?? 10000 }
28
+ )
29
+ }
@@ -0,0 +1,40 @@
1
+ const IDENT_RE = /^[a-zA-Z_][a-zA-Z0-9_]*$/;
2
+ const MAX_IDENTIFIER_LENGTH = 63
3
+
4
+ function validateSegment(segment: string): void {
5
+ if (segment.length === 0) {
6
+ throw new Error("Identifier segment cannot be empty")
7
+ }
8
+ if (segment.length > MAX_IDENTIFIER_LENGTH) {
9
+ throw new Error(`Identifier "${segment}" exceeds ${MAX_IDENTIFIER_LENGTH} character limit(Postgres will silently truncate it)`)
10
+ }
11
+ if (!IDENT_RE.test(segment)) {
12
+ throw new Error(`Unsafe or invalid identifier: "${segment}"`)
13
+ }
14
+ }
15
+
16
+ export function assertSafeIdentifier(name: string): string {
17
+ validateSegment(name)
18
+ return name
19
+ }
20
+
21
+ export function assertSafeQualifiedIdentifier(name: string): string {
22
+ const [core, ...rest] = name.split(/\s+as\s+/i)
23
+ if (rest.length > 1) {
24
+ throw new Error(`Invalid identifier expression: "${name}"`)
25
+ }
26
+ const segment = core.trim().split(".")
27
+ if (segment.length > 2) {
28
+ throw new Error(`Invalid identifier expression: "${name}"`)
29
+ }
30
+ segment.forEach(validateSegment)
31
+
32
+ if (rest.length === 1) {
33
+ validateSegment(rest[0].trim())
34
+ }
35
+ return name
36
+ }
37
+
38
+ export function quoteIdentifier(name: string): string {
39
+ return `"${name}"`
40
+ }
@@ -0,0 +1,21 @@
1
+ import { Prisma, PrismaClient } from "@prisma/client"
2
+
3
+ const prisma = new PrismaClient()
4
+
5
+
6
+ async function runJoin(label: string, sql: Prisma.Sql) {
7
+ const rows = await prisma.$queryRaw(sql)
8
+ console.log(`\n--- ${label} (${(rows as any[]).length} rows) ---`)
9
+ console.table(rows)
10
+ }
11
+
12
+ async function main() {
13
+ await runJoin("INNER JOIN (expect 3)", Prisma.sql`SELECT u.email, p.title FROM "User" u INNER JOIN "Post" p ON p."userId" = u.id`)
14
+ await runJoin("LEFT JOIN (expect 4, + TOM)", Prisma.sql`SELECT u.email, p.title FROM "User" u LEFT JOIN "Post" p ON p."userId" = u.id`)
15
+ await runJoin("RIGHT JOIN (expect 4 + Abandoned Post)", Prisma.sql`SELECT u.email,p.title FROM "User" u RIGHT JOIN "Post" p ON p."userId" = u.id`)
16
+ await runJoin("FULL OUTER JOIN (expect 5", Prisma.sql`SELECT u.email, p.title FROM "User" u FULL OUTER JOIN "Post" p ON p."userId" = u.id`);
17
+ await runJoin("CROS JOIN (expect 4 users * 4 posts = 16", Prisma.sql`SELECT u.email, p.title FROM "User" u CROSS JOIN "Post" p`)
18
+ }
19
+
20
+ main().then(() => console.log("Main function exexuted")).catch((err) => console.log(err)).finally(() => prisma.$disconnect())
21
+
@@ -0,0 +1,21 @@
1
+ import { PrismaClient } from "@prisma/client";
2
+ import { withToolKit } from "../index.js";
3
+
4
+ const base = new PrismaClient()
5
+ const prisma = withToolKit(base)
6
+
7
+ async function main() {
8
+ const data: any = {
9
+ from: "User",
10
+ select: ["title", "content"],
11
+ join: {
12
+ type: "CROSS",
13
+ table: "Post",
14
+ on: { fromColumn: "id", toColumn: "userId" }
15
+ }
16
+ }
17
+ const result = await prisma.$join(data)
18
+ return result
19
+ }
20
+
21
+ main().then((res) => console.table(res)).catch((err) => console.error(err)).finally(() => base.$disconnect())
@@ -0,0 +1,33 @@
1
+ import { PrismaClient } from "@prisma/client"
2
+ import { withToolKit } from ".."
3
+
4
+ const base = new PrismaClient()
5
+ const prisma = withToolKit(base)
6
+
7
+ async function main() {
8
+ const user = await prisma.user.findFirst({ where: { email: "tom1@gmail.com" } })
9
+ if (!user) throw new Error("emailId not found")
10
+ console.log("current version ", user.version)
11
+
12
+ const success = await prisma.$lock.optimistic({
13
+ model: "user",
14
+ id: user.id,
15
+ expectedVersion: user.version,
16
+ data: { name: "Updated Tom" }
17
+ })
18
+ console.log("Updated with new version successded ", success)
19
+
20
+ try {
21
+ const test2 = await prisma.$lock.optimistic({
22
+ model: "user",
23
+ id: user.id,
24
+ expectedVersion: user.version,
25
+ data: { name: "This should not apply" }
26
+ })
27
+ if (!test2) console.log('ERROR: stale update should NOT have succeeded');
28
+ console.log("this is new version", test2)
29
+ } catch (error: any) {
30
+ console.log('Correctly rejected stale update:', error.message);
31
+ }
32
+ }
33
+ main().then((res) => console.table(res)).catch((err) => console.error(err)).finally(() => base.$disconnect())
@@ -0,0 +1,47 @@
1
+ import { PrismaClient } from "@prisma/client"
2
+ import { withToolKit } from ".."
3
+
4
+ const urlLink = process.env.DIRECT_URL! as string
5
+ if (!urlLink) throw new Error("DIRECT_URL not exist")
6
+
7
+ const base = new PrismaClient({
8
+ datasources: { db: { url: urlLink } }
9
+ })
10
+ const prisma = withToolKit(base)
11
+
12
+ function sleep(ms: number) {
13
+ return new Promise((res) => setTimeout(res, ms))
14
+ }
15
+
16
+ async function main() {
17
+ const user = await prisma.user.findFirst({ where: { email: "tom1@gmail.com" } })
18
+ if (!user) throw new Error("Email Id not found for user")
19
+ const log = (label: string) => console.log(`[${new Date().toISOString()}] ${label}`)
20
+ const txa = prisma.$lock.pessimistic({ table: "User", id: user.id, mode: "FOR UPDATE" }, async (tx, row) => {
21
+ log("TX A: acquired lock")
22
+ await sleep(3000)
23
+ log("TX A: releasing lock (transaction committing)")
24
+ return row
25
+ })
26
+
27
+ const txb = (async () => {
28
+ await sleep(200)
29
+ log('TX B: attempting to acquire lock (should block here)');
30
+ return prisma.$lock.pessimistic({
31
+ table: "User",
32
+ id: user.id,
33
+ mode: "FOR UPDATE"
34
+ },
35
+ async function (tx, row) {
36
+ log("TX B: acquired lock (A must have released it)")
37
+ return row
38
+ }
39
+ )
40
+ })()
41
+ await txa
42
+ await txb
43
+ log("Both transactions complete")
44
+
45
+
46
+ }
47
+ main().then((res) => console.log(res)).catch((err) => console.error(err)).finally(() => prisma.$disconnect())