From 6e5b6d5be11ffbe749390af931cad9aa07b96f94 Mon Sep 17 00:00:00 2001 From: Chris Harris Date: Fri, 6 Mar 2026 07:36:57 -0600 Subject: [PATCH 1/3] feat(prisma): add sqlcommenter-prisma package SQLCommenter integration for Prisma ORM via driver adapter wrapping. Automatically appends query metadata (db_driver, route, method, file) as SQL comments to all Prisma queries. Key design decisions: - wrapAdapter patches the adapter in-place (not Object.create) to preserve Prisma's error registry object identity - wrapAdapterFactory supports Prisma 6+ SqlDriverAdapterFactory pattern where PrismaPg.connect() returns the SqlDriverAdapter - Bridges ALS context across Prisma's WASM engine boundary via module-level variables, since AsyncLocalStorage is lost when the Rust engine calls back into JS Co-Authored-By: Claude Opus 4.6 --- .../packages/sqlcommenter-prisma/LICENSE | 202 ++++++ .../sqlcommenter-prisma/package-lock.json | 686 ++++++++++++++++++ .../packages/sqlcommenter-prisma/package.json | 65 ++ .../sqlcommenter-prisma/src/adapter.ts | 176 +++++ .../packages/sqlcommenter-prisma/src/als.ts | 36 + .../sqlcommenter-prisma/src/extension.ts | 41 ++ .../packages/sqlcommenter-prisma/src/http.ts | 16 + .../packages/sqlcommenter-prisma/src/index.ts | 5 + .../sqlcommenter-prisma/src/path-trace.ts | 37 + .../packages/sqlcommenter-prisma/src/path.ts | 80 ++ .../src/request-context.ts | 18 + .../sqlcommenter-prisma/src/sqlcommenter.ts | 52 ++ .../sqlcommenter-prisma/src/tracing.ts | 14 + .../sqlcommenter-prisma/test/adapter.spec.ts | 177 +++++ .../sqlcommenter-prisma/test/path.spec.ts | 135 ++++ .../sqlcommenter-prisma/tsconfig.cjs.json | 8 + .../sqlcommenter-prisma/tsconfig.json | 17 + 17 files changed, 1765 insertions(+) create mode 100644 nodejs/sqlcommenter-nodejs/packages/sqlcommenter-prisma/LICENSE create mode 100644 nodejs/sqlcommenter-nodejs/packages/sqlcommenter-prisma/package-lock.json create mode 100644 nodejs/sqlcommenter-nodejs/packages/sqlcommenter-prisma/package.json create mode 100644 nodejs/sqlcommenter-nodejs/packages/sqlcommenter-prisma/src/adapter.ts create mode 100644 nodejs/sqlcommenter-nodejs/packages/sqlcommenter-prisma/src/als.ts create mode 100644 nodejs/sqlcommenter-nodejs/packages/sqlcommenter-prisma/src/extension.ts create mode 100644 nodejs/sqlcommenter-nodejs/packages/sqlcommenter-prisma/src/http.ts create mode 100644 nodejs/sqlcommenter-nodejs/packages/sqlcommenter-prisma/src/index.ts create mode 100644 nodejs/sqlcommenter-nodejs/packages/sqlcommenter-prisma/src/path-trace.ts create mode 100644 nodejs/sqlcommenter-nodejs/packages/sqlcommenter-prisma/src/path.ts create mode 100644 nodejs/sqlcommenter-nodejs/packages/sqlcommenter-prisma/src/request-context.ts create mode 100644 nodejs/sqlcommenter-nodejs/packages/sqlcommenter-prisma/src/sqlcommenter.ts create mode 100644 nodejs/sqlcommenter-nodejs/packages/sqlcommenter-prisma/src/tracing.ts create mode 100644 nodejs/sqlcommenter-nodejs/packages/sqlcommenter-prisma/test/adapter.spec.ts create mode 100644 nodejs/sqlcommenter-nodejs/packages/sqlcommenter-prisma/test/path.spec.ts create mode 100644 nodejs/sqlcommenter-nodejs/packages/sqlcommenter-prisma/tsconfig.cjs.json create mode 100644 nodejs/sqlcommenter-nodejs/packages/sqlcommenter-prisma/tsconfig.json diff --git a/nodejs/sqlcommenter-nodejs/packages/sqlcommenter-prisma/LICENSE b/nodejs/sqlcommenter-nodejs/packages/sqlcommenter-prisma/LICENSE new file mode 100644 index 00000000..d6456956 --- /dev/null +++ b/nodejs/sqlcommenter-nodejs/packages/sqlcommenter-prisma/LICENSE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/nodejs/sqlcommenter-nodejs/packages/sqlcommenter-prisma/package-lock.json b/nodejs/sqlcommenter-nodejs/packages/sqlcommenter-prisma/package-lock.json new file mode 100644 index 00000000..7f9a1fd3 --- /dev/null +++ b/nodejs/sqlcommenter-nodejs/packages/sqlcommenter-prisma/package-lock.json @@ -0,0 +1,686 @@ +{ + "name": "@query-doctor/sqlcommenter-prisma", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@query-doctor/sqlcommenter-prisma", + "version": "0.1.0", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/api": "~1.9.0" + }, + "devDependencies": { + "@types/node": "^20.19.34", + "tsx": "^4.20.5", + "typescript": "^5.9.3" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "@opentelemetry/core": ">=1.0.0", + "@prisma/client": ">=5.4.0", + "@prisma/driver-adapter-utils": ">=5.4.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz", + "integrity": "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.3.tgz", + "integrity": "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.3.tgz", + "integrity": "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.3.tgz", + "integrity": "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.3.tgz", + "integrity": "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.3.tgz", + "integrity": "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.3.tgz", + "integrity": "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.3.tgz", + "integrity": "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.3.tgz", + "integrity": "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.3.tgz", + "integrity": "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.3.tgz", + "integrity": "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.3.tgz", + "integrity": "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.3.tgz", + "integrity": "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.3.tgz", + "integrity": "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.3.tgz", + "integrity": "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.3.tgz", + "integrity": "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.3.tgz", + "integrity": "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.3.tgz", + "integrity": "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.3.tgz", + "integrity": "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.3.tgz", + "integrity": "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.3.tgz", + "integrity": "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.3.tgz", + "integrity": "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.3.tgz", + "integrity": "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.3.tgz", + "integrity": "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.3.tgz", + "integrity": "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.3.tgz", + "integrity": "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@opentelemetry/api": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz", + "integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==", + "license": "Apache-2.0", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/@opentelemetry/core": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.6.0.tgz", + "integrity": "sha512-HLM1v2cbZ4TgYN6KEOj+Bbj8rAKriOdkF9Ed3tG25FoprSiQl7kYc+RRT6fUZGOvx0oMi5U67GoFdT+XUn8zEg==", + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/semantic-conventions": { + "version": "1.40.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.40.0.tgz", + "integrity": "sha512-cifvXDhcqMwwTlTK04GBNeIe7yyo28Mfby85QXFe1Yk8nmi36Ab/5UQwptOx84SsoGNRg+EVSjwzfSZMy6pmlw==", + "license": "Apache-2.0", + "peer": true, + "engines": { + "node": ">=14" + } + }, + "node_modules/@prisma/client": { + "version": "7.4.2", + "resolved": "https://registry.npmjs.org/@prisma/client/-/client-7.4.2.tgz", + "integrity": "sha512-ts2mu+cQHriAhSxngO3StcYubBGTWDtu/4juZhXCUKOwgh26l+s4KD3vT2kMUzFyrYnll9u/3qWrtzRv9CGWzA==", + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "@prisma/client-runtime-utils": "7.4.2" + }, + "engines": { + "node": "^20.19 || ^22.12 || >=24.0" + }, + "peerDependencies": { + "prisma": "*", + "typescript": ">=5.4.0" + }, + "peerDependenciesMeta": { + "prisma": { + "optional": true + }, + "typescript": { + "optional": true + } + } + }, + "node_modules/@prisma/client-runtime-utils": { + "version": "7.4.2", + "resolved": "https://registry.npmjs.org/@prisma/client-runtime-utils/-/client-runtime-utils-7.4.2.tgz", + "integrity": "sha512-cID+rzOEb38VyMsx5LwJMEY4NGIrWCNpKu/0ImbeooQ2Px7TI+kOt7cm0NelxUzF2V41UVVXAmYjANZQtCu1/Q==", + "license": "Apache-2.0", + "peer": true + }, + "node_modules/@prisma/debug": { + "version": "7.4.2", + "resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-7.4.2.tgz", + "integrity": "sha512-aP7qzu+g/JnbF6U69LMwHoUkELiserKmWsE2shYuEpNUJ4GrtxBCvZwCyCBHFSH2kLTF2l1goBlBh4wuvRq62w==", + "license": "Apache-2.0", + "peer": true + }, + "node_modules/@prisma/driver-adapter-utils": { + "version": "7.4.2", + "resolved": "https://registry.npmjs.org/@prisma/driver-adapter-utils/-/driver-adapter-utils-7.4.2.tgz", + "integrity": "sha512-REdjFpT/ye9KdDs+CXAXPIbMQkVLhne9G5Pe97sNY4Ovx4r2DAbWM9hOFvvB1Oq8H8bOCdu0Ri3AoGALquQqVw==", + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "@prisma/debug": "7.4.2" + } + }, + "node_modules/@types/node": { + "version": "20.19.37", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.37.tgz", + "integrity": "sha512-8kzdPJ3FsNsVIurqBs7oodNnCEVbni9yUEkaHbgptDACOPW04jimGagZ51E6+lXUwJjgnBw+hyko/lkFWCldqw==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/esbuild": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz", + "integrity": "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.3", + "@esbuild/android-arm": "0.27.3", + "@esbuild/android-arm64": "0.27.3", + "@esbuild/android-x64": "0.27.3", + "@esbuild/darwin-arm64": "0.27.3", + "@esbuild/darwin-x64": "0.27.3", + "@esbuild/freebsd-arm64": "0.27.3", + "@esbuild/freebsd-x64": "0.27.3", + "@esbuild/linux-arm": "0.27.3", + "@esbuild/linux-arm64": "0.27.3", + "@esbuild/linux-ia32": "0.27.3", + "@esbuild/linux-loong64": "0.27.3", + "@esbuild/linux-mips64el": "0.27.3", + "@esbuild/linux-ppc64": "0.27.3", + "@esbuild/linux-riscv64": "0.27.3", + "@esbuild/linux-s390x": "0.27.3", + "@esbuild/linux-x64": "0.27.3", + "@esbuild/netbsd-arm64": "0.27.3", + "@esbuild/netbsd-x64": "0.27.3", + "@esbuild/openbsd-arm64": "0.27.3", + "@esbuild/openbsd-x64": "0.27.3", + "@esbuild/openharmony-arm64": "0.27.3", + "@esbuild/sunos-x64": "0.27.3", + "@esbuild/win32-arm64": "0.27.3", + "@esbuild/win32-ia32": "0.27.3", + "@esbuild/win32-x64": "0.27.3" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/get-tsconfig": { + "version": "4.13.6", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.6.tgz", + "integrity": "sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, + "node_modules/tsx": { + "version": "4.21.0", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", + "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.27.0", + "get-tsconfig": "^4.7.5" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "devOptional": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/nodejs/sqlcommenter-nodejs/packages/sqlcommenter-prisma/package.json b/nodejs/sqlcommenter-nodejs/packages/sqlcommenter-prisma/package.json new file mode 100644 index 00000000..ee72f5c6 --- /dev/null +++ b/nodejs/sqlcommenter-nodejs/packages/sqlcommenter-prisma/package.json @@ -0,0 +1,65 @@ +{ + "name": "@query-doctor/sqlcommenter-prisma", + "version": "0.1.0", + "description": "SQLCommenter integration for Prisma ORM via driver adapter wrapping", + "main": "dist/cjs/index.js", + "type": "module", + "types": "dist/esm/index.d.ts", + "files": [ + "dist" + ], + "exports": { + ".": { + "import": { + "types": "./dist/esm/index.d.ts", + "default": "./dist/esm/index.js" + }, + "require": { + "types": "./dist/cjs/index.d.ts", + "default": "./dist/cjs/index.js" + } + }, + "./http": { + "import": { + "types": "./dist/esm/http.d.ts", + "default": "./dist/esm/http.js" + }, + "require": { + "types": "./dist/cjs/http.d.ts", + "default": "./dist/cjs/http.js" + } + } + }, + "devDependencies": { + "@types/node": "^20.19.34", + "tsx": "^4.20.5", + "typescript": "^5.9.3" + }, + "dependencies": { + "@opentelemetry/api": "~1.9.0" + }, + "peerDependencies": { + "@opentelemetry/core": ">=1.0.0", + "@prisma/client": ">=5.4.0", + "@prisma/driver-adapter-utils": ">=5.4.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "scripts": { + "test": "node --import=tsx --test test/**/*.spec.ts", + "build": "tsc -p tsconfig.json && tsc -p tsconfig.cjs.json && echo '{\"type\":\"commonjs\"}' > dist/cjs/package.json" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/query-doctor/sqlcommenter.git" + }, + "publishConfig": { + "access": "public", + "registry": "https://registry.npmjs.org" + }, + "author": "Query Doctor", + "license": "Apache-2.0", + "packageManager": "npm@11.9.0", + "module": "dist/esm/index.js" +} diff --git a/nodejs/sqlcommenter-nodejs/packages/sqlcommenter-prisma/src/adapter.ts b/nodejs/sqlcommenter-nodejs/packages/sqlcommenter-prisma/src/adapter.ts new file mode 100644 index 00000000..f763e0ce --- /dev/null +++ b/nodejs/sqlcommenter-nodejs/packages/sqlcommenter-prisma/src/adapter.ts @@ -0,0 +1,176 @@ +import { + queryContextAls, + requestContextAls, + bridgedQueryContext, + bridgedRequestContext, +} from "./als.js"; +import { alreadyHasComment, serializeTags, type Tag } from "./sqlcommenter.js"; +import { pushW3CTraceContext } from "./tracing.js"; + +const SQLCOMMENTER_ARRAY_ELEM_DELIMITER = ";"; + +const WellKnownFields = { + dbDriver: "db_driver", + file: "file", +} as const; + +/** + * Builds the sqlcommenter tag string from the current ALS context, + * falling back to bridged context when ALS is unavailable (e.g. after + * crossing Prisma's WASM engine boundary). + */ +function buildComment(): string { + const queryContext = queryContextAls.getStore() ?? bridgedQueryContext; + const requestContext = requestContextAls.getStore() ?? bridgedRequestContext; + + const tags: Tag[] = [[WellKnownFields.dbDriver, "prisma"]]; + + pushW3CTraceContext(tags); + + if (queryContext && queryContext.queryStack.length > 0) { + tags.push([ + WellKnownFields.file, + queryContext.queryStack.join(SQLCOMMENTER_ARRAY_ELEM_DELIMITER), + ]); + } + + if (requestContext) { + for (const key in requestContext) { + tags.push([key, String(requestContext[key])]); + } + } + + return serializeTags(tags); +} + +/** + * Minimal type for the query params passed to queryRaw/executeRaw. + * Compatible with @prisma/driver-adapter-utils SqlQuery. + */ +interface QueryParams { + sql: string; + args?: unknown[]; +} + +/** + * Minimal type for the result returned by queryRaw. + * Compatible with @prisma/driver-adapter-utils SqlResultSet. + */ +interface QueryResult { + columnNames: string[]; + columnTypes: string[]; + rows: unknown[][]; + lastInsertId?: string; +} + +/** + * Minimal interface for a Prisma transaction. + * Compatible with @prisma/driver-adapter-utils Transaction. + */ +interface Transaction { + queryRaw(params: QueryParams): Promise; + executeRaw(params: QueryParams): Promise; + commit(): Promise; + rollback(): Promise; +} + +/** + * Minimal interface for a Prisma SQL driver adapter. + * Compatible with @prisma/driver-adapter-utils SqlDriverAdapter. + */ +interface SqlDriverAdapter { + queryRaw(params: QueryParams): Promise; + executeRaw(params: QueryParams): Promise; + startTransaction(isolationLevel?: string): Promise; +} + +function appendComment(params: QueryParams): QueryParams { + if (alreadyHasComment(params.sql)) { + return params; + } + const comment = buildComment(); + if (!comment) { + return params; + } + return { ...params, sql: params.sql + comment }; +} + +function wrapTransaction(tx: Transaction): Transaction { + return { + queryRaw(params: QueryParams) { + return tx.queryRaw(appendComment(params)); + }, + executeRaw(params: QueryParams) { + return tx.executeRaw(appendComment(params)); + }, + commit() { + return tx.commit(); + }, + rollback() { + return tx.rollback(); + }, + }; +} + +/** + * Wraps a Prisma driver adapter to automatically append sqlcommenter tags + * to every SQL query that passes through it. + * + * Monkey-patches the adapter in-place to preserve object identity, which + * Prisma's error registry relies on. + * + * Usage: + * const adapter = wrapAdapter(new PrismaPg(pool)) + * const prisma = new PrismaClient({ adapter }) + */ +export function wrapAdapter(adapter: T): T { + const origQueryRaw = adapter.queryRaw.bind(adapter); + const origExecuteRaw = adapter.executeRaw.bind(adapter); + const origStartTx = adapter.startTransaction.bind(adapter); + + adapter.queryRaw = function (params: QueryParams) { + return origQueryRaw(appendComment(params)); + }; + + adapter.executeRaw = function (params: QueryParams) { + return origExecuteRaw(appendComment(params)); + }; + + adapter.startTransaction = async function (isolationLevel?: string) { + const tx = await origStartTx(isolationLevel); + return wrapTransaction(tx); + }; + + return adapter; +} + +/** + * Minimal interface for a Prisma SQL driver adapter factory. + * Compatible with @prisma/driver-adapter-utils SqlDriverAdapterFactory. + * + * In Prisma 6+, PrismaPg implements this factory interface rather than + * SqlDriverAdapter directly. Call connect() to get a SqlDriverAdapter. + */ +interface SqlDriverAdapterFactory { + connect(): Promise; +} + +/** + * Wraps a Prisma driver adapter factory (e.g. PrismaPg in Prisma 6+) so that + * every connection returned by connect() is automatically instrumented with + * sqlcommenter tags. + * + * Usage: + * const adapter = wrapAdapterFactory(new PrismaPg(pool)) + * const prisma = new PrismaClient({ adapter }) + */ +export function wrapAdapterFactory(factory: T): T { + const origConnect = factory.connect.bind(factory); + + factory.connect = async function () { + const connection = await origConnect(); + return wrapAdapter(connection); + }; + + return factory; +} diff --git a/nodejs/sqlcommenter-nodejs/packages/sqlcommenter-prisma/src/als.ts b/nodejs/sqlcommenter-nodejs/packages/sqlcommenter-prisma/src/als.ts new file mode 100644 index 00000000..2b07e2b3 --- /dev/null +++ b/nodejs/sqlcommenter-nodejs/packages/sqlcommenter-prisma/src/als.ts @@ -0,0 +1,36 @@ +import { AsyncLocalStorage } from "node:async_hooks"; +import type { RequestContext } from "./request-context.js"; + +export type QueryContext = { + queryStack: string[]; +}; + +/** Stores the caller stack trace captured by the Prisma Client extension */ +export const queryContextAls = new AsyncLocalStorage(); + +/** Stores HTTP request context (route, method, etc.) set by middleware */ +export const requestContextAls = new AsyncLocalStorage(); + +/** + * Fallback context bridge for Prisma's WASM engine boundary. + * + * AsyncLocalStorage context is lost when Prisma's Rust/WASM query engine + * calls back into the JS driver adapter. The extension (which runs BEFORE + * the WASM boundary) snapshots context here so the adapter (which runs + * AFTER the boundary) can read it. + */ +export let bridgedQueryContext: QueryContext | undefined; +export let bridgedRequestContext: RequestContext | undefined; + +export function setBridgedContext( + query: QueryContext | undefined, + request: RequestContext | undefined, +) { + bridgedQueryContext = query; + bridgedRequestContext = request; +} + +export function clearBridgedContext() { + bridgedQueryContext = undefined; + bridgedRequestContext = undefined; +} diff --git a/nodejs/sqlcommenter-nodejs/packages/sqlcommenter-prisma/src/extension.ts b/nodejs/sqlcommenter-nodejs/packages/sqlcommenter-prisma/src/extension.ts new file mode 100644 index 00000000..93dbc9c9 --- /dev/null +++ b/nodejs/sqlcommenter-nodejs/packages/sqlcommenter-prisma/src/extension.ts @@ -0,0 +1,41 @@ +import { queryContextAls, requestContextAls, setBridgedContext, clearBridgedContext } from "./als.js"; +import { traceCaller } from "./path-trace.js"; + +type HookArgs = { args: unknown; query: (args: unknown) => Promise }; + +/** + * Wraps a Prisma query call, capturing the caller stack and bridging + * both query and request ALS context across Prisma's WASM engine boundary. + */ +function withContext({ args, query }: HookArgs): Promise { + const caller = traceCaller(); + const queryCtx = { queryStack: caller ? [caller] : [] }; + const requestCtx = requestContextAls.getStore(); + + // Bridge context so the adapter can read it after the WASM boundary + setBridgedContext(queryCtx, requestCtx); + + return queryContextAls.run(queryCtx, () => query(args)).finally(clearBridgedContext); +} + +/** + * Prisma Client extension that captures the call stack at the user's code + * and bridges context across the WASM engine boundary for the adapter + * wrapper to read. + * + * Usage: + * const prisma = new PrismaClient({ adapter }).$extends(sqlcommenterExtension()) + */ +export function sqlcommenterExtension() { + return { + query: { + $allModels: { + $allOperations: withContext, + }, + $queryRaw: withContext, + $executeRaw: withContext, + $queryRawUnsafe: withContext, + $executeRawUnsafe: withContext, + }, + }; +} diff --git a/nodejs/sqlcommenter-nodejs/packages/sqlcommenter-prisma/src/http.ts b/nodejs/sqlcommenter-nodejs/packages/sqlcommenter-prisma/src/http.ts new file mode 100644 index 00000000..b23cc0b7 --- /dev/null +++ b/nodejs/sqlcommenter-nodejs/packages/sqlcommenter-prisma/src/http.ts @@ -0,0 +1,16 @@ +import { requestContextAls } from "./als.js"; +import type { RequestContext } from "./request-context.js"; + +/** + * Wraps the next function in the AsyncLocalStorage with the request context. + * Used to get `route` and `controller` information from the request into the query + * without exposing the underlying AsyncLocalStorage API. + */ +export function withRequestContext( + context: RequestContext, + next: () => Promise, +) { + return requestContextAls.run(context, next); +} + +export type { RequestContext, WellKnownFields } from "./request-context.js"; diff --git a/nodejs/sqlcommenter-nodejs/packages/sqlcommenter-prisma/src/index.ts b/nodejs/sqlcommenter-nodejs/packages/sqlcommenter-prisma/src/index.ts new file mode 100644 index 00000000..73d203ee --- /dev/null +++ b/nodejs/sqlcommenter-nodejs/packages/sqlcommenter-prisma/src/index.ts @@ -0,0 +1,5 @@ +export { wrapAdapter, wrapAdapterFactory } from "./adapter.js"; +export { sqlcommenterExtension } from "./extension.js"; +export { withRequestContext } from "./http.js"; +export type { RequestContext } from "./request-context.js"; +export { WellKnownFields } from "./request-context.js"; diff --git a/nodejs/sqlcommenter-nodejs/packages/sqlcommenter-prisma/src/path-trace.ts b/nodejs/sqlcommenter-nodejs/packages/sqlcommenter-prisma/src/path-trace.ts new file mode 100644 index 00000000..e8790b95 --- /dev/null +++ b/nodejs/sqlcommenter-nodejs/packages/sqlcommenter-prisma/src/path-trace.ts @@ -0,0 +1,37 @@ +import { resolveFilePath } from "./path.js"; + +const LIBRARY_NAME = "sqlcommenter-prisma"; + +function isValidCaller(line: string): boolean { + if (line.includes("node_modules")) { + return false; + } + // make sure we don't break our own tests + if (line.includes(`${LIBRARY_NAME}/test/`)) { + return true; + } + if (line.includes(LIBRARY_NAME)) { + return false; + } + return true; +} + +// (file.ts:12:12) or file.ts:12:12 +const filepathRegex = /([^ (]*?:\d+:\d+)\)?$/; + +export function traceCaller(): string | undefined { + const stack = new Error().stack; + if (!stack) { + return; + } + // skip 1 line for `Error:`, 1 line for the caller of the current function + const stackLines = stack.split("\n").slice(2); + const methodCaller = stackLines.find(isValidCaller); + if (!methodCaller) { + return; + } + const match = methodCaller.match(filepathRegex); + if (match) { + return resolveFilePath(match[1]); + } +} diff --git a/nodejs/sqlcommenter-nodejs/packages/sqlcommenter-prisma/src/path.ts b/nodejs/sqlcommenter-nodejs/packages/sqlcommenter-prisma/src/path.ts new file mode 100644 index 00000000..811866bd --- /dev/null +++ b/nodejs/sqlcommenter-nodejs/packages/sqlcommenter-prisma/src/path.ts @@ -0,0 +1,80 @@ +import { existsSync } from "node:fs"; +import { dirname, join } from "node:path"; + +let cachedProjectRoot: string | undefined; + +/** + * Finds the project root by walking up from `process.cwd()` looking for `tsconfig.json`. + * + * In deployed environments, `process.cwd()` may not be the project root + * (e.g., `cd .amplify-hosting/compute/default/ && node app.js`). + * Walking up to find `tsconfig.json` — which is never copied to deployment directories — + * gives us the real project root. + * + * The result is cached since the project root doesn't change during a process's lifetime. + */ +export function findProjectRoot(): string { + if (cachedProjectRoot !== undefined) { + return cachedProjectRoot; + } + let projectRoot = process.cwd(); + for (let d = projectRoot; d !== dirname(d); d = dirname(d)) { + if (existsSync(join(d, "tsconfig.json"))) { + projectRoot = d; + break; + } + } + cachedProjectRoot = projectRoot; + return projectRoot; +} + +/** + * Resolves a file path from a stack trace to a correct absolute path. + * + * When compiled JS is relocated (e.g., postbuild copies `dist/` to a deployment directory), + * source-map-resolved paths become incorrect because the relative `sources` entries in + * `.map` files resolve against the new location instead of the original project. + * + * This extracts the `src/`-relative portion and reconstructs the path using the real + * project root. + * + * @param raw - A stack trace entry like "/wrong/path/src/routes/admin.ts:12:15" + * @returns The resolved path like "/project/root/src/routes/admin.ts:12:15" + */ +export function resolveFilePath(raw: string): string { + // Split off :line:column suffix + const match = raw.match(/^(.*?):(\d+:\d+)$/); + if (!match) { + return raw; + } + const [, filePath, lineCol] = match; + const srcIdx = filePath.indexOf("src/"); + if (srcIdx < 0) { + return raw; + } + const projectRoot = findProjectRoot(); + const relativePath = filePath.substring(srcIdx); + const resolved = `${projectRoot}/${relativePath}`; + return `${applyWslPrefix(resolved)}:${lineCol}`; +} + +/** + * Prefixes an absolute path with the WSL network path when running inside WSL. + * + * Inside WSL, absolute paths like `/home/user/project/...` can't be resolved + * from Windows-side tooling (e.g., clickable links in dashboards or VS Code). + * The `WSL_DISTRO_NAME` env var is always set inside WSL, and the path format + * `//wsl.localhost//...` makes paths accessible from Windows. + */ +export function applyWslPrefix(filePath: string): string { + const distro = process.env.WSL_DISTRO_NAME; + if (distro) { + return `//wsl.localhost/${distro}${filePath}`; + } + return filePath; +} + +/** @internal Exposed for testing only */ +export function _resetProjectRootCache() { + cachedProjectRoot = undefined; +} diff --git a/nodejs/sqlcommenter-nodejs/packages/sqlcommenter-prisma/src/request-context.ts b/nodejs/sqlcommenter-nodejs/packages/sqlcommenter-prisma/src/request-context.ts new file mode 100644 index 00000000..23d99f33 --- /dev/null +++ b/nodejs/sqlcommenter-nodejs/packages/sqlcommenter-prisma/src/request-context.ts @@ -0,0 +1,18 @@ +export const WellKnownFields = { + route: "route", + method: "method", + controller: "controller", +} as const; + +/** + * A context object with values that will be passed along to the final emitted query comments. + * Can support well-known fields used by existing sqlcommenter-compatible tooling and + * arbitrary key-value pairs. + */ +export type RequestContext = { + [WellKnownFields.route]: string; + [WellKnownFields.method]?: string; + [WellKnownFields.controller]?: string; + // the user can choose to add any other information to the context + [key: string]: unknown; +}; diff --git a/nodejs/sqlcommenter-nodejs/packages/sqlcommenter-prisma/src/sqlcommenter.ts b/nodejs/sqlcommenter-nodejs/packages/sqlcommenter-prisma/src/sqlcommenter.ts new file mode 100644 index 00000000..31e34d09 --- /dev/null +++ b/nodejs/sqlcommenter-nodejs/packages/sqlcommenter-prisma/src/sqlcommenter.ts @@ -0,0 +1,52 @@ +export type Tag = [string, string]; + +function escapeMetaCharacters(value: string): string { + return value.replaceAll("'", "\\'"); +} + +function serializeKey(key: string): string { + return escapeMetaCharacters(encodeURIComponent(key)); +} + +function serializeValue(value: unknown): string { + const encoded = encodeURIComponent(String(value)); + const metaEscaped = escapeMetaCharacters(encoded); + const final = `'${metaEscaped}'`; + return final; +} + +function isEmpty(tags: Tag[]): boolean { + return tags.length === 0; +} + +function sort(kvPairs: string[]): string[] { + return kvPairs.sort((a, b) => a.localeCompare(b)); +} + +export function serializeTags(tags: Tag[]): string { + if (isEmpty(tags)) { + return ""; + } + const parts: string[] = []; + for (const [k, v] of tags) { + try { + const key = serializeKey(k); + const value = serializeValue(v); + parts.push(`${key}=${value}`); + } catch (e) { + // ignore errors in serialization and skip pair + console.error("Error encoding key", e); + } + } + const sorted = sort(parts); + const concatenated = sorted.join(","); + return `/*${concatenated}*/`; +} + +/** + * Debatable whether this part of the spec even makes sense. + * But it's checked for compliance. + */ +export function alreadyHasComment(sql: string): boolean { + return sql.lastIndexOf("*/") !== -1; +} diff --git a/nodejs/sqlcommenter-nodejs/packages/sqlcommenter-prisma/src/tracing.ts b/nodejs/sqlcommenter-nodejs/packages/sqlcommenter-prisma/src/tracing.ts new file mode 100644 index 00000000..40a88820 --- /dev/null +++ b/nodejs/sqlcommenter-nodejs/packages/sqlcommenter-prisma/src/tracing.ts @@ -0,0 +1,14 @@ +import type { Tag } from "./sqlcommenter.js"; +import { context, type TextMapSetter } from "@opentelemetry/api"; +import { W3CTraceContextPropagator } from "@opentelemetry/core"; + +const sqlcommentAppender: TextMapSetter = { + set(context, key, value) { + context.push([key, value]); + }, +}; + +export function pushW3CTraceContext(tags: Tag[]) { + let propagator = new W3CTraceContextPropagator(); + propagator.inject(context.active(), tags, sqlcommentAppender); +} diff --git a/nodejs/sqlcommenter-nodejs/packages/sqlcommenter-prisma/test/adapter.spec.ts b/nodejs/sqlcommenter-nodejs/packages/sqlcommenter-prisma/test/adapter.spec.ts new file mode 100644 index 00000000..2ded3a41 --- /dev/null +++ b/nodejs/sqlcommenter-nodejs/packages/sqlcommenter-prisma/test/adapter.spec.ts @@ -0,0 +1,177 @@ +import { test } from "node:test"; +import assert from "node:assert"; +import { wrapAdapter } from "../src/adapter.js"; +import { sqlcommenterExtension } from "../src/extension.js"; +import { queryContextAls, requestContextAls } from "../src/als.js"; + +/** + * Creates a mock driver adapter that records all SQL queries sent to it. + */ +function createMockAdapter() { + const queries: string[] = []; + const executions: string[] = []; + + const adapter = { + queryRaw(params: { sql: string; args?: unknown[] }) { + queries.push(params.sql); + return Promise.resolve({ + columnNames: [], + columnTypes: [], + rows: [], + }); + }, + executeRaw(params: { sql: string; args?: unknown[] }) { + executions.push(params.sql); + return Promise.resolve(0); + }, + async startTransaction() { + return { + queryRaw(params: { sql: string; args?: unknown[] }) { + queries.push(params.sql); + return Promise.resolve({ + columnNames: [], + columnTypes: [], + rows: [], + }); + }, + executeRaw(params: { sql: string; args?: unknown[] }) { + executions.push(params.sql); + return Promise.resolve(0); + }, + commit() { + return Promise.resolve(); + }, + rollback() { + return Promise.resolve(); + }, + }; + }, + }; + + return { adapter, queries, executions }; +} + +test("wrapAdapter appends sqlcommenter tags to queryRaw", async () => { + const { adapter, queries } = createMockAdapter(); + const wrapped = wrapAdapter(adapter); + + await queryContextAls.run({ queryStack: ["/app/src/routes/users.ts:42:10"] }, async () => { + await wrapped.queryRaw({ sql: 'SELECT "id", "name" FROM "users" WHERE "id" = $1', args: [1] }); + }); + + assert.strictEqual(queries.length, 1); + assert.match(queries[0], /^SELECT "id", "name" FROM "users" WHERE "id" = \$1\/\*/); + assert.match(queries[0], /db_driver='prisma'/); + assert.match(queries[0], /file='/); + assert.match(queries[0], /\*\/$/); +}); + +test("wrapAdapter appends sqlcommenter tags to executeRaw", async () => { + const { adapter, executions } = createMockAdapter(); + const wrapped = wrapAdapter(adapter); + + await queryContextAls.run({ queryStack: ["/app/src/routes/users.ts:50:5"] }, async () => { + await wrapped.executeRaw({ sql: 'UPDATE "users" SET "name" = $1 WHERE "id" = $2', args: ["Alice", 1] }); + }); + + assert.strictEqual(executions.length, 1); + assert.match(executions[0], /^UPDATE "users" SET "name" = \$1 WHERE "id" = \$2\/\*/); + assert.match(executions[0], /db_driver='prisma'/); +}); + +test("wrapAdapter does not add comment when no ALS context", async () => { + const { adapter, queries } = createMockAdapter(); + const wrapped = wrapAdapter(adapter); + + // No queryContextAls.run — simulates BEGIN/COMMIT issued by engine + await wrapped.queryRaw({ sql: "BEGIN" }); + + assert.strictEqual(queries.length, 1); + // Should still have db_driver tag (always present) but no file tag + assert.match(queries[0], /db_driver='prisma'/); + assert.ok(!queries[0].includes("file=")); +}); + +test("wrapAdapter includes request context (route, method) in tags", async () => { + const { adapter, queries } = createMockAdapter(); + const wrapped = wrapAdapter(adapter); + + await requestContextAls.run({ route: "/users", method: "GET" }, async () => { + await queryContextAls.run({ queryStack: ["/app/src/routes/users.ts:42:10"] }, async () => { + await wrapped.queryRaw({ sql: 'SELECT * FROM "users"' }); + }); + }); + + assert.strictEqual(queries.length, 1); + assert.match(queries[0], /route='%2Fusers'/); + assert.match(queries[0], /method='GET'/); +}); + +test("wrapAdapter wraps transaction queries", async () => { + const { adapter, queries } = createMockAdapter(); + const wrapped = wrapAdapter(adapter); + + await queryContextAls.run({ queryStack: ["/app/src/routes/users.ts:60:3"] }, async () => { + const tx = await wrapped.startTransaction(); + await tx.queryRaw({ sql: 'SELECT * FROM "users" FOR UPDATE' }); + await tx.commit(); + }); + + assert.strictEqual(queries.length, 1); + assert.match(queries[0], /db_driver='prisma'/); + assert.match(queries[0], /file='/); +}); + +test("wrapAdapter skips SQL that already has a comment", async () => { + const { adapter, queries } = createMockAdapter(); + const wrapped = wrapAdapter(adapter); + + const sqlWithComment = 'SELECT * FROM "users" /* existing comment */'; + await queryContextAls.run({ queryStack: ["/app/src/file.ts:1:1"] }, async () => { + await wrapped.queryRaw({ sql: sqlWithComment }); + }); + + assert.strictEqual(queries.length, 1); + assert.strictEqual(queries[0], sqlWithComment); +}); + +test("sqlcommenterExtension sets queryStack in ALS", async () => { + const ext = sqlcommenterExtension(); + + let capturedContext: { queryStack: string[] } | undefined; + + // Simulate what Prisma does: call the $allOperations handler + const handler = ext.query.$allModels.$allOperations; + await handler({ + args: {}, + query: async (args: unknown) => { + capturedContext = queryContextAls.getStore(); + return {}; + }, + }); + + assert.ok(capturedContext, "queryContextAls should have a store"); + assert.ok(Array.isArray(capturedContext!.queryStack), "queryStack should be an array"); +}); + +test("tags are sorted alphabetically", async () => { + const { adapter, queries } = createMockAdapter(); + const wrapped = wrapAdapter(adapter); + + await requestContextAls.run({ route: "/users", method: "GET" }, async () => { + await queryContextAls.run({ queryStack: ["/app/src/routes/users.ts:42:10"] }, async () => { + await wrapped.queryRaw({ sql: "SELECT 1" }); + }); + }); + + assert.strictEqual(queries.length, 1); + // Extract the comment portion + const comment = queries[0].replace("SELECT 1", ""); + // Verify it starts with /* and ends with */ + assert.match(comment, /^\/\*.*\*\/$/); + // Extract key=value pairs + const inner = comment.slice(2, -2); + const pairs = inner.split(",").map((p) => p.split("=")[0]); + const sorted = [...pairs].sort(); + assert.deepStrictEqual(pairs, sorted, "Tags should be sorted alphabetically"); +}); diff --git a/nodejs/sqlcommenter-nodejs/packages/sqlcommenter-prisma/test/path.spec.ts b/nodejs/sqlcommenter-nodejs/packages/sqlcommenter-prisma/test/path.spec.ts new file mode 100644 index 00000000..38e66460 --- /dev/null +++ b/nodejs/sqlcommenter-nodejs/packages/sqlcommenter-prisma/test/path.spec.ts @@ -0,0 +1,135 @@ +import { test } from "node:test"; +import assert from "node:assert"; +import { existsSync } from "node:fs"; +import { join } from "node:path"; +import { + findProjectRoot, + resolveFilePath, + applyWslPrefix, + _resetProjectRootCache, +} from "../src/path.js"; + +test("findProjectRoot", async (t) => { + t.afterEach(() => { + _resetProjectRootCache(); + }); + + await t.test("returns a directory containing tsconfig.json", () => { + const root = findProjectRoot(); + assert.ok( + existsSync(join(root, "tsconfig.json")), + `Expected ${root} to contain tsconfig.json`, + ); + }); + + await t.test("caches the result across calls", () => { + const first = findProjectRoot(); + const second = findProjectRoot(); + assert.strictEqual(first, second); + }); +}); + +test("resolveFilePath", async (t) => { + const originalWslDistro = process.env.WSL_DISTRO_NAME; + + t.beforeEach(() => { + delete process.env.WSL_DISTRO_NAME; + }); + + t.afterEach(() => { + _resetProjectRootCache(); + if (originalWslDistro === undefined) { + delete process.env.WSL_DISTRO_NAME; + } else { + process.env.WSL_DISTRO_NAME = originalWslDistro; + } + }); + + await t.test( + "resolves path with src/ to project root", + () => { + const projectRoot = findProjectRoot(); + const result = resolveFilePath( + "/wrong/deploy/dir/src/routes/admin.ts:12:15", + ); + assert.strictEqual(result, `${projectRoot}/src/routes/admin.ts:12:15`); + }, + ); + + await t.test("leaves path without src/ unchanged", () => { + const result = resolveFilePath("/some/other/path/routes/admin.ts:5:10"); + assert.strictEqual(result, "/some/other/path/routes/admin.ts:5:10"); + }); + + await t.test("preserves line:column suffix", () => { + const projectRoot = findProjectRoot(); + const result = resolveFilePath("/bad/path/src/index.ts:99:3"); + assert.strictEqual(result, `${projectRoot}/src/index.ts:99:3`); + }); + + await t.test("uses first src/ occurrence", () => { + const projectRoot = findProjectRoot(); + const result = resolveFilePath( + "/deploy/src/nested/src/routes/admin.ts:1:1", + ); + assert.strictEqual( + result, + `${projectRoot}/src/nested/src/routes/admin.ts:1:1`, + ); + }); + + await t.test("returns raw string if no line:column suffix", () => { + const result = resolveFilePath("/some/path/src/file.ts"); + assert.strictEqual(result, "/some/path/src/file.ts"); + }); +}); + +test("applyWslPrefix", async (t) => { + const originalWslDistro = process.env.WSL_DISTRO_NAME; + + t.afterEach(() => { + if (originalWslDistro === undefined) { + delete process.env.WSL_DISTRO_NAME; + } else { + process.env.WSL_DISTRO_NAME = originalWslDistro; + } + }); + + await t.test("prefixes path when WSL_DISTRO_NAME is set", () => { + process.env.WSL_DISTRO_NAME = "Ubuntu"; + const result = applyWslPrefix("/home/user/project/src/index.ts"); + assert.strictEqual( + result, + "//wsl.localhost/Ubuntu/home/user/project/src/index.ts", + ); + }); + + await t.test("returns path unchanged when WSL_DISTRO_NAME is not set", () => { + delete process.env.WSL_DISTRO_NAME; + const result = applyWslPrefix("/home/user/project/src/index.ts"); + assert.strictEqual(result, "/home/user/project/src/index.ts"); + }); +}); + +test("resolveFilePath with WSL", async (t) => { + const originalWslDistro = process.env.WSL_DISTRO_NAME; + + t.afterEach(() => { + _resetProjectRootCache(); + if (originalWslDistro === undefined) { + delete process.env.WSL_DISTRO_NAME; + } else { + process.env.WSL_DISTRO_NAME = originalWslDistro; + } + }); + + await t.test("applies WSL prefix to resolved src/ paths", () => { + process.env.WSL_DISTRO_NAME = "Ubuntu"; + const projectRoot = findProjectRoot(); + const result = resolveFilePath("/wrong/path/src/routes/admin.ts:12:15"); + assert.strictEqual( + result, + `//wsl.localhost/Ubuntu${projectRoot}/src/routes/admin.ts:12:15`, + ); + }); +}); diff --git a/nodejs/sqlcommenter-nodejs/packages/sqlcommenter-prisma/tsconfig.cjs.json b/nodejs/sqlcommenter-nodejs/packages/sqlcommenter-prisma/tsconfig.cjs.json new file mode 100644 index 00000000..49f03bb2 --- /dev/null +++ b/nodejs/sqlcommenter-nodejs/packages/sqlcommenter-prisma/tsconfig.cjs.json @@ -0,0 +1,8 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "module": "commonjs", + "moduleResolution": "node", + "outDir": "./dist/cjs" + } +} diff --git a/nodejs/sqlcommenter-nodejs/packages/sqlcommenter-prisma/tsconfig.json b/nodejs/sqlcommenter-nodejs/packages/sqlcommenter-prisma/tsconfig.json new file mode 100644 index 00000000..de4e28fe --- /dev/null +++ b/nodejs/sqlcommenter-nodejs/packages/sqlcommenter-prisma/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "es2018", + "lib": ["es2022"], + "module": "es2020", + "moduleResolution": "bundler", + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "outDir": "./dist/esm", + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "strict": true, + "skipLibCheck": true + }, + "exclude": ["test", "dist", "node_modules"] +} From d603f018416c59216df6bc477c7a8252a17caa0d Mon Sep 17 00:00:00 2001 From: Chris Harris Date: Fri, 6 Mar 2026 09:46:15 -0600 Subject: [PATCH 2/3] fix(prisma): filter node:internal frames from stack trace Prisma's PrismaPromise defers execution via .then(), so by the time $allOperations fires, the user code is no longer on the call stack. The first non-filtered frame was node:internal/process/task_queues, producing a misleading file tag. Filter node:internal and node:async_hooks frames so the file tag is omitted rather than wrong. Co-Authored-By: Claude Opus 4.6 --- .../packages/sqlcommenter-prisma/src/path-trace.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/nodejs/sqlcommenter-nodejs/packages/sqlcommenter-prisma/src/path-trace.ts b/nodejs/sqlcommenter-nodejs/packages/sqlcommenter-prisma/src/path-trace.ts index e8790b95..bc9a6028 100644 --- a/nodejs/sqlcommenter-nodejs/packages/sqlcommenter-prisma/src/path-trace.ts +++ b/nodejs/sqlcommenter-nodejs/packages/sqlcommenter-prisma/src/path-trace.ts @@ -6,6 +6,9 @@ function isValidCaller(line: string): boolean { if (line.includes("node_modules")) { return false; } + if (line.includes("node:internal") || line.includes("node:async_hooks")) { + return false; + } // make sure we don't break our own tests if (line.includes(`${LIBRARY_NAME}/test/`)) { return true; From 1c7d44b84bc57ef93f4fef20ba251ee1485702f2 Mon Sep 17 00:00:00 2001 From: Chris Harris Date: Fri, 6 Mar 2026 19:39:44 -0600 Subject: [PATCH 3/3] feat: add Prisma 7 SQLCommenter demo app Full-stack Express + React demo showing Prisma 7's native `comments` API with `@prisma/sqlcommenter-query-tags`. Includes proxy-based call-site capture for the `file` tag (path:line:column) and `withQueryTags` middleware for route/method injection. Co-Authored-By: Claude Opus 4.6 --- .../samples/prisma-demo/.env.example | 1 + .../samples/prisma-demo/.gitignore | 3 + .../samples/prisma-demo/README.md | 55 ++++ .../samples/prisma-demo/index.html | 12 + .../samples/prisma-demo/package.json | 42 +++ .../20260306051925_init/migration.sql | 110 ++++++++ .../prisma/migrations/migration_lock.toml | 3 + .../prisma-demo/prisma/prisma.config.ts | 14 + .../samples/prisma-demo/prisma/schema.prisma | 82 ++++++ .../samples/prisma-demo/prisma/seed.ts | 123 +++++++++ .../samples/prisma-demo/server/db.ts | 142 ++++++++++ .../samples/prisma-demo/server/index.ts | 40 +++ .../samples/prisma-demo/server/query-log.ts | 25 ++ .../prisma-demo/server/routes/comments.ts | 30 +++ .../prisma-demo/server/routes/dashboard.ts | 62 +++++ .../prisma-demo/server/routes/issues.ts | 160 +++++++++++ .../prisma-demo/server/routes/labels.ts | 25 ++ .../prisma-demo/server/routes/projects.ts | 45 ++++ .../prisma-demo/server/routes/query-log.ts | 16 ++ .../prisma-demo/server/routes/users.ts | 18 ++ .../samples/prisma-demo/src/App.tsx | 49 ++++ .../samples/prisma-demo/src/api.ts | 63 +++++ .../samples/prisma-demo/src/app.css | 105 ++++++++ .../src/components/QueryLogPanel.tsx | 92 +++++++ .../samples/prisma-demo/src/main.tsx | 12 + .../prisma-demo/src/pages/Dashboard.tsx | 119 +++++++++ .../prisma-demo/src/pages/IssueDetail.tsx | 158 +++++++++++ .../samples/prisma-demo/src/pages/Labels.tsx | 99 +++++++ .../prisma-demo/src/pages/ProjectDetail.tsx | 249 ++++++++++++++++++ .../prisma-demo/src/pages/Projects.tsx | 103 ++++++++ .../samples/prisma-demo/tsconfig.json | 16 ++ .../samples/prisma-demo/vite.config.ts | 12 + 32 files changed, 2085 insertions(+) create mode 100644 nodejs/sqlcommenter-nodejs/samples/prisma-demo/.env.example create mode 100644 nodejs/sqlcommenter-nodejs/samples/prisma-demo/.gitignore create mode 100644 nodejs/sqlcommenter-nodejs/samples/prisma-demo/README.md create mode 100644 nodejs/sqlcommenter-nodejs/samples/prisma-demo/index.html create mode 100644 nodejs/sqlcommenter-nodejs/samples/prisma-demo/package.json create mode 100644 nodejs/sqlcommenter-nodejs/samples/prisma-demo/prisma/migrations/20260306051925_init/migration.sql create mode 100644 nodejs/sqlcommenter-nodejs/samples/prisma-demo/prisma/migrations/migration_lock.toml create mode 100644 nodejs/sqlcommenter-nodejs/samples/prisma-demo/prisma/prisma.config.ts create mode 100644 nodejs/sqlcommenter-nodejs/samples/prisma-demo/prisma/schema.prisma create mode 100644 nodejs/sqlcommenter-nodejs/samples/prisma-demo/prisma/seed.ts create mode 100644 nodejs/sqlcommenter-nodejs/samples/prisma-demo/server/db.ts create mode 100644 nodejs/sqlcommenter-nodejs/samples/prisma-demo/server/index.ts create mode 100644 nodejs/sqlcommenter-nodejs/samples/prisma-demo/server/query-log.ts create mode 100644 nodejs/sqlcommenter-nodejs/samples/prisma-demo/server/routes/comments.ts create mode 100644 nodejs/sqlcommenter-nodejs/samples/prisma-demo/server/routes/dashboard.ts create mode 100644 nodejs/sqlcommenter-nodejs/samples/prisma-demo/server/routes/issues.ts create mode 100644 nodejs/sqlcommenter-nodejs/samples/prisma-demo/server/routes/labels.ts create mode 100644 nodejs/sqlcommenter-nodejs/samples/prisma-demo/server/routes/projects.ts create mode 100644 nodejs/sqlcommenter-nodejs/samples/prisma-demo/server/routes/query-log.ts create mode 100644 nodejs/sqlcommenter-nodejs/samples/prisma-demo/server/routes/users.ts create mode 100644 nodejs/sqlcommenter-nodejs/samples/prisma-demo/src/App.tsx create mode 100644 nodejs/sqlcommenter-nodejs/samples/prisma-demo/src/api.ts create mode 100644 nodejs/sqlcommenter-nodejs/samples/prisma-demo/src/app.css create mode 100644 nodejs/sqlcommenter-nodejs/samples/prisma-demo/src/components/QueryLogPanel.tsx create mode 100644 nodejs/sqlcommenter-nodejs/samples/prisma-demo/src/main.tsx create mode 100644 nodejs/sqlcommenter-nodejs/samples/prisma-demo/src/pages/Dashboard.tsx create mode 100644 nodejs/sqlcommenter-nodejs/samples/prisma-demo/src/pages/IssueDetail.tsx create mode 100644 nodejs/sqlcommenter-nodejs/samples/prisma-demo/src/pages/Labels.tsx create mode 100644 nodejs/sqlcommenter-nodejs/samples/prisma-demo/src/pages/ProjectDetail.tsx create mode 100644 nodejs/sqlcommenter-nodejs/samples/prisma-demo/src/pages/Projects.tsx create mode 100644 nodejs/sqlcommenter-nodejs/samples/prisma-demo/tsconfig.json create mode 100644 nodejs/sqlcommenter-nodejs/samples/prisma-demo/vite.config.ts diff --git a/nodejs/sqlcommenter-nodejs/samples/prisma-demo/.env.example b/nodejs/sqlcommenter-nodejs/samples/prisma-demo/.env.example new file mode 100644 index 00000000..04ba804d --- /dev/null +++ b/nodejs/sqlcommenter-nodejs/samples/prisma-demo/.env.example @@ -0,0 +1 @@ +DATABASE_URL="postgresql://postgres:password@127.0.0.1:5432/prisma_demo" diff --git a/nodejs/sqlcommenter-nodejs/samples/prisma-demo/.gitignore b/nodejs/sqlcommenter-nodejs/samples/prisma-demo/.gitignore new file mode 100644 index 00000000..deed335b --- /dev/null +++ b/nodejs/sqlcommenter-nodejs/samples/prisma-demo/.gitignore @@ -0,0 +1,3 @@ +node_modules/ +dist/ +.env diff --git a/nodejs/sqlcommenter-nodejs/samples/prisma-demo/README.md b/nodejs/sqlcommenter-nodejs/samples/prisma-demo/README.md new file mode 100644 index 00000000..5b0c773c --- /dev/null +++ b/nodejs/sqlcommenter-nodejs/samples/prisma-demo/README.md @@ -0,0 +1,55 @@ +# Prisma SQLCommenter Demo + +A full-stack demo app (Express + React) showing how to use Prisma 7's built-in SQLCommenter support to annotate every SQL query with contextual metadata — source file, HTTP route, request method, model, and action. + +The app is a project/issue tracker (like a mini Linear) that generates a variety of OLTP queries: CRUD, aggregations, filtering, pagination, and batch operations. + +## Tags produced + +Every query gets a SQL comment like: + +```sql +SELECT ... FROM "Issue" WHERE ... +/*action='findMany',db_driver='prisma',file='server/routes/issues.ts:36:18',method='GET',model='Issue',route='/api/issues'*/ +``` + +| Tag | Source | +| ----------- | ----------------------------------------- | +| `db_driver` | Custom `SqlCommenterPlugin` | +| `model` | Custom `SqlCommenterPlugin` (from context)| +| `action` | Custom `SqlCommenterPlugin` (from context)| +| `route` | `withQueryTags` middleware (ALS) | +| `method` | `withQueryTags` middleware (ALS) | +| `file` | Proxy-based stack capture at call site | + +## Setup + +```bash +# Copy environment file +cp .env.example .env +# Edit .env with your PostgreSQL connection string + +# Install dependencies +npm install + +# Run migrations and seed +npx prisma migrate dev +npm run db:seed + +# Start dev server (Express + Vite) +npm run dev +``` + +The app runs at http://localhost:5173 (frontend) with the API on http://localhost:3456. + +## How the `file` tag works + +Prisma uses lazy `PrismaPromise` objects — the query doesn't execute at `prisma.issue.findMany()` but later when `.then()` is called by `await`. By that point, user code is no longer on the call stack, so the `SqlCommenterPlugin` can't capture the source file automatically. + +This demo solves it by proxying each `prisma..()` call to: + +1. Capture the stack trace at call time (where user code IS on the stack) +2. Extract the file path, line, and column from the first application frame +3. Return a custom thenable that wraps execution inside `withMergedQueryTags({ file })`, merging the file tag with existing route/method tags from the Express middleware + +See `server/db.ts` for the implementation. diff --git a/nodejs/sqlcommenter-nodejs/samples/prisma-demo/index.html b/nodejs/sqlcommenter-nodejs/samples/prisma-demo/index.html new file mode 100644 index 00000000..bf4dbeb6 --- /dev/null +++ b/nodejs/sqlcommenter-nodejs/samples/prisma-demo/index.html @@ -0,0 +1,12 @@ + + + + + + DevTracker - Prisma SQLCommenter Demo + + +
+ + + diff --git a/nodejs/sqlcommenter-nodejs/samples/prisma-demo/package.json b/nodejs/sqlcommenter-nodejs/samples/prisma-demo/package.json new file mode 100644 index 00000000..ce17817a --- /dev/null +++ b/nodejs/sqlcommenter-nodejs/samples/prisma-demo/package.json @@ -0,0 +1,42 @@ +{ + "name": "prisma-demo", + "version": "1.0.0", + "private": true, + "type": "module", + "scripts": { + "dev": "concurrently --names server,client -c blue,green \"npm run dev:server\" \"npm run dev:client\"", + "dev:server": "tsx watch server/index.ts", + "dev:client": "vite", + "db:migrate": "prisma migrate dev", + "db:seed": "tsx prisma/seed.ts", + "db:reset": "prisma migrate reset --force && tsx prisma/seed.ts", + "build": "vite build", + "postinstall": "prisma generate" + }, + "dependencies": { + "@prisma/adapter-pg": "^7.1.0", + "@prisma/client": "^7.1.0", + "@prisma/sqlcommenter": "^7.1.0", + "@prisma/sqlcommenter-query-tags": "^7.1.0", + "cors": "^2.8.5", + "express": "^4.21.0", + "pg": "^8.13.0", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "react-router-dom": "^7.3.0" + }, + "devDependencies": { + "@types/cors": "^2.8.17", + "@types/express": "^5.0.0", + "@types/node": "^22.0.0", + "@types/pg": "^8.11.0", + "@types/react": "^19.0.0", + "@types/react-dom": "^19.0.0", + "@vitejs/plugin-react": "^4.3.0", + "concurrently": "^9.1.0", + "prisma": "^7.1.0", + "tsx": "^4.19.0", + "typescript": "^5.7.0", + "vite": "^6.2.0" + } +} diff --git a/nodejs/sqlcommenter-nodejs/samples/prisma-demo/prisma/migrations/20260306051925_init/migration.sql b/nodejs/sqlcommenter-nodejs/samples/prisma-demo/prisma/migrations/20260306051925_init/migration.sql new file mode 100644 index 00000000..23169636 --- /dev/null +++ b/nodejs/sqlcommenter-nodejs/samples/prisma-demo/prisma/migrations/20260306051925_init/migration.sql @@ -0,0 +1,110 @@ +-- CreateEnum +CREATE TYPE "IssueStatus" AS ENUM ('OPEN', 'IN_PROGRESS', 'IN_REVIEW', 'CLOSED'); + +-- CreateEnum +CREATE TYPE "Priority" AS ENUM ('LOW', 'MEDIUM', 'HIGH', 'CRITICAL'); + +-- CreateTable +CREATE TABLE "User" ( + "id" TEXT NOT NULL, + "name" TEXT NOT NULL, + "email" TEXT NOT NULL, + "avatarUrl" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "User_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Project" ( + "id" TEXT NOT NULL, + "name" TEXT NOT NULL, + "key" TEXT NOT NULL, + "description" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Project_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Issue" ( + "id" TEXT NOT NULL, + "number" INTEGER NOT NULL, + "title" TEXT NOT NULL, + "description" TEXT, + "status" "IssueStatus" NOT NULL DEFAULT 'OPEN', + "priority" "Priority" NOT NULL DEFAULT 'MEDIUM', + "projectId" TEXT NOT NULL, + "assigneeId" TEXT, + "creatorId" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Issue_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Label" ( + "id" TEXT NOT NULL, + "name" TEXT NOT NULL, + "color" TEXT NOT NULL, + + CONSTRAINT "Label_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Comment" ( + "id" TEXT NOT NULL, + "body" TEXT NOT NULL, + "issueId" TEXT NOT NULL, + "authorId" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Comment_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "_IssueToLabel" ( + "A" TEXT NOT NULL, + "B" TEXT NOT NULL, + + CONSTRAINT "_IssueToLabel_AB_pkey" PRIMARY KEY ("A","B") +); + +-- CreateIndex +CREATE UNIQUE INDEX "User_email_key" ON "User"("email"); + +-- CreateIndex +CREATE UNIQUE INDEX "Project_key_key" ON "Project"("key"); + +-- CreateIndex +CREATE UNIQUE INDEX "Issue_projectId_number_key" ON "Issue"("projectId", "number"); + +-- CreateIndex +CREATE UNIQUE INDEX "Label_name_key" ON "Label"("name"); + +-- CreateIndex +CREATE INDEX "_IssueToLabel_B_index" ON "_IssueToLabel"("B"); + +-- AddForeignKey +ALTER TABLE "Issue" ADD CONSTRAINT "Issue_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Issue" ADD CONSTRAINT "Issue_assigneeId_fkey" FOREIGN KEY ("assigneeId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Issue" ADD CONSTRAINT "Issue_creatorId_fkey" FOREIGN KEY ("creatorId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Comment" ADD CONSTRAINT "Comment_issueId_fkey" FOREIGN KEY ("issueId") REFERENCES "Issue"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Comment" ADD CONSTRAINT "Comment_authorId_fkey" FOREIGN KEY ("authorId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "_IssueToLabel" ADD CONSTRAINT "_IssueToLabel_A_fkey" FOREIGN KEY ("A") REFERENCES "Issue"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "_IssueToLabel" ADD CONSTRAINT "_IssueToLabel_B_fkey" FOREIGN KEY ("B") REFERENCES "Label"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/nodejs/sqlcommenter-nodejs/samples/prisma-demo/prisma/migrations/migration_lock.toml b/nodejs/sqlcommenter-nodejs/samples/prisma-demo/prisma/migrations/migration_lock.toml new file mode 100644 index 00000000..044d57cd --- /dev/null +++ b/nodejs/sqlcommenter-nodejs/samples/prisma-demo/prisma/migrations/migration_lock.toml @@ -0,0 +1,3 @@ +# Please do not edit this file manually +# It should be added in your version-control system (e.g., Git) +provider = "postgresql" diff --git a/nodejs/sqlcommenter-nodejs/samples/prisma-demo/prisma/prisma.config.ts b/nodejs/sqlcommenter-nodejs/samples/prisma-demo/prisma/prisma.config.ts new file mode 100644 index 00000000..5bbae1bc --- /dev/null +++ b/nodejs/sqlcommenter-nodejs/samples/prisma-demo/prisma/prisma.config.ts @@ -0,0 +1,14 @@ +import path from "node:path"; +import type { PrismaConfig } from "prisma"; + +export default { + earlyAccess: true, + schema: path.join(import.meta.dirname, "schema.prisma"), + migrate: { + async development() { + return { + url: process.env.DATABASE_URL!, + }; + }, + }, +} satisfies PrismaConfig; diff --git a/nodejs/sqlcommenter-nodejs/samples/prisma-demo/prisma/schema.prisma b/nodejs/sqlcommenter-nodejs/samples/prisma-demo/prisma/schema.prisma new file mode 100644 index 00000000..12e9e06b --- /dev/null +++ b/nodejs/sqlcommenter-nodejs/samples/prisma-demo/prisma/schema.prisma @@ -0,0 +1,82 @@ +generator client { + provider = "prisma-client-js" + +} + +datasource db { + provider = "postgresql" +} + +model User { + id String @id @default(uuid()) + name String + email String @unique + avatarUrl String? + assignedIssues Issue[] @relation("assignee") + createdIssues Issue[] @relation("creator") + comments Comment[] + createdAt DateTime @default(now()) +} + +model Project { + id String @id @default(uuid()) + name String + key String @unique + description String? + issues Issue[] + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt +} + +model Issue { + id String @id @default(uuid()) + number Int + title String + description String? + status IssueStatus @default(OPEN) + priority Priority @default(MEDIUM) + projectId String + project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) + assigneeId String? + assignee User? @relation("assignee", fields: [assigneeId], references: [id]) + creatorId String + creator User @relation("creator", fields: [creatorId], references: [id]) + labels Label[] + comments Comment[] + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@unique([projectId, number]) +} + +enum IssueStatus { + OPEN + IN_PROGRESS + IN_REVIEW + CLOSED +} + +enum Priority { + LOW + MEDIUM + HIGH + CRITICAL +} + +model Label { + id String @id @default(uuid()) + name String @unique + color String + issues Issue[] +} + +model Comment { + id String @id @default(uuid()) + body String + issueId String + issue Issue @relation(fields: [issueId], references: [id], onDelete: Cascade) + authorId String + author User @relation(fields: [authorId], references: [id]) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt +} diff --git a/nodejs/sqlcommenter-nodejs/samples/prisma-demo/prisma/seed.ts b/nodejs/sqlcommenter-nodejs/samples/prisma-demo/prisma/seed.ts new file mode 100644 index 00000000..dcae626c --- /dev/null +++ b/nodejs/sqlcommenter-nodejs/samples/prisma-demo/prisma/seed.ts @@ -0,0 +1,123 @@ +import pg from "pg"; +import { PrismaPg } from "@prisma/adapter-pg"; +import { PrismaClient } from "@prisma/client"; + +const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL }); +const adapter = new PrismaPg(pool); +const prisma = new PrismaClient({ adapter }); + +async function main() { + // Clean + await prisma.comment.deleteMany(); + await prisma.issue.deleteMany(); + await prisma.label.deleteMany(); + await prisma.project.deleteMany(); + await prisma.user.deleteMany(); + + // Users + const [alice, bob, charlie] = await Promise.all([ + prisma.user.create({ data: { name: "Alice Chen", email: "alice@example.com" } }), + prisma.user.create({ data: { name: "Bob Martinez", email: "bob@example.com" } }), + prisma.user.create({ data: { name: "Charlie Kim", email: "charlie@example.com" } }), + ]); + + // Labels + const [bug, feature, docs, perf] = await Promise.all([ + prisma.label.create({ data: { name: "bug", color: "#e11d48" } }), + prisma.label.create({ data: { name: "feature", color: "#3b82f6" } }), + prisma.label.create({ data: { name: "docs", color: "#22c55e" } }), + prisma.label.create({ data: { name: "performance", color: "#f97316" } }), + ]); + + // Projects + const [api, web] = await Promise.all([ + prisma.project.create({ data: { name: "API Server", key: "API", description: "Backend REST API service" } }), + prisma.project.create({ data: { name: "Web Frontend", key: "WEB", description: "React web application" } }), + ]); + + // Issues for API project + const issues = await Promise.all([ + prisma.issue.create({ + data: { + number: 1, title: "Auth endpoint returns 500 on expired tokens", + description: "When a JWT token expires, the /auth/refresh endpoint throws an unhandled exception instead of returning 401.", + status: "OPEN", priority: "HIGH", projectId: api.id, creatorId: alice.id, assigneeId: bob.id, + labels: { connect: [{ id: bug.id }] }, + }, + }), + prisma.issue.create({ + data: { + number: 2, title: "Add rate limiting to public endpoints", + description: "We need rate limiting on all public-facing endpoints to prevent abuse.", + status: "IN_PROGRESS", priority: "MEDIUM", projectId: api.id, creatorId: bob.id, assigneeId: alice.id, + labels: { connect: [{ id: feature.id }] }, + }, + }), + prisma.issue.create({ + data: { + number: 3, title: "Optimize N+1 query in /users/list", + description: "The users list endpoint makes a separate query for each user's role. Should use a join.", + status: "OPEN", priority: "HIGH", projectId: api.id, creatorId: charlie.id, assigneeId: bob.id, + labels: { connect: [{ id: perf.id }, { id: bug.id }] }, + }, + }), + prisma.issue.create({ + data: { + number: 4, title: "Document webhook payload format", + description: "External integrators need documentation for our webhook event payloads.", + status: "CLOSED", priority: "LOW", projectId: api.id, creatorId: alice.id, + labels: { connect: [{ id: docs.id }] }, + }, + }), + // Web project issues + prisma.issue.create({ + data: { + number: 1, title: "Dashboard charts not rendering on Safari", + description: "The D3 charts on the dashboard fail to render in Safari 17. Console shows a TypeError.", + status: "OPEN", priority: "CRITICAL", projectId: web.id, creatorId: bob.id, assigneeId: charlie.id, + labels: { connect: [{ id: bug.id }] }, + }, + }), + prisma.issue.create({ + data: { + number: 2, title: "Add dark mode support", + description: "Users have requested dark mode. Should respect system preference and allow manual toggle.", + status: "IN_REVIEW", priority: "MEDIUM", projectId: web.id, creatorId: alice.id, assigneeId: charlie.id, + labels: { connect: [{ id: feature.id }] }, + }, + }), + prisma.issue.create({ + data: { + number: 3, title: "Reduce bundle size by lazy loading routes", + description: "Initial load is 2.4MB. We can cut this in half with route-based code splitting.", + status: "OPEN", priority: "MEDIUM", projectId: web.id, creatorId: charlie.id, + labels: { connect: [{ id: perf.id }] }, + }, + }), + ]); + + // Comments + await Promise.all([ + prisma.comment.create({ + data: { body: "I can reproduce this with any expired token. The error is in the middleware layer.", issueId: issues[0].id, authorId: bob.id }, + }), + prisma.comment.create({ + data: { body: "I think we should use express-rate-limit. It has built-in Redis support for distributed setups.", issueId: issues[1].id, authorId: alice.id }, + }), + prisma.comment.create({ + data: { body: "Agreed. Let's set 100 req/min for authenticated and 20 req/min for anonymous.", issueId: issues[1].id, authorId: bob.id }, + }), + prisma.comment.create({ + data: { body: "This is a known Safari bug with SVG viewBox. We need to add explicit width/height.", issueId: issues[4].id, authorId: charlie.id }, + }), + prisma.comment.create({ + data: { body: "Dark mode PR is ready for review: PR #247", issueId: issues[5].id, authorId: charlie.id }, + }), + ]); + + console.log("Seed complete: 3 users, 4 labels, 2 projects, 7 issues, 5 comments"); +} + +main() + .catch(console.error) + .finally(() => prisma.$disconnect()); diff --git a/nodejs/sqlcommenter-nodejs/samples/prisma-demo/server/db.ts b/nodejs/sqlcommenter-nodejs/samples/prisma-demo/server/db.ts new file mode 100644 index 00000000..618da4b8 --- /dev/null +++ b/nodejs/sqlcommenter-nodejs/samples/prisma-demo/server/db.ts @@ -0,0 +1,142 @@ +import "dotenv/config"; +import pg from "pg"; +import { PrismaPg } from "@prisma/adapter-pg"; +import { PrismaClient } from "@prisma/client"; +import { + queryTags, + withQueryTags, + withMergedQueryTags, +} from "@prisma/sqlcommenter-query-tags"; +import type { SqlCommenterPlugin } from "@prisma/sqlcommenter"; +import { addQueryLog } from "./query-log.js"; + +const connectionString = process.env.DATABASE_URL!; + +const pool = new pg.Pool({ connectionString }); + +// Intercept queries at the pool level to capture SQL with comments +const originalQuery = pool.query.bind(pool); +(pool as any).query = function (...args: any[]) { + const sql = + typeof args[0] === "string" ? args[0] : args[0]?.text ?? String(args[0]); + const start = performance.now(); + const result = originalQuery(...args); + if (result && typeof result.then === "function") { + result.then( + () => addQueryLog(sql, performance.now() - start), + () => addQueryLog(sql, performance.now() - start), + ); + } else { + addQueryLog(sql, performance.now() - start); + } + return result; +}; + +// Custom sqlcommenter plugin: adds db_driver, model, and action +const appPlugin: SqlCommenterPlugin = (context) => ({ + db_driver: "prisma", + ...(context.query.modelName && { model: context.query.modelName }), + action: context.query.action, +}); + +const adapter = new PrismaPg(pool); + +const _prisma = new PrismaClient({ + adapter, + comments: [appPlugin, queryTags()], +}); + +// --- Auto-capture call-site file:line as a query tag --- + +function applyWslPrefix(filePath: string): string { + const distro = process.env.WSL_DISTRO_NAME; + if (distro) return `//wsl.localhost/${distro}${filePath}`; + return filePath; +} + +function extractCallerFile(stack: string): string | undefined { + const lines = stack.split("\n"); + for (let i = 1; i < lines.length; i++) { + const line = lines[i]; + if (line.includes("node_modules") || line.includes("node:")) continue; + const match = + line.match(/\((.+?):(\d+):(\d+)\)/) || + line.match(/at (.+?):(\d+):(\d+)/); + if (match) { + const filePath = match[1]; + if (filePath.endsWith("/server/db.ts")) continue; + if (!filePath.includes("/server/")) continue; + return `${applyWslPrefix(filePath)}:${match[2]}:${match[3]}`; + } + } + return undefined; +} + +const modelNames = [ + "user", + "project", + "issue", + "label", + "comment", +] as const; +const queryMethods = [ + "findMany", + "findUnique", + "findFirst", + "findFirstOrThrow", + "findUniqueOrThrow", + "create", + "createMany", + "update", + "updateMany", + "upsert", + "delete", + "deleteMany", + "count", + "aggregate", + "groupBy", +]; + +for (const model of modelNames) { + const delegate = (_prisma as any)[model]; + if (!delegate) continue; + for (const method of queryMethods) { + if (typeof delegate[method] !== "function") continue; + const original = delegate[method].bind(delegate); + delegate[method] = function (...args: any[]) { + const stack = new Error().stack ?? ""; + const file = extractCallerFile(stack); + const promise = original(...args); + if (!file) return promise; + return { + then(onFulfilled: any, onRejected: any) { + return withMergedQueryTags({ file }, () => promise).then( + onFulfilled, + onRejected, + ); + }, + catch(onRejected: any) { + return this.then(undefined, onRejected); + }, + finally(onFinally: any) { + return this.then( + (value: any) => { + onFinally?.(); + return value; + }, + (reason: any) => { + onFinally?.(); + throw reason; + }, + ); + }, + [Symbol.toStringTag]: "PrismaPromise", + }; + }; + } +} + +export const prisma = _prisma; + +// Re-export withQueryTags for use in middleware +export { withQueryTags }; diff --git a/nodejs/sqlcommenter-nodejs/samples/prisma-demo/server/index.ts b/nodejs/sqlcommenter-nodejs/samples/prisma-demo/server/index.ts new file mode 100644 index 00000000..060c786b --- /dev/null +++ b/nodejs/sqlcommenter-nodejs/samples/prisma-demo/server/index.ts @@ -0,0 +1,40 @@ +import express from "express"; +import cors from "cors"; +import { withQueryTags } from "./db.js"; +import projectRoutes from "./routes/projects.js"; +import issueRoutes from "./routes/issues.js"; +import commentRoutes from "./routes/comments.js"; +import labelRoutes from "./routes/labels.js"; +import userRoutes from "./routes/users.js"; +import dashboardRoutes from "./routes/dashboard.js"; +import queryLogRoutes from "./routes/query-log.js"; + +const app = express(); +app.use(cors()); +app.use(express.json()); + +// Inject request context as sqlcommenter query tags +app.use((req, res, next) => { + withQueryTags( + { route: req.path, method: req.method }, + async () => { + return new Promise((resolve) => { + res.on("finish", resolve); + next(); + }); + }, + ); +}); + +app.use("/api/projects", projectRoutes); +app.use("/api/issues", issueRoutes); +app.use("/api/comments", commentRoutes); +app.use("/api/labels", labelRoutes); +app.use("/api/users", userRoutes); +app.use("/api/dashboard", dashboardRoutes); +app.use("/api/query-log", queryLogRoutes); + +const PORT = process.env.PORT || 3456; +app.listen(PORT, () => { + console.log(`Server running on http://localhost:${PORT}`); +}); diff --git a/nodejs/sqlcommenter-nodejs/samples/prisma-demo/server/query-log.ts b/nodejs/sqlcommenter-nodejs/samples/prisma-demo/server/query-log.ts new file mode 100644 index 00000000..8ef2d074 --- /dev/null +++ b/nodejs/sqlcommenter-nodejs/samples/prisma-demo/server/query-log.ts @@ -0,0 +1,25 @@ +export interface QueryLogEntry { + id: number; + sql: string; + timestamp: number; + durationMs: number; +} + +const MAX_ENTRIES = 200; +let nextId = 1; +const entries: QueryLogEntry[] = []; + +export function addQueryLog(sql: string, durationMs: number) { + entries.push({ id: nextId++, sql, timestamp: Date.now(), durationMs }); + if (entries.length > MAX_ENTRIES) { + entries.splice(0, entries.length - MAX_ENTRIES); + } +} + +export function getQueryLogs(sinceId = 0): QueryLogEntry[] { + return entries.filter((e) => e.id > sinceId); +} + +export function clearQueryLogs() { + entries.length = 0; +} diff --git a/nodejs/sqlcommenter-nodejs/samples/prisma-demo/server/routes/comments.ts b/nodejs/sqlcommenter-nodejs/samples/prisma-demo/server/routes/comments.ts new file mode 100644 index 00000000..9773b821 --- /dev/null +++ b/nodejs/sqlcommenter-nodejs/samples/prisma-demo/server/routes/comments.ts @@ -0,0 +1,30 @@ +import { Router } from "express"; +import { prisma } from "../db.js"; + +const router = Router(); + +router.post("/", async (req, res) => { + const { body, issueId, authorId } = req.body; + const comment = await prisma.comment.create({ + data: { body, issueId, authorId }, + include: { author: { select: { id: true, name: true } } }, + }); + res.status(201).json(comment); +}); + +router.put("/:id", async (req, res) => { + const { body } = req.body; + const comment = await prisma.comment.update({ + where: { id: req.params.id }, + data: { body }, + include: { author: { select: { id: true, name: true } } }, + }); + res.json(comment); +}); + +router.delete("/:id", async (req, res) => { + await prisma.comment.delete({ where: { id: req.params.id } }); + res.status(204).end(); +}); + +export default router; diff --git a/nodejs/sqlcommenter-nodejs/samples/prisma-demo/server/routes/dashboard.ts b/nodejs/sqlcommenter-nodejs/samples/prisma-demo/server/routes/dashboard.ts new file mode 100644 index 00000000..04a78972 --- /dev/null +++ b/nodejs/sqlcommenter-nodejs/samples/prisma-demo/server/routes/dashboard.ts @@ -0,0 +1,62 @@ +import { Router } from "express"; +import { prisma } from "../db.js"; + +const router = Router(); + +router.get("/", async (_req, res) => { + const [ + totalProjects, + totalIssues, + openIssues, + inProgressIssues, + closedIssues, + issuesByPriority, + recentIssues, + recentComments, + ] = await Promise.all([ + prisma.project.count(), + prisma.issue.count(), + prisma.issue.count({ where: { status: "OPEN" } }), + prisma.issue.count({ where: { status: "IN_PROGRESS" } }), + prisma.issue.count({ where: { status: "CLOSED" } }), + prisma.issue.groupBy({ + by: ["priority"], + _count: { id: true }, + }), + prisma.issue.findMany({ + take: 5, + orderBy: { createdAt: "desc" }, + include: { + project: { select: { key: true } }, + assignee: { select: { name: true } }, + }, + }), + prisma.comment.findMany({ + take: 5, + orderBy: { createdAt: "desc" }, + include: { + author: { select: { name: true } }, + issue: { + select: { + title: true, + number: true, + project: { select: { key: true } }, + }, + }, + }, + }), + ]); + + res.json({ + totalProjects, + totalIssues, + openIssues, + inProgressIssues, + closedIssues, + issuesByPriority, + recentIssues, + recentComments, + }); +}); + +export default router; diff --git a/nodejs/sqlcommenter-nodejs/samples/prisma-demo/server/routes/issues.ts b/nodejs/sqlcommenter-nodejs/samples/prisma-demo/server/routes/issues.ts new file mode 100644 index 00000000..103e4ca2 --- /dev/null +++ b/nodejs/sqlcommenter-nodejs/samples/prisma-demo/server/routes/issues.ts @@ -0,0 +1,160 @@ +import { Router } from "express"; +import { prisma } from "../db.js"; +import type { Prisma } from "@prisma/client"; + +const router = Router(); + +// List issues with filtering, search, pagination +router.get("/", async (req, res) => { + const { + projectId, + status, + priority, + assigneeId, + labelId, + search, + page = "1", + limit = "20", + } = req.query; + + const where: Prisma.IssueWhereInput = {}; + if (projectId) where.projectId = projectId as string; + if (status) where.status = status as Prisma.EnumIssueStatusFilter; + if (priority) where.priority = priority as Prisma.EnumPriorityFilter; + if (assigneeId) where.assigneeId = assigneeId as string; + if (labelId) + where.labels = { some: { id: labelId as string } }; + if (search) + where.OR = [ + { title: { contains: search as string, mode: "insensitive" } }, + { description: { contains: search as string, mode: "insensitive" } }, + ]; + + const skip = (Number(page) - 1) * Number(limit); + + const [issues, total] = await Promise.all([ + prisma.issue.findMany({ + where, + include: { + project: { select: { key: true, name: true } }, + assignee: { select: { id: true, name: true } }, + creator: { select: { id: true, name: true } }, + labels: true, + _count: { select: { comments: true } }, + }, + orderBy: { createdAt: "desc" }, + skip, + take: Number(limit), + }), + prisma.issue.count({ where }), + ]); + + res.json({ issues, total, page: Number(page), limit: Number(limit) }); +}); + +// Get single issue +router.get("/:id", async (req, res) => { + const issue = await prisma.issue.findUnique({ + where: { id: req.params.id }, + include: { + project: { select: { key: true, name: true } }, + assignee: { select: { id: true, name: true } }, + creator: { select: { id: true, name: true } }, + labels: true, + comments: { + include: { author: { select: { id: true, name: true } } }, + orderBy: { createdAt: "asc" }, + }, + }, + }); + if (!issue) return res.status(404).json({ error: "Not found" }); + res.json(issue); +}); + +// Create issue +router.post("/", async (req, res) => { + const { title, description, projectId, priority, assigneeId, labelIds, creatorId } = + req.body; + + // Get next issue number for this project (transaction) + const issue = await prisma.$transaction(async (tx) => { + const lastIssue = await tx.issue.findFirst({ + where: { projectId }, + orderBy: { number: "desc" }, + select: { number: true }, + }); + const number = (lastIssue?.number ?? 0) + 1; + + return tx.issue.create({ + data: { + number, + title, + description, + projectId, + priority, + creatorId, + assigneeId: assigneeId || undefined, + labels: labelIds?.length + ? { connect: labelIds.map((id: string) => ({ id })) } + : undefined, + }, + include: { + project: { select: { key: true, name: true } }, + assignee: { select: { id: true, name: true } }, + creator: { select: { id: true, name: true } }, + labels: true, + }, + }); + }); + + res.status(201).json(issue); +}); + +// Update issue +router.put("/:id", async (req, res) => { + const { title, description, status, priority, assigneeId, labelIds } = + req.body; + + const data: Prisma.IssueUpdateInput = {}; + if (title !== undefined) data.title = title; + if (description !== undefined) data.description = description; + if (status !== undefined) data.status = status; + if (priority !== undefined) data.priority = priority; + if (assigneeId !== undefined) + data.assignee = assigneeId + ? { connect: { id: assigneeId } } + : { disconnect: true }; + if (labelIds !== undefined) + data.labels = { set: labelIds.map((id: string) => ({ id })) }; + + const issue = await prisma.issue.update({ + where: { id: req.params.id }, + data, + include: { + project: { select: { key: true, name: true } }, + assignee: { select: { id: true, name: true } }, + creator: { select: { id: true, name: true } }, + labels: true, + }, + }); + res.json(issue); +}); + +// Delete issue +router.delete("/:id", async (req, res) => { + await prisma.issue.delete({ where: { id: req.params.id } }); + res.status(204).end(); +}); + +// Bulk status update (transaction) +router.post("/bulk-update", async (req, res) => { + const { issueIds, status } = req.body; + const result = await prisma.$transaction( + issueIds.map((id: string) => + prisma.issue.update({ where: { id }, data: { status } }), + ), + ); + res.json({ updated: result.length }); +}); + +export default router; diff --git a/nodejs/sqlcommenter-nodejs/samples/prisma-demo/server/routes/labels.ts b/nodejs/sqlcommenter-nodejs/samples/prisma-demo/server/routes/labels.ts new file mode 100644 index 00000000..73e45853 --- /dev/null +++ b/nodejs/sqlcommenter-nodejs/samples/prisma-demo/server/routes/labels.ts @@ -0,0 +1,25 @@ +import { Router } from "express"; +import { prisma } from "../db.js"; + +const router = Router(); + +router.get("/", async (_req, res) => { + const labels = await prisma.label.findMany({ + include: { _count: { select: { issues: true } } }, + orderBy: { name: "asc" }, + }); + res.json(labels); +}); + +router.post("/", async (req, res) => { + const { name, color } = req.body; + const label = await prisma.label.create({ data: { name, color } }); + res.status(201).json(label); +}); + +router.delete("/:id", async (req, res) => { + await prisma.label.delete({ where: { id: req.params.id } }); + res.status(204).end(); +}); + +export default router; diff --git a/nodejs/sqlcommenter-nodejs/samples/prisma-demo/server/routes/projects.ts b/nodejs/sqlcommenter-nodejs/samples/prisma-demo/server/routes/projects.ts new file mode 100644 index 00000000..84edeadb --- /dev/null +++ b/nodejs/sqlcommenter-nodejs/samples/prisma-demo/server/routes/projects.ts @@ -0,0 +1,45 @@ +import { Router } from "express"; +import { prisma } from "../db.js"; + +const router = Router(); + +router.get("/", async (_req, res) => { + const projects = await prisma.project.findMany({ + include: { _count: { select: { issues: true } } }, + orderBy: { createdAt: "desc" }, + }); + res.json(projects); +}); + +router.get("/:id", async (req, res) => { + const project = await prisma.project.findUnique({ + where: { id: req.params.id }, + include: { _count: { select: { issues: true } } }, + }); + if (!project) return res.status(404).json({ error: "Not found" }); + res.json(project); +}); + +router.post("/", async (req, res) => { + const { name, key, description } = req.body; + const project = await prisma.project.create({ + data: { name, key: key.toUpperCase(), description }, + }); + res.status(201).json(project); +}); + +router.put("/:id", async (req, res) => { + const { name, description } = req.body; + const project = await prisma.project.update({ + where: { id: req.params.id }, + data: { name, description }, + }); + res.json(project); +}); + +router.delete("/:id", async (req, res) => { + await prisma.project.delete({ where: { id: req.params.id } }); + res.status(204).end(); +}); + +export default router; diff --git a/nodejs/sqlcommenter-nodejs/samples/prisma-demo/server/routes/query-log.ts b/nodejs/sqlcommenter-nodejs/samples/prisma-demo/server/routes/query-log.ts new file mode 100644 index 00000000..8f1b6777 --- /dev/null +++ b/nodejs/sqlcommenter-nodejs/samples/prisma-demo/server/routes/query-log.ts @@ -0,0 +1,16 @@ +import { Router } from "express"; +import { getQueryLogs, clearQueryLogs } from "../query-log.js"; + +const router = Router(); + +router.get("/", (req, res) => { + const sinceId = Number(req.query.sinceId) || 0; + res.json(getQueryLogs(sinceId)); +}); + +router.delete("/", (_req, res) => { + clearQueryLogs(); + res.status(204).end(); +}); + +export default router; diff --git a/nodejs/sqlcommenter-nodejs/samples/prisma-demo/server/routes/users.ts b/nodejs/sqlcommenter-nodejs/samples/prisma-demo/server/routes/users.ts new file mode 100644 index 00000000..b9919ded --- /dev/null +++ b/nodejs/sqlcommenter-nodejs/samples/prisma-demo/server/routes/users.ts @@ -0,0 +1,18 @@ +import { Router } from "express"; +import { prisma } from "../db.js"; + +const router = Router(); + +router.get("/", async (_req, res) => { + const users = await prisma.user.findMany({ + include: { + _count: { + select: { assignedIssues: true, createdIssues: true, comments: true }, + }, + }, + orderBy: { name: "asc" }, + }); + res.json(users); +}); + +export default router; diff --git a/nodejs/sqlcommenter-nodejs/samples/prisma-demo/src/App.tsx b/nodejs/sqlcommenter-nodejs/samples/prisma-demo/src/App.tsx new file mode 100644 index 00000000..6fbd52df --- /dev/null +++ b/nodejs/sqlcommenter-nodejs/samples/prisma-demo/src/App.tsx @@ -0,0 +1,49 @@ +import { Routes, Route, NavLink } from "react-router-dom"; +import { useState } from "react"; +import Dashboard from "./pages/Dashboard"; +import Projects from "./pages/Projects"; +import ProjectDetail from "./pages/ProjectDetail"; +import IssueDetail from "./pages/IssueDetail"; +import Labels from "./pages/Labels"; +import QueryLogPanel from "./components/QueryLogPanel"; +import "./app.css"; + +export default function App() { + const [queryLogOpen, setQueryLogOpen] = useState(true); + + return ( +
+ +
+
+ + } /> + } /> + } /> + } /> + } /> + +
+ {queryLogOpen && } +
+
+ ); +} diff --git a/nodejs/sqlcommenter-nodejs/samples/prisma-demo/src/api.ts b/nodejs/sqlcommenter-nodejs/samples/prisma-demo/src/api.ts new file mode 100644 index 00000000..2dbdd813 --- /dev/null +++ b/nodejs/sqlcommenter-nodejs/samples/prisma-demo/src/api.ts @@ -0,0 +1,63 @@ +const BASE = "/api"; + +async function request(path: string, options?: RequestInit) { + const res = await fetch(`${BASE}${path}`, { + headers: { "Content-Type": "application/json" }, + ...options, + }); + if (res.status === 204) return null; + if (!res.ok) throw new Error(`${res.status}: ${await res.text()}`); + return res.json(); +} + +export const api = { + // Dashboard + getDashboard: () => request("/dashboard"), + + // Projects + getProjects: () => request("/projects"), + getProject: (id: string) => request(`/projects/${id}`), + createProject: (data: { name: string; key: string; description?: string }) => + request("/projects", { method: "POST", body: JSON.stringify(data) }), + deleteProject: (id: string) => + request(`/projects/${id}`, { method: "DELETE" }), + + // Issues + getIssues: (params?: Record) => { + const qs = params ? "?" + new URLSearchParams(params).toString() : ""; + return request(`/issues${qs}`); + }, + getIssue: (id: string) => request(`/issues/${id}`), + createIssue: (data: Record) => + request("/issues", { method: "POST", body: JSON.stringify(data) }), + updateIssue: (id: string, data: Record) => + request(`/issues/${id}`, { method: "PUT", body: JSON.stringify(data) }), + deleteIssue: (id: string) => + request(`/issues/${id}`, { method: "DELETE" }), + bulkUpdateIssues: (issueIds: string[], status: string) => + request("/issues/bulk-update", { + method: "POST", + body: JSON.stringify({ issueIds, status }), + }), + + // Comments + createComment: (data: { body: string; issueId: string; authorId: string }) => + request("/comments", { method: "POST", body: JSON.stringify(data) }), + deleteComment: (id: string) => + request(`/comments/${id}`, { method: "DELETE" }), + + // Labels + getLabels: () => request("/labels"), + createLabel: (data: { name: string; color: string }) => + request("/labels", { method: "POST", body: JSON.stringify(data) }), + deleteLabel: (id: string) => + request(`/labels/${id}`, { method: "DELETE" }), + + // Users + getUsers: () => request("/users"), + + // Query log + getQueryLogs: (sinceId?: number) => + request(`/query-log${sinceId ? `?sinceId=${sinceId}` : ""}`), + clearQueryLogs: () => request("/query-log", { method: "DELETE" }), +}; diff --git a/nodejs/sqlcommenter-nodejs/samples/prisma-demo/src/app.css b/nodejs/sqlcommenter-nodejs/samples/prisma-demo/src/app.css new file mode 100644 index 00000000..f04c585c --- /dev/null +++ b/nodejs/sqlcommenter-nodejs/samples/prisma-demo/src/app.css @@ -0,0 +1,105 @@ +* { box-sizing: border-box; margin: 0; padding: 0; } +body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #0d1117; color: #e6edf3; } +a { color: #58a6ff; text-decoration: none; } +a:hover { text-decoration: underline; } + +.app { display: flex; height: 100vh; } + +/* Sidebar */ +.sidebar { width: 220px; background: #161b22; border-right: 1px solid #30363d; display: flex; flex-direction: column; flex-shrink: 0; } +.sidebar-header { padding: 20px 16px 12px; border-bottom: 1px solid #30363d; } +.sidebar-header h1 { font-size: 18px; color: #f0f6fc; } +.sidebar-header .subtitle { font-size: 11px; color: #8b949e; text-transform: uppercase; letter-spacing: 1px; } +.sidebar ul { list-style: none; padding: 8px 0; flex: 1; } +.sidebar li a { display: block; padding: 8px 16px; color: #e6edf3; font-size: 14px; border-left: 3px solid transparent; } +.sidebar li a:hover { background: #1c2128; text-decoration: none; } +.sidebar li a.active { background: #1c2128; border-left-color: #58a6ff; color: #58a6ff; } +.sidebar-footer { padding: 12px 16px; border-top: 1px solid #30363d; } + +/* Content */ +.content { flex: 1; display: flex; flex-direction: column; overflow: hidden; } +.page-content { flex: 1; overflow-y: auto; padding: 24px 32px; } + +/* Cards & Stats */ +.stats-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: 16px; margin-bottom: 24px; } +.stat-card { background: #161b22; border: 1px solid #30363d; border-radius: 8px; padding: 16px; } +.stat-card .label { font-size: 12px; color: #8b949e; text-transform: uppercase; } +.stat-card .value { font-size: 28px; font-weight: 600; margin-top: 4px; } + +/* Tables */ +table { width: 100%; border-collapse: collapse; } +th, td { text-align: left; padding: 10px 12px; border-bottom: 1px solid #21262d; } +th { font-size: 12px; color: #8b949e; text-transform: uppercase; font-weight: 600; } +tr:hover { background: #161b22; } + +/* Buttons */ +.btn { display: inline-flex; align-items: center; gap: 6px; padding: 6px 14px; border-radius: 6px; border: 1px solid #30363d; background: #21262d; color: #e6edf3; font-size: 13px; cursor: pointer; } +.btn:hover { background: #30363d; border-color: #8b949e; } +.btn-primary { background: #238636; border-color: #238636; } +.btn-primary:hover { background: #2ea043; } +.btn-danger { background: #da3633; border-color: #da3633; } +.btn-danger:hover { background: #f85149; } +.btn-sm { padding: 4px 10px; font-size: 12px; } + +/* Forms */ +input, select, textarea { background: #0d1117; border: 1px solid #30363d; border-radius: 6px; padding: 8px 12px; color: #e6edf3; font-size: 14px; width: 100%; } +input:focus, select:focus, textarea:focus { outline: none; border-color: #58a6ff; } +.form-group { margin-bottom: 12px; } +.form-group label { display: block; font-size: 13px; color: #8b949e; margin-bottom: 4px; } +.form-row { display: flex; gap: 12px; } +.form-row > * { flex: 1; } + +/* Badges */ +.badge { display: inline-block; padding: 2px 8px; border-radius: 12px; font-size: 12px; font-weight: 500; } +.badge-open { background: #1a7f37; color: #fff; } +.badge-in-progress { background: #9a6700; color: #fff; } +.badge-in-review { background: #8957e5; color: #fff; } +.badge-closed { background: #6e7681; color: #fff; } +.badge-low { background: #21262d; color: #8b949e; } +.badge-medium { background: #9a6700; color: #fff; } +.badge-high { background: #da3633; color: #fff; } +.badge-critical { background: #f85149; color: #fff; } + +.label-dot { display: inline-block; width: 12px; height: 12px; border-radius: 50%; margin-right: 6px; vertical-align: middle; } + +/* Modal overlay */ +.modal-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.6); display: flex; align-items: center; justify-content: center; z-index: 100; } +.modal { background: #161b22; border: 1px solid #30363d; border-radius: 12px; padding: 24px; width: 480px; max-width: 90vw; max-height: 80vh; overflow-y: auto; } +.modal h2 { margin-bottom: 16px; font-size: 18px; } +.modal-actions { display: flex; gap: 8px; justify-content: flex-end; margin-top: 16px; } + +/* Query log panel */ +.query-log { height: 260px; border-top: 1px solid #30363d; background: #0d1117; display: flex; flex-direction: column; flex-shrink: 0; } +.query-log-header { display: flex; align-items: center; justify-content: space-between; padding: 8px 16px; background: #161b22; border-bottom: 1px solid #30363d; } +.query-log-header h3 { font-size: 13px; color: #8b949e; } +.query-log-entries { flex: 1; overflow-y: auto; font-family: 'SF Mono', 'Fira Code', monospace; font-size: 12px; padding: 4px 0; } +.query-entry { padding: 4px 16px; border-bottom: 1px solid #21262d; display: flex; gap: 12px; align-items: baseline; } +.query-entry:hover { background: #161b22; } +.query-time { color: #6e7681; white-space: nowrap; min-width: 70px; } +.query-duration { color: #8b949e; white-space: nowrap; min-width: 55px; text-align: right; } +.query-sql { color: #e6edf3; word-break: break-all; flex: 1; } +.query-sql .comment { color: #58a6ff; } + +/* Section headers */ +.section-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 16px; } +.section-header h2 { font-size: 20px; } + +/* Misc */ +.empty { text-align: center; color: #8b949e; padding: 40px; } +.flex-center { display: flex; align-items: center; gap: 8px; } +.clickable { cursor: pointer; } +.mt-8 { margin-top: 8px; } +.mt-16 { margin-top: 16px; } +.mb-16 { margin-bottom: 16px; } +.text-muted { color: #8b949e; font-size: 13px; } + +/* Issue detail */ +.issue-header { margin-bottom: 24px; } +.issue-header h2 { font-size: 24px; margin-bottom: 8px; } +.issue-meta { display: flex; gap: 16px; flex-wrap: wrap; align-items: center; } +.issue-body { background: #161b22; border: 1px solid #30363d; border-radius: 8px; padding: 16px; margin-bottom: 24px; min-height: 60px; white-space: pre-wrap; } +.comment-card { background: #161b22; border: 1px solid #30363d; border-radius: 8px; padding: 12px 16px; margin-bottom: 12px; } +.comment-card .comment-meta { font-size: 13px; color: #8b949e; margin-bottom: 8px; } + +/* Checkbox */ +input[type="checkbox"] { width: auto; margin-right: 8px; accent-color: #58a6ff; } diff --git a/nodejs/sqlcommenter-nodejs/samples/prisma-demo/src/components/QueryLogPanel.tsx b/nodejs/sqlcommenter-nodejs/samples/prisma-demo/src/components/QueryLogPanel.tsx new file mode 100644 index 00000000..5e10194b --- /dev/null +++ b/nodejs/sqlcommenter-nodejs/samples/prisma-demo/src/components/QueryLogPanel.tsx @@ -0,0 +1,92 @@ +import { useState, useEffect, useRef } from "react"; +import { api } from "../api"; + +interface LogEntry { + id: number; + sql: string; + timestamp: number; + durationMs: number; +} + +function highlightComment(sql: string) { + const commentStart = sql.lastIndexOf("/*"); + if (commentStart === -1) return <>{sql}; + return ( + <> + {sql.slice(0, commentStart)} + {sql.slice(commentStart)} + + ); +} + +export default function QueryLogPanel() { + const [entries, setEntries] = useState([]); + const lastId = useRef(0); + const listRef = useRef(null); + const autoScroll = useRef(true); + + useEffect(() => { + const poll = setInterval(async () => { + try { + const newEntries = await api.getQueryLogs(lastId.current); + if (newEntries.length > 0) { + lastId.current = newEntries[newEntries.length - 1].id; + setEntries((prev) => [...prev, ...newEntries].slice(-200)); + } + } catch { + // ignore + } + }, 500); + return () => clearInterval(poll); + }, []); + + useEffect(() => { + if (autoScroll.current && listRef.current) { + listRef.current.scrollTop = listRef.current.scrollHeight; + } + }, [entries]); + + function handleScroll() { + if (!listRef.current) return; + const { scrollTop, scrollHeight, clientHeight } = listRef.current; + autoScroll.current = scrollHeight - scrollTop - clientHeight < 40; + } + + return ( +
+
+

SQL Query Log (sqlcommenter annotations in blue)

+ +
+
+ {entries.map((e) => ( +
+ + {new Date(e.timestamp).toLocaleTimeString()} + + + {e.durationMs.toFixed(1)}ms + + {highlightComment(e.sql)} +
+ ))} + {entries.length === 0 && ( +
Queries will appear here as you interact with the app
+ )} +
+
+ ); +} diff --git a/nodejs/sqlcommenter-nodejs/samples/prisma-demo/src/main.tsx b/nodejs/sqlcommenter-nodejs/samples/prisma-demo/src/main.tsx new file mode 100644 index 00000000..350a0bf0 --- /dev/null +++ b/nodejs/sqlcommenter-nodejs/samples/prisma-demo/src/main.tsx @@ -0,0 +1,12 @@ +import { StrictMode } from "react"; +import { createRoot } from "react-dom/client"; +import { BrowserRouter } from "react-router-dom"; +import App from "./App"; + +createRoot(document.getElementById("root")!).render( + + + + + , +); diff --git a/nodejs/sqlcommenter-nodejs/samples/prisma-demo/src/pages/Dashboard.tsx b/nodejs/sqlcommenter-nodejs/samples/prisma-demo/src/pages/Dashboard.tsx new file mode 100644 index 00000000..dc559ebe --- /dev/null +++ b/nodejs/sqlcommenter-nodejs/samples/prisma-demo/src/pages/Dashboard.tsx @@ -0,0 +1,119 @@ +import { useState, useEffect } from "react"; +import { Link } from "react-router-dom"; +import { api } from "../api"; + +interface DashboardData { + totalProjects: number; + totalIssues: number; + openIssues: number; + inProgressIssues: number; + closedIssues: number; + issuesByPriority: { priority: string; _count: { id: number } }[]; + recentIssues: { + id: string; + title: string; + number: number; + status: string; + priority: string; + project: { key: string }; + assignee: { name: string } | null; + createdAt: string; + }[]; + recentComments: { + id: string; + body: string; + author: { name: string }; + issue: { title: string; number: number; project: { key: string } }; + createdAt: string; + }[]; +} + +export default function Dashboard() { + const [data, setData] = useState(null); + + useEffect(() => { + api.getDashboard().then(setData); + }, []); + + if (!data) return
Loading...
; + + return ( +
+

Dashboard

+
+
+
Projects
+
{data.totalProjects}
+
+
+
Total Issues
+
{data.totalIssues}
+
+
+
Open
+
{data.openIssues}
+
+
+
In Progress
+
{data.inProgressIssues}
+
+
+
Closed
+
{data.closedIssues}
+
+ {data.issuesByPriority.map((p) => ( +
+
{p.priority}
+
{p._count.id}
+
+ ))} +
+ +
+

Recent Issues

+
+ + + + + + + + + + + + {data.recentIssues.map((issue) => ( + + + + + + + + ))} + +
IssueStatusPriorityAssigneeCreated
+ + {issue.project.key}-{issue.number} {issue.title} + + {issue.status.replace("_", " ")}{issue.priority}{issue.assignee?.name ?? "Unassigned"}{new Date(issue.createdAt).toLocaleDateString()}
+ +
+

Recent Comments

+
+ {data.recentComments.map((c) => ( +
+
+ {c.author.name} on{" "} + + {c.issue.project.key}-{c.issue.number} {c.issue.title} + + {" "}·{" "}{new Date(c.createdAt).toLocaleDateString()} +
+
{c.body.length > 120 ? c.body.slice(0, 120) + "..." : c.body}
+
+ ))} +
+ ); +} diff --git a/nodejs/sqlcommenter-nodejs/samples/prisma-demo/src/pages/IssueDetail.tsx b/nodejs/sqlcommenter-nodejs/samples/prisma-demo/src/pages/IssueDetail.tsx new file mode 100644 index 00000000..be8826b2 --- /dev/null +++ b/nodejs/sqlcommenter-nodejs/samples/prisma-demo/src/pages/IssueDetail.tsx @@ -0,0 +1,158 @@ +import { useState, useEffect } from "react"; +import { useParams, Link, useNavigate } from "react-router-dom"; +import { api } from "../api"; + +interface Issue { + id: string; + number: number; + title: string; + description: string | null; + status: string; + priority: string; + project: { key: string; name: string }; + projectId: string; + assignee: { id: string; name: string } | null; + creator: { id: string; name: string }; + labels: { id: string; name: string; color: string }[]; + comments: { + id: string; + body: string; + author: { id: string; name: string }; + createdAt: string; + }[]; + createdAt: string; +} + +export default function IssueDetail() { + const { id } = useParams<{ id: string }>(); + const navigate = useNavigate(); + const [issue, setIssue] = useState(null); + const [users, setUsers] = useState<{ id: string; name: string }[]>([]); + const [commentBody, setCommentBody] = useState(""); + + const load = () => api.getIssue(id!).then(setIssue); + useEffect(() => { load(); }, [id]); + useEffect(() => { api.getUsers().then(setUsers); }, []); + + async function updateField(data: Record) { + await api.updateIssue(id!, data); + load(); + } + + async function addComment(e: React.FormEvent) { + e.preventDefault(); + if (!commentBody.trim()) return; + await api.createComment({ body: commentBody, issueId: id!, authorId: users[0]?.id }); + setCommentBody(""); + load(); + } + + async function deleteComment(commentId: string) { + await api.deleteComment(commentId); + load(); + } + + async function handleDelete() { + if (!confirm("Delete this issue?")) return; + await api.deleteIssue(id!); + navigate(`/projects/${issue!.projectId}`); + } + + if (!issue) return
Loading...
; + + return ( +
+ ← {issue.project.key} · {issue.project.name} + +
+

{issue.project.key}-{issue.number}: {issue.title}

+
+ + {issue.status.replace(/_/g, " ")} + + + {issue.priority} + + {issue.labels.map((l) => ( + {l.name} + ))} + + Created by {issue.creator.name} on {new Date(issue.createdAt).toLocaleDateString()} + +
+
+ + {/* Editable fields */} +
+
+ + +
+
+ + +
+
+ + +
+
+ + {/* Description */} +

Description

+
+ {issue.description || No description provided.} +
+ + {/* Comments */} +
+

Comments ({issue.comments.length})

+
+ + {issue.comments.map((c) => ( +
+
+ {c.author.name} · {new Date(c.createdAt).toLocaleString()} + +
+
{c.body}
+
+ ))} + +
+
+ +