chadstart 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (115) hide show
  1. package/.dockerignore +10 -0
  2. package/.env.example +46 -0
  3. package/.github/workflows/browser-test.yml +34 -0
  4. package/.github/workflows/docker-publish.yml +54 -0
  5. package/.github/workflows/docs.yml +31 -0
  6. package/.github/workflows/npm-chadstart.yml +27 -0
  7. package/.github/workflows/npm-sdk.yml +38 -0
  8. package/.github/workflows/test.yml +85 -0
  9. package/.weblate +9 -0
  10. package/Dockerfile +23 -0
  11. package/README.md +348 -0
  12. package/admin/index.html +2802 -0
  13. package/admin/login.html +207 -0
  14. package/chadstart.example.yml +416 -0
  15. package/chadstart.schema.json +367 -0
  16. package/chadstart.yaml +53 -0
  17. package/cli/cli.js +295 -0
  18. package/core/api-generator.js +606 -0
  19. package/core/auth.js +298 -0
  20. package/core/db.js +384 -0
  21. package/core/entity-engine.js +166 -0
  22. package/core/error-reporter.js +132 -0
  23. package/core/file-storage.js +97 -0
  24. package/core/functions-engine.js +353 -0
  25. package/core/openapi.js +171 -0
  26. package/core/plugin-loader.js +92 -0
  27. package/core/realtime.js +93 -0
  28. package/core/schema-validator.js +50 -0
  29. package/core/seeder.js +231 -0
  30. package/core/telemetry.js +119 -0
  31. package/core/upload.js +372 -0
  32. package/core/workers/php_worker.php +19 -0
  33. package/core/workers/python_worker.py +33 -0
  34. package/core/workers/ruby_worker.rb +21 -0
  35. package/core/yaml-loader.js +64 -0
  36. package/demo/chadstart.yaml +178 -0
  37. package/demo/docker-compose.yml +31 -0
  38. package/demo/functions/greet.go +39 -0
  39. package/demo/functions/hello.cpp +18 -0
  40. package/demo/functions/hello.py +13 -0
  41. package/demo/functions/hello.rb +10 -0
  42. package/demo/functions/onTodoCreated.js +13 -0
  43. package/demo/functions/ping.sh +13 -0
  44. package/demo/functions/stats.js +22 -0
  45. package/demo/public/index.html +522 -0
  46. package/docker-compose.yml +17 -0
  47. package/docs/access-policies.md +155 -0
  48. package/docs/admin-ui.md +29 -0
  49. package/docs/angular.md +69 -0
  50. package/docs/astro.md +71 -0
  51. package/docs/auth.md +160 -0
  52. package/docs/cli.md +56 -0
  53. package/docs/config.md +127 -0
  54. package/docs/crud.md +627 -0
  55. package/docs/deploy.md +113 -0
  56. package/docs/docker.md +59 -0
  57. package/docs/entities.md +385 -0
  58. package/docs/functions.md +196 -0
  59. package/docs/getting-started.md +79 -0
  60. package/docs/groups.md +85 -0
  61. package/docs/index.md +5 -0
  62. package/docs/llm-rules.md +81 -0
  63. package/docs/middlewares.md +78 -0
  64. package/docs/overrides/home.html +350 -0
  65. package/docs/plugins.md +59 -0
  66. package/docs/react.md +75 -0
  67. package/docs/realtime.md +43 -0
  68. package/docs/s3-storage.md +40 -0
  69. package/docs/security.md +23 -0
  70. package/docs/stylesheets/extra.css +375 -0
  71. package/docs/svelte.md +71 -0
  72. package/docs/telemetry.md +97 -0
  73. package/docs/upload.md +168 -0
  74. package/docs/validation.md +115 -0
  75. package/docs/vue.md +86 -0
  76. package/docs/webhooks.md +87 -0
  77. package/index.js +11 -0
  78. package/locales/en/admin.json +169 -0
  79. package/mkdocs.yml +82 -0
  80. package/package.json +65 -0
  81. package/playwright.config.js +24 -0
  82. package/public/.gitkeep +0 -0
  83. package/sdk/README.md +284 -0
  84. package/sdk/package.json +39 -0
  85. package/sdk/scripts/build.js +58 -0
  86. package/sdk/src/index.js +368 -0
  87. package/sdk/test/sdk.test.cjs +340 -0
  88. package/sdk/types/index.d.ts +217 -0
  89. package/server/express-server.js +734 -0
  90. package/test/access-policies.test.js +96 -0
  91. package/test/ai.test.js +81 -0
  92. package/test/api-keys.test.js +361 -0
  93. package/test/auth.test.js +122 -0
  94. package/test/browser/admin-ui.spec.js +127 -0
  95. package/test/browser/global-setup.js +71 -0
  96. package/test/browser/global-teardown.js +11 -0
  97. package/test/db.test.js +227 -0
  98. package/test/entity-engine.test.js +193 -0
  99. package/test/error-reporter.test.js +140 -0
  100. package/test/functions-engine.test.js +240 -0
  101. package/test/groups.test.js +212 -0
  102. package/test/hot-reload.test.js +153 -0
  103. package/test/i18n.test.js +173 -0
  104. package/test/middleware.test.js +76 -0
  105. package/test/openapi.test.js +67 -0
  106. package/test/schema-validator.test.js +83 -0
  107. package/test/sdk.test.js +90 -0
  108. package/test/seeder.test.js +279 -0
  109. package/test/settings.test.js +109 -0
  110. package/test/telemetry.test.js +254 -0
  111. package/test/test.js +17 -0
  112. package/test/upload.test.js +265 -0
  113. package/test/validation.test.js +96 -0
  114. package/test/yaml-loader.test.js +93 -0
  115. package/utils/logger.js +24 -0
