@usequark/quark-core 2.5.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +63 -0
- package/package.json +80 -0
- package/src/admin-auth.js +121 -0
- package/src/admin.js +352 -0
- package/src/admin.test.js +257 -0
- package/src/auth/index.js +160 -0
- package/src/auth/middleware.js +106 -0
- package/src/auth/middleware.test.js +116 -0
- package/src/auth/password.js +9 -0
- package/src/auth.test.js +235 -0
- package/src/authorization.js +235 -0
- package/src/authorization.test.js +314 -0
- package/src/cache.js +137 -0
- package/src/cache.test.js +217 -0
- package/src/core.js +32 -0
- package/src/csrf.js +122 -0
- package/src/csrf.test.js +169 -0
- package/src/db-instrumentation.js +281 -0
- package/src/db-instrumentation.test.js +284 -0
- package/src/db-metrics.js +26 -0
- package/src/db.js +218 -0
- package/src/db.test.js +365 -0
- package/src/email-templates.js +134 -0
- package/src/email-templates.test.js +124 -0
- package/src/email.js +314 -0
- package/src/email.test.js +498 -0
- package/src/error-reporter.js +266 -0
- package/src/error-reporter.test.js +236 -0
- package/src/errors.js +192 -0
- package/src/errors.test.js +128 -0
- package/src/exports.test.js +154 -0
- package/src/file-validation.js +249 -0
- package/src/file-validation.test.js +207 -0
- package/src/index.js +58 -0
- package/src/locale.js +21 -0
- package/src/logger.js +182 -0
- package/src/logger.test.js +287 -0
- package/src/mail.js +50 -0
- package/src/metrics.js +379 -0
- package/src/metrics.test.js +380 -0
- package/src/multipart.js +144 -0
- package/src/multipart.test.js +188 -0
- package/src/pagination.js +139 -0
- package/src/pagination.test.js +210 -0
- package/src/query-builder.js +183 -0
- package/src/query-builder.test.js +234 -0
- package/src/queue/index.js +446 -0
- package/src/queue/queue.test.js +365 -0
- package/src/rate-limiter.js +253 -0
- package/src/rate-limiter.test.js +130 -0
- package/src/redis.js +154 -0
- package/src/redis.test.js +150 -0
- package/src/request-logger.js +211 -0
- package/src/request-logger.test.js +286 -0
- package/src/sms.js +208 -0
- package/src/storage-s3.js +236 -0
- package/src/storage.js +214 -0
- package/src/storage.test.js +457 -0
- package/src/stripe.js +89 -0
- package/src/testing/factories.js +89 -0
- package/src/testing/helpers.js +266 -0
- package/src/testing/index.js +46 -0
- package/src/testing/mocks.js +480 -0
- package/src/testing/testing.test.js +543 -0
- package/src/types.js +74 -0
- package/src/utils.js +219 -0
- package/src/utils.test.js +193 -0
- package/src/validation.js +26 -0
package/README.md
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# @techstream/quark-core
|
|
2
|
+
|
|
3
|
+
Shared infrastructure for the Quark platform - authentication, job queues, error handling, and utilities.
|
|
4
|
+
|
|
5
|
+
## What's Included
|
|
6
|
+
|
|
7
|
+
- **Authentication** - Auth.js-compatible config helpers (`createAuthConfig`, `requireAuth`, password hashing) plus framework-agnostic session guards (`requireSession`, `requireSessionRole`, `createAuthMiddleware`)
|
|
8
|
+
- **Database** - Prisma client singleton (`createPrismaClient`), connection-string/pool helpers, and a `pingDatabase()` health check
|
|
9
|
+
- **Admin** - Prisma schema introspection (`parseSchema`, `getModels`, `getEnums`, `coerceId`) for auto-generated admin UIs
|
|
10
|
+
- **Job Queue** - BullMQ integration (`createQueue`, `createWorker`, `addJob`)
|
|
11
|
+
- **Storage** - Local filesystem built in; S3/R2/MinIO via optional AWS SDK install in the app
|
|
12
|
+
- **Errors** - Standardized error types (`ValidationError`, `NotFoundError`, `UnauthorizedError`, etc.)
|
|
13
|
+
- **Utilities** - `retryAsync`, `deepMerge`, `randomString`, `sanitizeId`, `measureTime`, `memoize`
|
|
14
|
+
- **Validation** - Zod-based request body validation
|
|
15
|
+
- **Redis / Mail** - Connection helpers for Redis and local mail (Mailpit)
|
|
16
|
+
|
|
17
|
+
## Usage
|
|
18
|
+
|
|
19
|
+
```javascript
|
|
20
|
+
import {
|
|
21
|
+
createAuthConfig,
|
|
22
|
+
requireAuth,
|
|
23
|
+
createQueue,
|
|
24
|
+
createWorker,
|
|
25
|
+
addJob,
|
|
26
|
+
ValidationError,
|
|
27
|
+
retryAsync,
|
|
28
|
+
} from "@techstream/quark-core";
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
All modules are re-exported from the package root. See JSDoc comments on each function for options and usage details.
|
|
32
|
+
|
|
33
|
+
### Subpath exports
|
|
34
|
+
|
|
35
|
+
Prefer a subpath when you want a smaller import graph or the browser-safe logger:
|
|
36
|
+
|
|
37
|
+
| Subpath | Contents |
|
|
38
|
+
|---|---|
|
|
39
|
+
| `@techstream/quark-core/core` | errors, logger, validation, utils, pagination, csrf, rate limiter, file validation, db |
|
|
40
|
+
| `@techstream/quark-core/db` | Prisma singleton, connection string, pool config, `pingDatabase()` |
|
|
41
|
+
| `@techstream/quark-core/admin` | Prisma schema introspection helpers |
|
|
42
|
+
| `@techstream/quark-core/auth` | auth config helpers + session guards |
|
|
43
|
+
| `@techstream/quark-core/auth/middleware` | session guards only |
|
|
44
|
+
| `@techstream/quark-core/storage` | local adapter, storage factory, key/URL helpers, `createS3Storage` |
|
|
45
|
+
| `@techstream/quark-core/storage/s3` | S3 adapter only |
|
|
46
|
+
| `@techstream/quark-core/logger` | browser-safe logger |
|
|
47
|
+
| `@techstream/quark-core/locale` | locale helpers |
|
|
48
|
+
| `@techstream/quark-core/stripe` | Stripe client + webhook helpers |
|
|
49
|
+
|
|
50
|
+
`@aws-sdk/client-s3`, `@aws-sdk/s3-request-presigner`, `pg`, and `stripe` are optional peer dependencies. They are only loaded when the matching feature is used, so apps that do not use S3, `pingDatabase()`, or payments do not need them installed.
|
|
51
|
+
|
|
52
|
+
## Testing
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
cd packages/core
|
|
56
|
+
pnpm test
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
## Support
|
|
60
|
+
|
|
61
|
+
For issues, questions, and discussions:
|
|
62
|
+
- 🐛 [Issue Tracker](https://github.com/usequark/quark/issues)
|
|
63
|
+
- 💬 [Discussions](https://github.com/usequark/quark/discussions)
|
package/package.json
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@usequark/quark-core",
|
|
3
|
+
"version": "2.5.4",
|
|
4
|
+
"description": "Core utilities for Quark apps: auth, BullMQ queues, Redis, email, storage, rate limiting, logging, and error handling",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "src/index.js",
|
|
7
|
+
"homepage": "https://github.com/usequark/quark#readme",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "https://github.com/usequark/quark.git",
|
|
11
|
+
"directory": "packages/core"
|
|
12
|
+
},
|
|
13
|
+
"bugs": {
|
|
14
|
+
"url": "https://github.com/usequark/quark/issues"
|
|
15
|
+
},
|
|
16
|
+
"author": "Quark Contributors",
|
|
17
|
+
"exports": {
|
|
18
|
+
".": "./src/index.js",
|
|
19
|
+
"./core": "./src/core.js",
|
|
20
|
+
"./admin": "./src/admin.js",
|
|
21
|
+
"./auth": "./src/auth/index.js",
|
|
22
|
+
"./auth/middleware": "./src/auth/middleware.js",
|
|
23
|
+
"./db": "./src/db.js",
|
|
24
|
+
"./email": "./src/email.js",
|
|
25
|
+
"./errors": "./src/errors.js",
|
|
26
|
+
"./locale": "./src/locale.js",
|
|
27
|
+
"./logger": "./src/logger.js",
|
|
28
|
+
"./metrics": "./src/metrics.js",
|
|
29
|
+
"./queue": "./src/queue/index.js",
|
|
30
|
+
"./sms": "./src/sms.js",
|
|
31
|
+
"./storage": "./src/storage.js",
|
|
32
|
+
"./storage/s3": "./src/storage-s3.js",
|
|
33
|
+
"./stripe": "./src/stripe.js",
|
|
34
|
+
"./testing": "./src/testing/index.js"
|
|
35
|
+
},
|
|
36
|
+
"dependencies": {
|
|
37
|
+
"bcryptjs": "^3.0.3",
|
|
38
|
+
"busboy": "^1.6.0",
|
|
39
|
+
"bullmq": "^6.3.8",
|
|
40
|
+
"ioredis": "^6.0.0",
|
|
41
|
+
"nodemailer": "^10.0.10",
|
|
42
|
+
"zod": "^4.6.5"
|
|
43
|
+
},
|
|
44
|
+
"peerDependencies": {
|
|
45
|
+
"@aws-sdk/client-s3": "^3.1004.0",
|
|
46
|
+
"@aws-sdk/s3-request-presigner": "^3.1004.0",
|
|
47
|
+
"pg": "^8.20.0",
|
|
48
|
+
"stripe": "^17.0.0"
|
|
49
|
+
},
|
|
50
|
+
"peerDependenciesMeta": {
|
|
51
|
+
"@aws-sdk/client-s3": {
|
|
52
|
+
"optional": true
|
|
53
|
+
},
|
|
54
|
+
"@aws-sdk/s3-request-presigner": {
|
|
55
|
+
"optional": true
|
|
56
|
+
},
|
|
57
|
+
"pg": {
|
|
58
|
+
"optional": true
|
|
59
|
+
},
|
|
60
|
+
"stripe": {
|
|
61
|
+
"optional": true
|
|
62
|
+
}
|
|
63
|
+
},
|
|
64
|
+
"devDependencies": {
|
|
65
|
+
"@types/node": "^26.6.2"
|
|
66
|
+
},
|
|
67
|
+
"scripts": {
|
|
68
|
+
"test": "node ../../scripts/run-tests.mjs src",
|
|
69
|
+
"lint": "biome format --write && biome check --write"
|
|
70
|
+
},
|
|
71
|
+
"files": [
|
|
72
|
+
"src",
|
|
73
|
+
"README.md"
|
|
74
|
+
],
|
|
75
|
+
"publishConfig": {
|
|
76
|
+
"registry": "https://registry.npmjs.org",
|
|
77
|
+
"access": "public"
|
|
78
|
+
},
|
|
79
|
+
"license": "MIT"
|
|
80
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @techstream/quark-core — Admin Authorization Module
|
|
3
|
+
*
|
|
4
|
+
* Lightweight, stateless admin-token authorization for drop-in internal
|
|
5
|
+
* endpoints (health checks, metrics, admin APIs).
|
|
6
|
+
*
|
|
7
|
+
* Agnostic by design: the admin token is read from the environment variable
|
|
8
|
+
* ADMIN_API_TOKEN. No project-specific user database or session is required.
|
|
9
|
+
* When no ADMIN_API_TOKEN is set, the endpoint is effectively open and the
|
|
10
|
+
* guard is a no-op (appropriate for local development).
|
|
11
|
+
*
|
|
12
|
+
* Security properties:
|
|
13
|
+
* - Constant-time comparison to prevent timing attacks.
|
|
14
|
+
* - Token never appears in logs (masked by default).
|
|
15
|
+
* - Returns a generic 401 response that does not reveal whether the
|
|
16
|
+
* token was missing or incorrect.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { createLogger } from "./logger.js";
|
|
20
|
+
|
|
21
|
+
const log = createLogger("admin-auth");
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Checks whether the given token matches the configured ADMIN_API_TOKEN.
|
|
25
|
+
*
|
|
26
|
+
* Uses timing-safe comparison when both values are present. When no
|
|
27
|
+
* ADMIN_API_TOKEN is configured, the check always passes (development
|
|
28
|
+
* convenience — log a warning on first call).
|
|
29
|
+
*
|
|
30
|
+
* @param {string | null | undefined} providedToken — Token from the request.
|
|
31
|
+
* @returns {{ authorized: boolean, reason?: string }}
|
|
32
|
+
*/
|
|
33
|
+
export function verifyAdminToken(providedToken) {
|
|
34
|
+
const configuredToken = process.env.ADMIN_API_TOKEN;
|
|
35
|
+
|
|
36
|
+
// No token configured — dev mode, allow access with a warning.
|
|
37
|
+
if (!configuredToken) {
|
|
38
|
+
log.warn(
|
|
39
|
+
"ADMIN_API_TOKEN is not set — admin endpoints are unprotected. " +
|
|
40
|
+
"Set ADMIN_API_TOKEN in production.",
|
|
41
|
+
);
|
|
42
|
+
return { authorized: true };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
if (!providedToken) {
|
|
46
|
+
return { authorized: false, reason: "Missing authorization token" };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Constant-time comparison.
|
|
50
|
+
const normalized = providedToken.trim();
|
|
51
|
+
if (normalized.length !== configuredToken.length) {
|
|
52
|
+
return { authorized: false, reason: "Invalid authorization token" };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
let mismatch = 0;
|
|
56
|
+
for (let i = 0; i < normalized.length; i++) {
|
|
57
|
+
mismatch |= normalized.charCodeAt(i) ^ configuredToken.charCodeAt(i);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if (mismatch !== 0) {
|
|
61
|
+
return { authorized: false, reason: "Invalid authorization token" };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return { authorized: true };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Express/Next.js middleware-style guard for API routes.
|
|
69
|
+
*
|
|
70
|
+
* Extracts the token from the `Authorization: Bearer <token>` header and
|
|
71
|
+
* validates it. Returns a 401 JSON response when unauthorized.
|
|
72
|
+
*
|
|
73
|
+
* Usage in an API route:
|
|
74
|
+
*
|
|
75
|
+
* import { requireAdminToken } from "@techstream/quark-core";
|
|
76
|
+
* import { NextResponse } from "next/server";
|
|
77
|
+
*
|
|
78
|
+
* export async function GET(request) {
|
|
79
|
+
* const auth = requireAdminToken(request);
|
|
80
|
+
* if (!auth.authorized) {
|
|
81
|
+
* return NextResponse.json(
|
|
82
|
+
* { error: auth.reason },
|
|
83
|
+
* { status: 401 },
|
|
84
|
+
* );
|
|
85
|
+
* }
|
|
86
|
+
* // ... handle request
|
|
87
|
+
* }
|
|
88
|
+
*
|
|
89
|
+
* @param {Request} request — The incoming Next.js/Web API Request object.
|
|
90
|
+
* @returns {{ authorized: true } | { authorized: false, reason: string, response: Response }}
|
|
91
|
+
*/
|
|
92
|
+
export function requireAdminToken(request) {
|
|
93
|
+
const authHeader = request.headers.get("authorization") || "";
|
|
94
|
+
let token = null;
|
|
95
|
+
|
|
96
|
+
// Support both "Bearer <token>" and raw token in header.
|
|
97
|
+
const bearerMatch = authHeader.match(/^Bearer\s+(.+)$/i);
|
|
98
|
+
if (bearerMatch) {
|
|
99
|
+
token = bearerMatch[1];
|
|
100
|
+
} else if (authHeader) {
|
|
101
|
+
// Allow token directly in header (for tools that don't support Bearer).
|
|
102
|
+
token = authHeader;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Also check x-admin-token header as a secondary option.
|
|
106
|
+
if (!token) {
|
|
107
|
+
token = request.headers.get("x-admin-token");
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const result = verifyAdminToken(token);
|
|
111
|
+
|
|
112
|
+
if (!result.authorized) {
|
|
113
|
+
log.warn("Admin endpoint rejected", { reason: result.reason });
|
|
114
|
+
return {
|
|
115
|
+
authorized: false,
|
|
116
|
+
reason: result.reason,
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return { authorized: true };
|
|
121
|
+
}
|
package/src/admin.js
ADDED
|
@@ -0,0 +1,352 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @techstream/quark-core - Prisma Schema Introspection
|
|
3
|
+
*
|
|
4
|
+
* Hand-rolled Prisma schema parser that reads `.prisma` files and
|
|
5
|
+
* extracts model and enum definitions. Used by the admin CRUD layer
|
|
6
|
+
* to auto-generate admin interfaces.
|
|
7
|
+
*
|
|
8
|
+
* The parser is deliberately self-contained — no dependency on Prisma
|
|
9
|
+
* CLI or `@prisma/internals` — so it works in serverless / edge runtimes
|
|
10
|
+
* where those packages are unavailable.
|
|
11
|
+
*
|
|
12
|
+
* Usage:
|
|
13
|
+
* import { parseSchema, getModelByName, detectIdField, coerceId } from "@techstream/quark-core/admin";
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
17
|
+
import * as path from "node:path";
|
|
18
|
+
|
|
19
|
+
// ---------------------------------------------------------------------------
|
|
20
|
+
// Constants
|
|
21
|
+
// ---------------------------------------------------------------------------
|
|
22
|
+
|
|
23
|
+
const DEFAULT_SCHEMA_RELATIVE_PATHS = [
|
|
24
|
+
"schema.prisma",
|
|
25
|
+
"packages/db/prisma/schema.prisma",
|
|
26
|
+
"../../packages/db/prisma/schema.prisma",
|
|
27
|
+
// In Next.js standalone, cwd = .next/standalone/<app>
|
|
28
|
+
"../../../../../../packages/db/prisma/schema.prisma",
|
|
29
|
+
];
|
|
30
|
+
|
|
31
|
+
const SCALAR_TYPES = new Set([
|
|
32
|
+
"String",
|
|
33
|
+
"Int",
|
|
34
|
+
"Float",
|
|
35
|
+
"Decimal",
|
|
36
|
+
"BigInt",
|
|
37
|
+
"Boolean",
|
|
38
|
+
"DateTime",
|
|
39
|
+
"Json",
|
|
40
|
+
"Bytes",
|
|
41
|
+
]);
|
|
42
|
+
|
|
43
|
+
const NUMERIC_TYPES = new Set(["Int", "Float", "Decimal", "BigInt"]);
|
|
44
|
+
|
|
45
|
+
const CACHE_TTL_MS = 5_000;
|
|
46
|
+
|
|
47
|
+
// ---------------------------------------------------------------------------
|
|
48
|
+
// Cache
|
|
49
|
+
// ---------------------------------------------------------------------------
|
|
50
|
+
|
|
51
|
+
/** @type {{ models: Model[], enums: EnumDef[] } | null} */
|
|
52
|
+
let _parsed = null;
|
|
53
|
+
let _parsedAt = 0;
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Reset the schema cache. Useful for testing.
|
|
57
|
+
*/
|
|
58
|
+
export function resetSchemaCache() {
|
|
59
|
+
_parsed = null;
|
|
60
|
+
_parsedAt = 0;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// ---------------------------------------------------------------------------
|
|
64
|
+
// Schema file resolution
|
|
65
|
+
// ---------------------------------------------------------------------------
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Reads the Prisma schema text from disk, resolving the file path from
|
|
69
|
+
* environment variables, explicit override, or default relative paths.
|
|
70
|
+
*
|
|
71
|
+
* @param {string} [schemaPath] - Explicit path override
|
|
72
|
+
* @returns {string | null} Schema text, or null if not found
|
|
73
|
+
*/
|
|
74
|
+
function readSchemaText(schemaPath) {
|
|
75
|
+
if (schemaPath) {
|
|
76
|
+
const resolved = path.resolve(schemaPath);
|
|
77
|
+
if (existsSync(resolved)) return readFileSync(resolved, "utf-8");
|
|
78
|
+
throw new Error(
|
|
79
|
+
`Prisma schema file not found at override path: ${schemaPath}`,
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const envPath = process.env.PRISMA_SCHEMA_PATH;
|
|
84
|
+
if (envPath) {
|
|
85
|
+
const resolved = path.resolve(envPath);
|
|
86
|
+
if (existsSync(resolved)) return readFileSync(resolved, "utf-8");
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const candidates = [
|
|
90
|
+
...new Set(
|
|
91
|
+
DEFAULT_SCHEMA_RELATIVE_PATHS.map((rp) =>
|
|
92
|
+
path.resolve(process.cwd(), rp),
|
|
93
|
+
),
|
|
94
|
+
),
|
|
95
|
+
];
|
|
96
|
+
|
|
97
|
+
for (const candidate of candidates) {
|
|
98
|
+
if (existsSync(candidate)) {
|
|
99
|
+
return readFileSync(candidate, "utf-8");
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// ---------------------------------------------------------------------------
|
|
107
|
+
// Public API
|
|
108
|
+
// ---------------------------------------------------------------------------
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Parse the Prisma schema and return models + enums.
|
|
112
|
+
* Result is cached with a 5-second TTL so schema changes are picked up
|
|
113
|
+
* without requiring a full process restart.
|
|
114
|
+
*
|
|
115
|
+
* @param {string} [schemaPath] - Explicit path override
|
|
116
|
+
* @returns {{ models: Model[], enums: EnumDef[] }}
|
|
117
|
+
*/
|
|
118
|
+
export function parseSchema(schemaPath) {
|
|
119
|
+
const now = Date.now();
|
|
120
|
+
if (_parsed && now - _parsedAt < CACHE_TTL_MS) return _parsed;
|
|
121
|
+
|
|
122
|
+
const text = readSchemaText(schemaPath);
|
|
123
|
+
if (text === null) {
|
|
124
|
+
_parsed = { models: [], enums: [] };
|
|
125
|
+
_parsedAt = now;
|
|
126
|
+
return _parsed;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
_parsed = parseSchemaText(text);
|
|
130
|
+
_parsedAt = now;
|
|
131
|
+
return _parsed;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Returns all model definitions from the Prisma schema.
|
|
136
|
+
* @param {string} [schemaPath]
|
|
137
|
+
* @returns {Model[]}
|
|
138
|
+
*/
|
|
139
|
+
export function getModels(schemaPath) {
|
|
140
|
+
return parseSchema(schemaPath).models;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Returns all enum definitions from the Prisma schema.
|
|
145
|
+
* @param {string} [schemaPath]
|
|
146
|
+
* @returns {EnumDef[]}
|
|
147
|
+
*/
|
|
148
|
+
export function getEnums(schemaPath) {
|
|
149
|
+
return parseSchema(schemaPath).enums;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Returns a single model definition by name (case-insensitive).
|
|
154
|
+
* @param {string} name
|
|
155
|
+
* @param {string} [schemaPath]
|
|
156
|
+
* @returns {Model | undefined}
|
|
157
|
+
*/
|
|
158
|
+
export function getModelByName(name, schemaPath) {
|
|
159
|
+
const lower = name.toLowerCase();
|
|
160
|
+
return parseSchema(schemaPath).models.find(
|
|
161
|
+
(m) => m.name.toLowerCase() === lower,
|
|
162
|
+
);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Returns the @id field for a model, or null if none exists.
|
|
167
|
+
* @param {Model} model
|
|
168
|
+
* @returns {Field | null}
|
|
169
|
+
*/
|
|
170
|
+
export function detectIdField(model) {
|
|
171
|
+
return model.fields.find((f) => f.isId) ?? null;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Coerce a raw string ID to the correct type for a model's @id field.
|
|
176
|
+
* @param {Model} model
|
|
177
|
+
* @param {string} rawId
|
|
178
|
+
* @returns {string | number}
|
|
179
|
+
*/
|
|
180
|
+
export function coerceId(model, rawId) {
|
|
181
|
+
const field = detectIdField(model);
|
|
182
|
+
if (field && NUMERIC_TYPES.has(field.type)) return Number(rawId);
|
|
183
|
+
return rawId;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// ---------------------------------------------------------------------------
|
|
187
|
+
// Internal parser
|
|
188
|
+
// ---------------------------------------------------------------------------
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* @param {string} text
|
|
192
|
+
* @returns {{ models: Model[], enums: EnumDef[] }}
|
|
193
|
+
*/
|
|
194
|
+
function parseSchemaText(text) {
|
|
195
|
+
const lines = text.split("\n");
|
|
196
|
+
|
|
197
|
+
// --- Enums (first pass) ---
|
|
198
|
+
const enums = [];
|
|
199
|
+
let inEnum = null;
|
|
200
|
+
|
|
201
|
+
for (const line of lines) {
|
|
202
|
+
const stripped = stripComment(line).trim();
|
|
203
|
+
|
|
204
|
+
if (inEnum) {
|
|
205
|
+
if (stripped === "}") {
|
|
206
|
+
inEnum = null;
|
|
207
|
+
} else if (stripped) {
|
|
208
|
+
const val = stripped.split(/\s+/)[0];
|
|
209
|
+
if (val && !val.startsWith("@") && !val.startsWith("//")) {
|
|
210
|
+
enums[enums.length - 1].values.push(val);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
} else {
|
|
214
|
+
const m = stripped.match(/^enum\s+(\w+)\s*\{/);
|
|
215
|
+
if (m) {
|
|
216
|
+
inEnum = m[1];
|
|
217
|
+
enums.push({ name: m[1], values: [] });
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const enumNames = new Set(enums.map((e) => e.name));
|
|
223
|
+
const enumByName = Object.fromEntries(enums.map((e) => [e.name, e.values]));
|
|
224
|
+
|
|
225
|
+
// --- Models (second pass) ---
|
|
226
|
+
const models = [];
|
|
227
|
+
let inModel = null;
|
|
228
|
+
|
|
229
|
+
for (const line of lines) {
|
|
230
|
+
const stripped = stripComment(line).trim();
|
|
231
|
+
|
|
232
|
+
if (inModel) {
|
|
233
|
+
if (stripped === "}") {
|
|
234
|
+
inModel = null;
|
|
235
|
+
} else if (stripped && !stripped.startsWith("@@")) {
|
|
236
|
+
const field = parseField(stripped, enumNames, enumByName);
|
|
237
|
+
if (field) models[models.length - 1].fields.push(field);
|
|
238
|
+
}
|
|
239
|
+
} else {
|
|
240
|
+
const m = stripped.match(/^model\s+(\w+)\s*\{/);
|
|
241
|
+
if (m) {
|
|
242
|
+
inModel = m[1];
|
|
243
|
+
models.push({ name: m[1], fields: [], dbName: null });
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
return { models, enums };
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* @param {string} line
|
|
253
|
+
* @returns {string}
|
|
254
|
+
*/
|
|
255
|
+
function stripComment(line) {
|
|
256
|
+
const idx = line.indexOf("//");
|
|
257
|
+
return idx >= 0 ? line.substring(0, idx) : line;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* @param {string} line
|
|
262
|
+
* @param {Set<string>} enumNames
|
|
263
|
+
* @param {Record<string, string[]>} enumByName
|
|
264
|
+
* @returns {Field | null}
|
|
265
|
+
*/
|
|
266
|
+
function parseField(line, enumNames, enumByName) {
|
|
267
|
+
const tokens = line.split(/\s+/);
|
|
268
|
+
if (tokens.length < 2) return null;
|
|
269
|
+
|
|
270
|
+
const name = tokens[0];
|
|
271
|
+
if (name.startsWith("@") || name.startsWith("//")) return null;
|
|
272
|
+
|
|
273
|
+
let typeStr = tokens[1];
|
|
274
|
+
|
|
275
|
+
const isList = typeStr.endsWith("[]");
|
|
276
|
+
if (isList) typeStr = typeStr.slice(0, -2);
|
|
277
|
+
|
|
278
|
+
const isOptional = typeStr.endsWith("?");
|
|
279
|
+
if (isOptional) typeStr = typeStr.slice(0, -1);
|
|
280
|
+
|
|
281
|
+
const isRequired = !isOptional && !isList;
|
|
282
|
+
|
|
283
|
+
const restStr = tokens.slice(2).join(" ");
|
|
284
|
+
|
|
285
|
+
let kind;
|
|
286
|
+
if (SCALAR_TYPES.has(typeStr)) {
|
|
287
|
+
kind = "scalar";
|
|
288
|
+
} else if (enumNames.has(typeStr)) {
|
|
289
|
+
kind = "enum";
|
|
290
|
+
} else {
|
|
291
|
+
kind = "object";
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
const isId = /(?:^|\s)@id(?:\s|$)/.test(restStr);
|
|
295
|
+
const isUnique = /(?:^|\s)@unique(?:\s|$)/.test(restStr);
|
|
296
|
+
const hasDefaultValue = /(?:^|\s)@default\(/.test(restStr);
|
|
297
|
+
const isReadOnly = /(?:^|\s)@updatedAt(?:\s|$)/.test(restStr);
|
|
298
|
+
|
|
299
|
+
let relationName = null;
|
|
300
|
+
if (kind === "object") {
|
|
301
|
+
const namedMatch = restStr.match(/@relation\(\s*name:\s*"([^"]+)"/);
|
|
302
|
+
relationName = namedMatch ? namedMatch[1] : `${typeStr}To${name}`;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
return {
|
|
306
|
+
name,
|
|
307
|
+
type: typeStr,
|
|
308
|
+
kind,
|
|
309
|
+
isList,
|
|
310
|
+
isRequired,
|
|
311
|
+
isId,
|
|
312
|
+
isReadOnly,
|
|
313
|
+
hasDefaultValue,
|
|
314
|
+
isUnique,
|
|
315
|
+
relationName,
|
|
316
|
+
enumValues: kind === "enum" ? (enumByName[typeStr] ?? null) : null,
|
|
317
|
+
documentation: null,
|
|
318
|
+
};
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// ---------------------------------------------------------------------------
|
|
322
|
+
// Types
|
|
323
|
+
// ---------------------------------------------------------------------------
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* @typedef {Object} Field
|
|
327
|
+
* @property {string} name
|
|
328
|
+
* @property {string} type
|
|
329
|
+
* @property {"scalar" | "enum" | "object"} kind
|
|
330
|
+
* @property {boolean} isList
|
|
331
|
+
* @property {boolean} isRequired
|
|
332
|
+
* @property {boolean} isId
|
|
333
|
+
* @property {boolean} isReadOnly
|
|
334
|
+
* @property {boolean} hasDefaultValue
|
|
335
|
+
* @property {boolean} isUnique
|
|
336
|
+
* @property {string | null} relationName
|
|
337
|
+
* @property {string[] | null} enumValues
|
|
338
|
+
* @property {string | null} documentation
|
|
339
|
+
*/
|
|
340
|
+
|
|
341
|
+
/**
|
|
342
|
+
* @typedef {Object} Model
|
|
343
|
+
* @property {string} name
|
|
344
|
+
* @property {Field[]} fields
|
|
345
|
+
* @property {string | null} dbName
|
|
346
|
+
*/
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* @typedef {Object} EnumDef
|
|
350
|
+
* @property {string} name
|
|
351
|
+
* @property {string[]} values
|
|
352
|
+
*/
|