@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.
Files changed (68) hide show
  1. package/README.md +63 -0
  2. package/package.json +80 -0
  3. package/src/admin-auth.js +121 -0
  4. package/src/admin.js +352 -0
  5. package/src/admin.test.js +257 -0
  6. package/src/auth/index.js +160 -0
  7. package/src/auth/middleware.js +106 -0
  8. package/src/auth/middleware.test.js +116 -0
  9. package/src/auth/password.js +9 -0
  10. package/src/auth.test.js +235 -0
  11. package/src/authorization.js +235 -0
  12. package/src/authorization.test.js +314 -0
  13. package/src/cache.js +137 -0
  14. package/src/cache.test.js +217 -0
  15. package/src/core.js +32 -0
  16. package/src/csrf.js +122 -0
  17. package/src/csrf.test.js +169 -0
  18. package/src/db-instrumentation.js +281 -0
  19. package/src/db-instrumentation.test.js +284 -0
  20. package/src/db-metrics.js +26 -0
  21. package/src/db.js +218 -0
  22. package/src/db.test.js +365 -0
  23. package/src/email-templates.js +134 -0
  24. package/src/email-templates.test.js +124 -0
  25. package/src/email.js +314 -0
  26. package/src/email.test.js +498 -0
  27. package/src/error-reporter.js +266 -0
  28. package/src/error-reporter.test.js +236 -0
  29. package/src/errors.js +192 -0
  30. package/src/errors.test.js +128 -0
  31. package/src/exports.test.js +154 -0
  32. package/src/file-validation.js +249 -0
  33. package/src/file-validation.test.js +207 -0
  34. package/src/index.js +58 -0
  35. package/src/locale.js +21 -0
  36. package/src/logger.js +182 -0
  37. package/src/logger.test.js +287 -0
  38. package/src/mail.js +50 -0
  39. package/src/metrics.js +379 -0
  40. package/src/metrics.test.js +380 -0
  41. package/src/multipart.js +144 -0
  42. package/src/multipart.test.js +188 -0
  43. package/src/pagination.js +139 -0
  44. package/src/pagination.test.js +210 -0
  45. package/src/query-builder.js +183 -0
  46. package/src/query-builder.test.js +234 -0
  47. package/src/queue/index.js +446 -0
  48. package/src/queue/queue.test.js +365 -0
  49. package/src/rate-limiter.js +253 -0
  50. package/src/rate-limiter.test.js +130 -0
  51. package/src/redis.js +154 -0
  52. package/src/redis.test.js +150 -0
  53. package/src/request-logger.js +211 -0
  54. package/src/request-logger.test.js +286 -0
  55. package/src/sms.js +208 -0
  56. package/src/storage-s3.js +236 -0
  57. package/src/storage.js +214 -0
  58. package/src/storage.test.js +457 -0
  59. package/src/stripe.js +89 -0
  60. package/src/testing/factories.js +89 -0
  61. package/src/testing/helpers.js +266 -0
  62. package/src/testing/index.js +46 -0
  63. package/src/testing/mocks.js +480 -0
  64. package/src/testing/testing.test.js +543 -0
  65. package/src/types.js +74 -0
  66. package/src/utils.js +219 -0
  67. package/src/utils.test.js +193 -0
  68. 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
+ */