package/core/auth.js ADDED
@@ -0,0 +1,298 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * JWT-based authentication for authenticable entities.
5
+ *
6
+ * Endpoints per authenticable entity:
7
+ * POST /api/auth/:slug/signup
8
+ * POST /api/auth/:slug/login
9
+ * GET /api/auth/:slug/me
10
+ * GET /api/auth/:slug/api-keys
11
+ * POST /api/auth/:slug/api-keys
12
+ * DELETE /api/auth/:slug/api-keys/:id
13
+ */
14
+
15
+ const crypto = require('crypto');
16
+ const jwt = require('jsonwebtoken');
17
+ const bcrypt = require('bcryptjs');
18
+ const db = require('./db');
19
+ const logger = require('../utils/logger');
20
+
21
+ const API_KEY_PREFIX = 'cs_';
22
+
23
+ const JWT_SECRET = process.env.JWT_SECRET || process.env.TOKEN_SECRET_KEY || (() => {
24
+ if (process.env.NODE_ENV === 'production') {
25
+ throw new Error('JWT_SECRET must be set in production (e.g. `openssl rand -hex 64`).');
26
+ }
27
+ return 'chadstart-dev-secret-change-in-production';
28
+ })();
29
+ const JWT_EXPIRES = process.env.JWT_EXPIRES || '7d';
30
+ const BCRYPT_ROUNDS = 10;
31
+
32
+ function signToken(payload, expiresIn) {
33
+ return jwt.sign(payload, JWT_SECRET, { expiresIn: expiresIn !== undefined ? expiresIn : JWT_EXPIRES });
34
+ }
35
+ function verifyToken(token) { return jwt.verify(token, JWT_SECRET); }
36
+
37
+ // ─── API Keys ─────────────────────────────────────────────────────────────────
38
+
39
+ /**
40
+ * Initialize the _cs_api_keys system table.
41
+ * Must be called after initDb().
42
+ */
43
+ function initApiKeys() {
44
+ db.getDb().exec(`
45
+ CREATE TABLE IF NOT EXISTS "_cs_api_keys" (
46
+ "id" TEXT PRIMARY KEY,
47
+ "name" TEXT NOT NULL,
48
+ "keyHash" TEXT NOT NULL UNIQUE,
49
+ "userId" TEXT NOT NULL,
50
+ "userEntity" TEXT NOT NULL,
51
+ "permissions" TEXT NOT NULL DEFAULT '[]',
52
+ "entities" TEXT NOT NULL DEFAULT '[]',
53
+ "expiresAt" TEXT,
54
+ "createdAt" TEXT NOT NULL,
55
+ "updatedAt" TEXT NOT NULL,
56
+ "lastUsedAt" TEXT
57
+ )
58
+ `);
59
+ }
60
+
61
+ function _hashApiKey(key) {
62
+ return crypto.createHash('sha256').update(key).digest('hex');
63
+ }
64
+
65
+ /**
66
+ * Create a new API key for a user.
67
+ * @param {string} userId
68
+ * @param {string} userEntity Entity name (e.g. 'Admin')
69
+ * @param {object} opts { name, permissions, entities, expiresAt }
70
+ * @returns {{ key: string, record: object }} key is the plaintext — returned once only.
71
+ */
72
+ function createApiKey(userId, userEntity, opts = {}) {
73
+ const { name = 'API Key', permissions = [], entities = [], expiresAt = null } = opts;
74
+ const key = API_KEY_PREFIX + crypto.randomBytes(32).toString('hex');
75
+ const keyHash = _hashApiKey(key);
76
+ const now = new Date().toISOString();
77
+ const id = crypto.randomUUID();
78
+
79
+ db.getDb().prepare(
80
+ `INSERT INTO "_cs_api_keys" ("id","name","keyHash","userId","userEntity","permissions","entities","expiresAt","createdAt","updatedAt")
81
+ VALUES (?,?,?,?,?,?,?,?,?,?)`
82
+ ).run(
83
+ id, name, keyHash, userId, userEntity,
84
+ JSON.stringify(permissions), JSON.stringify(entities),
85
+ expiresAt || null, now, now
86
+ );
87
+
88
+ const record = db.getDb().prepare('SELECT * FROM "_cs_api_keys" WHERE "id" = ?').get(id);
89
+ const safe = _safeApiKeyRecord(record);
90
+ return { key, record: safe };
91
+ }
92
+
93
+ /**
94
+ * Verify an API key string. Returns the DB record (without keyHash) or null.
95
+ */
96
+ function verifyApiKeyStr(key) {
97
+ if (!key || !key.startsWith(API_KEY_PREFIX)) return null;
98
+ const hash = _hashApiKey(key);
99
+ const record = db.getDb().prepare('SELECT * FROM "_cs_api_keys" WHERE "keyHash" = ?').get(hash);
100
+ if (!record) return null;
101
+ if (record.expiresAt && new Date(record.expiresAt) < new Date()) return null;
102
+ // Update lastUsedAt asynchronously (best effort)
103
+ try {
104
+ db.getDb().prepare('UPDATE "_cs_api_keys" SET "lastUsedAt" = ? WHERE "id" = ?').run(new Date().toISOString(), record.id);
105
+ } catch { /* ignore */ }
106
+ return _safeApiKeyRecord(record);
107
+ }
108
+
109
+ /** List API keys for a specific user (without key hashes). */
110
+ function listApiKeys(userId, userEntity) {
111
+ return db.getDb()
112
+ .prepare('SELECT * FROM "_cs_api_keys" WHERE "userId" = ? AND "userEntity" = ? ORDER BY "createdAt" DESC')
113
+ .all(userId, userEntity)
114
+ .map(_safeApiKeyRecord);
115
+ }
116
+
117
+ /** List all API keys — admin view (without key hashes). */
118
+ function listAllApiKeys() {
119
+ return db.getDb()
120
+ .prepare('SELECT * FROM "_cs_api_keys" ORDER BY "createdAt" DESC')
121
+ .all()
122
+ .map(_safeApiKeyRecord);
123
+ }
124
+
125
+ /** Delete an API key by ID. */
126
+ function deleteApiKey(id) {
127
+ db.getDb().prepare('DELETE FROM "_cs_api_keys" WHERE "id" = ?').run(id);
128
+ }
129
+
130
+ /** Strip the keyHash before returning to clients. */
131
+ function _safeApiKeyRecord(record) {
132
+ if (!record) return null;
133
+ const { keyHash: _, ...safe } = record;
134
+ // Parse JSON arrays
135
+ if (typeof safe.permissions === 'string') {
136
+ try { safe.permissions = JSON.parse(safe.permissions); } catch { safe.permissions = []; }
137
+ }
138
+ if (typeof safe.entities === 'string') {
139
+ try { safe.entities = JSON.parse(safe.entities); } catch { safe.entities = []; }
140
+ }
141
+ return safe;
142
+ }
143
+
144
+ /**
145
+ * Resolve an Authorization header to a user payload.
146
+ * Supports both JWT Bearer tokens and API key strings (cs_ prefix).
147
+ * Returns { user, apiKeyPermissions, error }.
148
+ */
149
+ function resolveAuthHeader(header) {
150
+ if (!header || !header.startsWith('Bearer ')) {
151
+ return { user: null, apiKeyPermissions: null, error: 'no_header' };
152
+ }
153
+ const token = header.slice(7);
154
+
155
+ // API key
156
+ if (token.startsWith(API_KEY_PREFIX)) {
157
+ const record = verifyApiKeyStr(token);
158
+ if (!record) return { user: null, apiKeyPermissions: null, error: 'invalid_token' };
159
+ return {
160
+ user: { id: record.userId, entity: record.userEntity },
161
+ apiKeyPermissions: { operations: record.permissions, entities: record.entities },
162
+ error: null,
163
+ };
164
+ }
165
+
166
+ // JWT
167
+ try {
168
+ const payload = verifyToken(token);
169
+ return { user: payload, apiKeyPermissions: null, error: null };
170
+ } catch {
171
+ return { user: null, apiKeyPermissions: null, error: 'invalid_token' };
172
+ }
173
+ }
174
+
175
+ /**
176
+ * Register API key management routes for each authenticable entity.
177
+ * GET /api/auth/:slug/api-keys — list caller's keys
178
+ * POST /api/auth/:slug/api-keys — create a new key
179
+ * DELETE /api/auth/:slug/api-keys/:id — delete a key
180
+ */
181
+ function registerApiKeyRoutes(app, core) {
182
+ for (const entity of Object.values(core.authenticableEntities || {})) {
183
+ const slug = entity.slug;
184
+
185
+ // List caller's API keys
186
+ app.get(`/api/auth/${slug}/api-keys`, requireAuth(entity.name), (req, res) => {
187
+ try {
188
+ const keys = listApiKeys(req.user.id, entity.name);
189
+ res.json(keys);
190
+ } catch (e) { res.status(500).json({ error: e.message }); }
191
+ });
192
+
193
+ // Create a new API key
194
+ app.post(`/api/auth/${slug}/api-keys`, requireAuth(entity.name), (req, res) => {
195
+ try {
196
+ const { name, permissions, entities: keyEntities, expiresAt } = req.body || {};
197
+ const { key, record } = createApiKey(req.user.id, entity.name, {
198
+ name: name || 'API Key',
199
+ permissions: Array.isArray(permissions) ? permissions : [],
200
+ entities: Array.isArray(keyEntities) ? keyEntities : [],
201
+ expiresAt: expiresAt || null,
202
+ });
203
+ res.status(201).json({ key, record });
204
+ } catch (e) { res.status(500).json({ error: e.message }); }
205
+ });
206
+
207
+ // Delete one of the caller's API keys
208
+ app.delete(`/api/auth/${slug}/api-keys/:id`, requireAuth(entity.name), (req, res) => {
209
+ try {
210
+ const record = db.getDb().prepare('SELECT * FROM "_cs_api_keys" WHERE "id" = ?').get(req.params.id);
211
+ if (!record) return res.status(404).json({ error: 'API key not found' });
212
+ if (record.userId !== req.user.id || record.userEntity !== entity.name) {
213
+ return res.status(403).json({ error: 'Access denied' });
214
+ }
215
+ deleteApiKey(req.params.id);
216
+ res.json({ success: true });
217
+ } catch (e) { res.status(500).json({ error: e.message }); }
218
+ });
219
+ }
220
+ }
221
+
222
+ function registerAuthRoutes(app, core, emit) {
223
+ const _emit = typeof emit === 'function' ? emit : () => {};
224
+ for (const entity of Object.values(core.authenticableEntities || {})) {
225
+ const slug = entity.slug;
226
+ const table = entity.tableName;
227
+ const allowed = new Set(entity.properties.map((p) => p.name));
228
+ const sanitize = (body) => Object.fromEntries(Object.entries(body).filter(([k]) => allowed.has(k)));
229
+
230
+ // Check signup policy: forbidden => block, other values => allow
231
+ const signupPolicies = (entity.policies || {}).signup;
232
+ const signupForbidden = signupPolicies && signupPolicies.length > 0 && signupPolicies[0].access === 'forbidden';
233
+
234
+ app.post(`/api/auth/${slug}/signup`, async (req, res) => {
235
+ try {
236
+ if (signupForbidden) return res.status(403).json({ error: 'Signup is forbidden for this entity' });
237
+
238
+ const { email, password, ...rest } = req.body || {};
239
+ if (!email || !password) return res.status(400).json({ error: 'email and password are required' });
240
+ if (db.findAllSimple(table, { email }).length) return res.status(409).json({ error: 'Email already registered' });
241
+ const user = db.create(table, { email, password: await bcrypt.hash(password, BCRYPT_ROUNDS), ...sanitize(rest) });
242
+ _emit(`${entity.name}.created`, omitPassword(user));
243
+ res.status(201).json({ token: signToken({ id: user.id, entity: entity.name }), user: omitPassword(user) });
244
+ } catch (e) { logger.error('signup error', e.message); res.status(500).json({ error: e.message }); }
245
+ });
246
+
247
+ app.post(`/api/auth/${slug}/login`, async (req, res) => {
248
+ try {
249
+ const { email, password } = req.body || {};
250
+ if (!email || !password) return res.status(400).json({ error: 'email and password are required' });
251
+ const user = db.findAllSimple(table, { email })[0];
252
+ if (!user || !(await bcrypt.compare(password, user.password))) return res.status(401).json({ error: 'Invalid credentials' });
253
+ res.json({ token: signToken({ id: user.id, entity: entity.name }), user: omitPassword(user) });
254
+ } catch (e) { logger.error('login error', e.message); res.status(500).json({ error: e.message }); }
255
+ });
256
+
257
+ app.get(`/api/auth/${slug}/me`, requireAuth(entity.name), (req, res) => {
258
+ const user = db.findById(table, req.user.id);
259
+ if (!user) return res.status(404).json({ error: 'User not found' });
260
+ res.json(omitPassword(user));
261
+ });
262
+
263
+ logger.info(` Registered auth routes at /api/auth/${slug}/`);
264
+ }
265
+ }
266
+
267
+ function requireAuth(entityName) {
268
+ return (req, res, next) => {
269
+ const { user, apiKeyPermissions, error } = resolveAuthHeader(req.headers.authorization);
270
+ if (!user) return res.status(401).json({ error: 'Authorization header required (Bearer <token>)' });
271
+ if (error === 'invalid_token') return res.status(401).json({ error: 'Invalid or expired token' });
272
+ if (entityName && user.entity !== entityName) return res.status(403).json({ error: 'Token does not belong to this collection' });
273
+ req.user = user;
274
+ if (apiKeyPermissions) req._apiKeyPermissions = apiKeyPermissions;
275
+ next();
276
+ };
277
+ }
278
+
279
+ function optionalAuth(req, _res, next) {
280
+ const { user, apiKeyPermissions } = resolveAuthHeader(req.headers.authorization);
281
+ if (user) {
282
+ req.user = user;
283
+ if (apiKeyPermissions) req._apiKeyPermissions = apiKeyPermissions;
284
+ }
285
+ next();
286
+ }
287
+
288
+ function omitPassword(user) {
289
+ const { password: _, ...rest } = user;
290
+ return rest;
291
+ }
292
+
293
+ module.exports = {
294
+ registerAuthRoutes, registerApiKeyRoutes, initApiKeys,
295
+ requireAuth, optionalAuth, resolveAuthHeader,
296
+ signToken, verifyToken, omitPassword, JWT_SECRET,
297
+ createApiKey, listApiKeys, listAllApiKeys, deleteApiKey, verifyApiKeyStr,
298
+ };
package/core/db.js ADDED
@@ -0,0 +1,384 @@
1
+ 'use strict';
2
+
3
+ const crypto = require('crypto');
4
+ const fs = require('fs');
5
+ const Database = require('better-sqlite3');
6
+ const path = require('path');
7
+ const logger = require('../utils/logger');
8
+
9
+ let db = null;
10
+ // Cached entity metadata for relation queries. Set by initDb.
11
+ let _core = null;
12
+
13
+ const SQL_TYPE = {
14
+ text: 'TEXT', string: 'TEXT', richText: 'TEXT',
15
+ integer: 'INTEGER', int: 'INTEGER',
16
+ number: 'REAL', float: 'REAL', real: 'REAL', money: 'REAL',
17
+ boolean: 'INTEGER', bool: 'INTEGER',
18
+ date: 'TEXT', timestamp: 'TEXT', email: 'TEXT', link: 'TEXT',
19
+ password: 'TEXT', choice: 'TEXT', location: 'TEXT',
20
+ file: 'TEXT', image: 'TEXT', group: 'TEXT', json: 'TEXT',
21
+ };
22
+
23
+ function generateUUID() {
24
+ return crypto.randomUUID();
25
+ }
26
+
27
+ function initDb(core, dbPath) {
28
+ const resolved = dbPath ? path.resolve(dbPath) : path.resolve(process.env.DB_PATH || 'data/chadstart.db');
29
+ try {
30
+ fs.mkdirSync(path.dirname(resolved), { recursive: true });
31
+ } catch (err) {
32
+ throw new Error(`Failed to create database directory "${path.dirname(resolved)}": ${err.message}`);
33
+ }
34
+ try {
35
+ db = new Database(resolved);
36
+ } catch (err) {
37
+ throw new Error(
38
+ `Failed to open database at "${resolved}": ${err.message}\n` +
39
+ ` Make sure the directory exists and is writable, and that no other process has an exclusive lock on the file.`
40
+ );
41
+ }
42
+ db.pragma('journal_mode = WAL');
43
+ db.pragma('foreign_keys = ON');
44
+ _core = core;
45
+ logger.info(`Database initialized at ${resolved}`);
46
+ syncSchema(core);
47
+ return db;
48
+ }
49
+
50
+ function syncSchema(core) {
51
+ for (const entity of Object.values(core.entities)) {
52
+ const cols = buildColumnDefs(entity, core.entities);
53
+ const existing = getExistingColumns(entity.tableName);
54
+
55
+ if (!existing) {
56
+ const defs = ['"id" TEXT PRIMARY KEY', '"createdAt" TEXT', '"updatedAt" TEXT', ...cols.map((c) => c.def)];
57
+ db.exec(`CREATE TABLE "${entity.tableName}" (${defs.join(', ')})`);
58
+ } else {
59
+ // Add createdAt/updatedAt if missing (migration)
60
+ if (!existing.has('createdAt')) {
61
+ db.exec(`ALTER TABLE "${entity.tableName}" ADD COLUMN "createdAt" TEXT`);
62
+ }
63
+ if (!existing.has('updatedAt')) {
64
+ db.exec(`ALTER TABLE "${entity.tableName}" ADD COLUMN "updatedAt" TEXT`);
65
+ }
66
+ for (const col of cols) {
67
+ if (!existing.has(col.name)) {
68
+ db.exec(`ALTER TABLE "${entity.tableName}" ADD COLUMN ${stripConstraints(col.def)}`);
69
+ }
70
+ }
71
+ }
72
+ }
73
+
74
+ // belongsToMany junction tables
75
+ for (const entity of Object.values(core.entities)) {
76
+ for (const rel of entity.belongsToMany || []) {
77
+ const relName = typeof rel === 'string' ? rel : (rel.entity || rel.name);
78
+ const relEntity = core.entities[relName];
79
+ if (!relEntity) continue;
80
+ const [a, b] = [entity.tableName, relEntity.tableName].sort();
81
+ const jt = `${a}_${b}`;
82
+ if (!getExistingColumns(jt)) {
83
+ db.exec(
84
+ `CREATE TABLE "${jt}" ("${a}_id" TEXT REFERENCES "${a}"(id), "${b}_id" TEXT REFERENCES "${b}"(id), PRIMARY KEY ("${a}_id", "${b}_id"))`
85
+ );
86
+ }
87
+ }
88
+ }
89
+ }
90
+
91
+ function getExistingColumns(table) {
92
+ try {
93
+ const rows = db.pragma(`table_info("${table}")`);
94
+ return rows && rows.length ? new Set(rows.map((r) => r.name)) : null;
95
+ } catch { return null; }
96
+ }
97
+
98
+ function stripConstraints(def) {
99
+ return def.replace(/\bNOT\s+NULL\b/gi, '').replace(/\bUNIQUE\b/gi, '')
100
+ .replace(/\bREFERENCES\s+"[^"]+"\([^)]+\)/gi, '').replace(/\s{2,}/g, ' ').trim();
101
+ }
102
+
103
+ function buildColumnDefs(entity, allEntities) {
104
+ const cols = [];
105
+
106
+ if (entity.authenticable) {
107
+ cols.push({ name: 'email', def: '"email" TEXT NOT NULL UNIQUE' });
108
+ cols.push({ name: 'password', def: '"password" TEXT NOT NULL' });
109
+ }
110
+
111
+ for (const p of entity.properties) {
112
+ // Skip email/password for authenticable entities — they are already added above
113
+ if (entity.authenticable && (p.name === 'email' || p.name === 'password')) continue;
114
+ cols.push({ name: p.name, def: `"${p.name}" ${SQL_TYPE[p.type] || 'TEXT'}` });
115
+ }
116
+
117
+ for (const rel of entity.belongsTo || []) {
118
+ const relName = typeof rel === 'string' ? rel : (rel.entity || rel.name);
119
+ const ref = allEntities[relName];
120
+ if (ref) {
121
+ const fk = `${ref.tableName}_id`;
122
+ cols.push({ name: fk, def: `"${fk}" TEXT REFERENCES "${ref.tableName}"(id)` });
123
+ }
124
+ }
125
+
126
+ return cols;
127
+ }
128
+
129
+ function getDb() {
130
+ if (!db) throw new Error('Database not initialized. Call initDb() first.');
131
+ return db;
132
+ }
133
+
134
+ // ─── Filter parsing ──────────────────────────────────────────────────────────
135
+
136
+ const FILTER_SUFFIXES = {
137
+ _eq: (col, val) => ({ sql: `"${col}" = ?`, val }),
138
+ _neq: (col, val) => ({ sql: `"${col}" != ?`, val }),
139
+ _gt: (col, val) => ({ sql: `"${col}" > ?`, val }),
140
+ _gte: (col, val) => ({ sql: `"${col}" >= ?`, val }),
141
+ _lt: (col, val) => ({ sql: `"${col}" < ?`, val }),
142
+ _lte: (col, val) => ({ sql: `"${col}" <= ?`, val }),
143
+ _like: (col, val) => ({ sql: `"${col}" LIKE ?`, val }),
144
+ _in: (col, val) => {
145
+ const items = String(val).split(',');
146
+ return { sql: `"${col}" IN (${items.map(() => '?').join(',')})`, val: items };
147
+ },
148
+ };
149
+
150
+ /**
151
+ * Parse query string params into filter clauses.
152
+ * Supports: prop=val (exact match), prop_eq=val, prop_gt=val, etc.
153
+ */
154
+ function parseFilters(query, validColumns) {
155
+ const clauses = [];
156
+ const values = [];
157
+ const reserved = new Set(['page', 'perPage', 'orderBy', 'order', 'relations']);
158
+
159
+ for (const [key, val] of Object.entries(query)) {
160
+ if (reserved.has(key)) continue;
161
+
162
+ let matched = false;
163
+ for (const [suffix, builder] of Object.entries(FILTER_SUFFIXES)) {
164
+ if (key.endsWith(suffix)) {
165
+ const col = key.slice(0, -suffix.length);
166
+ if (validColumns.has(col)) {
167
+ const result = builder(col, val);
168
+ clauses.push(result.sql);
169
+ if (Array.isArray(result.val)) values.push(...result.val);
170
+ else values.push(result.val);
171
+ }
172
+ matched = true;
173
+ break;
174
+ }
175
+ }
176
+
177
+ // Exact match (no suffix)
178
+ if (!matched && validColumns.has(key)) {
179
+ clauses.push(`"${key}" = ?`);
180
+ values.push(val);
181
+ }
182
+ }
183
+
184
+ return { clauses, values };
185
+ }
186
+
187
+ // ─── CRUD ────────────────────────────────────────────────────────────────────
188
+
189
+ /**
190
+ * Query rows with filter suffixes, ordering, and pagination.
191
+ * opts: { page, perPage, orderBy, order, relations }
192
+ */
193
+ function findAll(table, query = {}, opts = {}) {
194
+ const d = getDb();
195
+ const validCols = new Set(d.pragma(`table_info("${table}")`).map((r) => r.name));
196
+ const { clauses, values } = parseFilters(query, validCols);
197
+
198
+ let sql = `SELECT * FROM "${table}"`;
199
+ if (clauses.length) sql += ` WHERE ${clauses.join(' AND ')}`;
200
+
201
+ // Ordering — only allow column names that exist and match safe pattern
202
+ const SAFE_COL = /^[a-zA-Z_][a-zA-Z0-9_]*$/;
203
+ const orderBy = opts.orderBy || 'createdAt';
204
+ const orderDir = (opts.order || 'DESC').toUpperCase() === 'ASC' ? 'ASC' : 'DESC';
205
+ if (validCols.has(orderBy) && SAFE_COL.test(orderBy)) {
206
+ sql += ` ORDER BY "${orderBy}" ${orderDir}`;
207
+ }
208
+
209
+ // Count total before pagination
210
+ const countSql = sql.replace(/^SELECT \*/, 'SELECT COUNT(*) as total');
211
+ const total = d.prepare(countSql).get(...values).total;
212
+
213
+ // Pagination
214
+ const page = Math.max(1, parseInt(opts.page, 10) || 1);
215
+ const perPage = Math.min(1000, Math.max(1, parseInt(opts.perPage, 10) || 10));
216
+ const offset = (page - 1) * perPage;
217
+ sql += ` LIMIT ? OFFSET ?`;
218
+
219
+ const data = d.prepare(sql).all(...values, perPage, offset);
220
+ const lastPage = Math.max(1, Math.ceil(total / perPage));
221
+
222
+ return {
223
+ data,
224
+ currentPage: page,
225
+ lastPage,
226
+ from: total > 0 ? offset + 1 : 0,
227
+ to: Math.min(offset + perPage, total),
228
+ total,
229
+ perPage,
230
+ };
231
+ }
232
+
233
+ /**
234
+ * Simple findAll without pagination for internal use (e.g., auth lookups).
235
+ */
236
+ function findAllSimple(table, filters = {}) {
237
+ const d = getDb();
238
+ const keys = Object.keys(filters);
239
+ if (!keys.length) return d.prepare(`SELECT * FROM "${table}"`).all();
240
+ const valid = new Set(d.pragma(`table_info("${table}")`).map((r) => r.name));
241
+ const safe = Object.fromEntries(keys.filter((k) => valid.has(k)).map((k) => [k, filters[k]]));
242
+ if (!Object.keys(safe).length) return d.prepare(`SELECT * FROM "${table}"`).all();
243
+ const where = Object.keys(safe).map((k) => `"${k}" = ?`).join(' AND ');
244
+ return d.prepare(`SELECT * FROM "${table}" WHERE ${where}`).all(...Object.values(safe));
245
+ }
246
+
247
+ function findById(table, id) {
248
+ return getDb().prepare(`SELECT * FROM "${table}" WHERE id = ?`).get(id) || null;
249
+ }
250
+
251
+ function create(table, data) {
252
+ const d = getDb();
253
+ const now = new Date().toISOString();
254
+ const id = generateUUID();
255
+ const full = { id, createdAt: now, updatedAt: now, ...data };
256
+ const keys = Object.keys(full);
257
+ const cols = keys.map((k) => `"${k}"`).join(', ');
258
+ const ph = keys.map(() => '?').join(', ');
259
+ d.prepare(`INSERT INTO "${table}" (${cols}) VALUES (${ph})`).run(...Object.values(full));
260
+ return findById(table, id);
261
+ }
262
+
263
+ function update(table, id, data) {
264
+ const now = new Date().toISOString();
265
+ const full = { ...data, updatedAt: now };
266
+ const keys = Object.keys(full);
267
+ if (!keys.length) return findById(table, id);
268
+ const set = keys.map((k) => `"${k}" = ?`).join(', ');
269
+ getDb().prepare(`UPDATE "${table}" SET ${set} WHERE id = ?`).run(...Object.values(full), id);
270
+ return findById(table, id);
271
+ }
272
+
273
+ function remove(table, id) {
274
+ const existing = findById(table, id);
275
+ if (!existing) return null;
276
+ getDb().prepare(`DELETE FROM "${table}" WHERE id = ?`).run(id);
277
+ return existing;
278
+ }
279
+
280
+ // ─── Relation helpers ────────────────────────────────────────────────────────
281
+
282
+ /**
283
+ * Load relations for a single row. Mutates the row in-place.
284
+ * relationNames: comma-separated string or array.
285
+ */
286
+ function loadRelations(row, entity, relationNames) {
287
+ if (!row || !entity || !relationNames || !_core) return row;
288
+ const names = Array.isArray(relationNames) ? relationNames : relationNames.split(',').map((s) => s.trim());
289
+
290
+ for (const relName of names) {
291
+ // belongsTo: look up the FK column
292
+ const btRel = (entity.belongsTo || []).find((r) => {
293
+ const rName = typeof r === 'string' ? r : (r.name || r.entity);
294
+ return rName.toLowerCase() === relName.toLowerCase();
295
+ });
296
+ if (btRel) {
297
+ const relEntityName = typeof btRel === 'string' ? btRel : (btRel.entity || btRel.name);
298
+ const relEntity = _core.entities[relEntityName];
299
+ if (relEntity) {
300
+ const fk = `${relEntity.tableName}_id`;
301
+ if (row[fk]) {
302
+ row[relName] = findById(relEntity.tableName, row[fk]);
303
+ } else {
304
+ row[relName] = null;
305
+ }
306
+ }
307
+ continue;
308
+ }
309
+
310
+ // belongsToMany: look up junction table
311
+ const btmRel = (entity.belongsToMany || []).find((r) => {
312
+ const rName = typeof r === 'string' ? r : (r.name || r.entity);
313
+ return rName.toLowerCase() === relName.toLowerCase();
314
+ });
315
+ if (btmRel) {
316
+ const relEntityName = typeof btmRel === 'string' ? btmRel : (btmRel.entity || btmRel.name);
317
+ const relEntity = _core.entities[relEntityName];
318
+ if (relEntity) {
319
+ const [a, b] = [entity.tableName, relEntity.tableName].sort();
320
+ const jt = `${a}_${b}`;
321
+ const myCol = `${entity.tableName}_id`;
322
+ const otherCol = `${relEntity.tableName}_id`;
323
+ const related = getDb()
324
+ .prepare(`SELECT t.* FROM "${relEntity.tableName}" t JOIN "${jt}" j ON j."${otherCol}" = t.id WHERE j."${myCol}" = ?`)
325
+ .all(row.id);
326
+ row[relName] = related;
327
+ }
328
+ continue;
329
+ }
330
+
331
+ // hasMany (reverse belongsTo): another entity belongsTo this entity
332
+ for (const otherEntity of Object.values(_core.entities)) {
333
+ const reverseRel = (otherEntity.belongsTo || []).find((r) => {
334
+ const rEntity = typeof r === 'string' ? r : (r.entity || r.name);
335
+ return rEntity === entity.name;
336
+ });
337
+ if (reverseRel && otherEntity.slug.toLowerCase() === relName.toLowerCase()) {
338
+ const fk = `${entity.tableName}_id`;
339
+ row[relName] = getDb()
340
+ .prepare(`SELECT * FROM "${otherEntity.tableName}" WHERE "${fk}" = ?`)
341
+ .all(row.id);
342
+ break;
343
+ }
344
+ }
345
+ }
346
+
347
+ return row;
348
+ }
349
+
350
+ /**
351
+ * Store belongsToMany relations for a record.
352
+ * body may contain keys like `skillIds: [id1, id2]`.
353
+ */
354
+ function saveBelongsToMany(entity, recordId, body) {
355
+ if (!_core) return;
356
+ for (const rel of entity.belongsToMany || []) {
357
+ const relEntityName = typeof rel === 'string' ? rel : (rel.entity || rel.name);
358
+ const relEntity = _core.entities[relEntityName];
359
+ if (!relEntity) continue;
360
+
361
+ // Convention: entityIds (camelCase plural)
362
+ const idsKey = `${relEntityName.charAt(0).toLowerCase() + relEntityName.slice(1)}Ids`;
363
+ const ids = body[idsKey];
364
+ if (!Array.isArray(ids)) continue;
365
+
366
+ const [a, b] = [entity.tableName, relEntity.tableName].sort();
367
+ const jt = `${a}_${b}`;
368
+ const myCol = `${entity.tableName}_id`;
369
+ const otherCol = `${relEntity.tableName}_id`;
370
+
371
+ // Clear existing
372
+ getDb().prepare(`DELETE FROM "${jt}" WHERE "${myCol}" = ?`).run(recordId);
373
+
374
+ // Insert new
375
+ const ins = getDb().prepare(`INSERT OR IGNORE INTO "${jt}" ("${myCol}", "${otherCol}") VALUES (?, ?)`);
376
+ for (const otherId of ids) ins.run(recordId, otherId);
377
+ }
378
+ }
379
+
380
+ module.exports = {
381
+ initDb, syncSchema, getDb, generateUUID,
382
+ findAll, findAllSimple, findById, create, update, remove,
383
+ loadRelations, saveBelongsToMany,
384
+ };