@simplix-react/mock 0.0.1

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.
@@ -0,0 +1,157 @@
1
+ import { PGlite } from '@electric-sql/pglite';
2
+ import * as msw from 'msw';
3
+ import { EntityDefinition, OperationDefinition, ApiContractConfig } from '@simplix-react/contract';
4
+ import { z } from 'zod';
5
+
6
+ /**
7
+ * Initialize a PGlite instance with IndexedDB persistence.
8
+ */
9
+ declare function initPGlite(dataDir: string): Promise<PGlite>;
10
+ /**
11
+ * Get the current PGlite instance.
12
+ * Throws if not initialized.
13
+ */
14
+ declare function getPGliteInstance(): PGlite;
15
+ /**
16
+ * Reset the PGlite instance (for testing).
17
+ */
18
+ declare function resetPGliteInstance(): void;
19
+
20
+ type RequestHandler = unknown;
21
+ interface MockServerConfig {
22
+ /**
23
+ * IndexedDB data directory for PGlite persistence
24
+ */
25
+ dataDir?: string;
26
+ /**
27
+ * Migration functions to run in order
28
+ */
29
+ migrations: Array<(db: PGlite) => Promise<void>>;
30
+ /**
31
+ * Seed functions to run in order (after migrations)
32
+ */
33
+ seed: Array<(db: PGlite) => Promise<void>>;
34
+ /**
35
+ * MSW request handlers
36
+ */
37
+ handlers: RequestHandler[];
38
+ }
39
+ /**
40
+ * Set up the mock server with PGlite + MSW.
41
+ *
42
+ * 1. Initializes PGlite at the given dataDir
43
+ * 2. Runs all migrations sequentially
44
+ * 3. Runs all seed functions sequentially
45
+ * 4. Starts the MSW service worker
46
+ */
47
+ declare function setupMockWorker(config: MockServerConfig): Promise<void>;
48
+
49
+ type AnyEntityDef = EntityDefinition<z.ZodTypeAny, z.ZodTypeAny, z.ZodTypeAny>;
50
+ type AnyOperationDef = OperationDefinition<z.ZodTypeAny, z.ZodTypeAny>;
51
+ /**
52
+ * Extended entity definition with mock-specific config.
53
+ * Users pass this as part of the contract config.
54
+ */
55
+ interface MockEntityConfig {
56
+ tableName?: string;
57
+ }
58
+ /**
59
+ * Derive MSW request handlers from an API contract config.
60
+ *
61
+ * Generates standard CRUD handlers for each entity:
62
+ * - GET list (by parent if defined)
63
+ * - GET by id
64
+ * - POST create
65
+ * - PATCH update
66
+ * - DELETE
67
+ *
68
+ * For entities with mock.tableName defined, auto-generates SQL queries.
69
+ */
70
+ declare function deriveMockHandlers<TEntities extends Record<string, AnyEntityDef>, TOperations extends Record<string, AnyOperationDef>>(config: ApiContractConfig<TEntities, TOperations>, mockConfig?: Record<string, MockEntityConfig>): msw.HttpHandler[];
71
+
72
+ /**
73
+ * Unified result type for all mock repository operations.
74
+ */
75
+ interface MockResult<T> {
76
+ success: boolean;
77
+ data?: T;
78
+ error?: {
79
+ code: string;
80
+ message: string;
81
+ };
82
+ }
83
+ /**
84
+ * Create a successful result.
85
+ */
86
+ declare function mockSuccess<T>(data: T): MockResult<T>;
87
+ /**
88
+ * Create a failure result.
89
+ */
90
+ declare function mockFailure(error: {
91
+ code: string;
92
+ message: string;
93
+ }): MockResult<never>;
94
+
95
+ /**
96
+ * Database row type
97
+ */
98
+ type DbRow = Record<string, unknown>;
99
+ /**
100
+ * Convert snake_case string to camelCase
101
+ */
102
+ declare function toCamelCase(str: string): string;
103
+ /**
104
+ * Convert camelCase string to snake_case
105
+ */
106
+ declare function toSnakeCase(str: string): string;
107
+ /**
108
+ * Map a database row (snake_case) to a camelCase object.
109
+ * Automatically converts columns ending in `_at` to Date objects.
110
+ */
111
+ declare function mapRow<T>(row: DbRow): T;
112
+ /**
113
+ * Map an array of database rows to camelCase objects.
114
+ */
115
+ declare function mapRows<T>(rows: DbRow[]): T[];
116
+
117
+ interface SetClauseResult {
118
+ clause: string;
119
+ values: unknown[];
120
+ nextIndex: number;
121
+ }
122
+ /**
123
+ * Build a dynamic SQL SET clause from a partial object.
124
+ * Converts camelCase keys to snake_case columns.
125
+ * Skips undefined values.
126
+ * Automatically appends `updated_at = NOW()`.
127
+ */
128
+ declare function buildSetClause<T extends object>(input: T, startIndex?: number): SetClauseResult;
129
+
130
+ interface MockError {
131
+ status: number;
132
+ code: string;
133
+ message: string;
134
+ }
135
+ /**
136
+ * Map PostgreSQL errors to HTTP-friendly MockError.
137
+ */
138
+ declare function mapPgError(err: unknown): MockError;
139
+
140
+ /**
141
+ * Check if a table exists in the database.
142
+ */
143
+ declare function tableExists(db: PGlite, tableName: string): Promise<boolean>;
144
+ /**
145
+ * Check if a column exists in a table.
146
+ */
147
+ declare function columnExists(db: PGlite, tableName: string, columnName: string): Promise<boolean>;
148
+ /**
149
+ * Execute multiple SQL statements separated by semicolons.
150
+ */
151
+ declare function executeSql(db: PGlite, sql: string): Promise<void>;
152
+ /**
153
+ * Add a column to a table if it doesn't exist.
154
+ */
155
+ declare function addColumnIfNotExists(db: PGlite, tableName: string, columnName: string, columnDef: string): Promise<void>;
156
+
157
+ export { type DbRow, type MockError, type MockResult, type MockServerConfig, type SetClauseResult, addColumnIfNotExists, buildSetClause, columnExists, deriveMockHandlers, executeSql, getPGliteInstance, initPGlite, mapPgError, mapRow, mapRows, mockFailure, mockSuccess, resetPGliteInstance, setupMockWorker, tableExists, toCamelCase, toSnakeCase };
package/dist/index.js ADDED
@@ -0,0 +1,362 @@
1
+ import { http, HttpResponse } from 'msw';
2
+
3
+ // src/pglite.ts
4
+ var instance = null;
5
+ async function initPGlite(dataDir) {
6
+ if (instance) return instance;
7
+ const { PGlite: PGliteClass } = await import('@electric-sql/pglite');
8
+ instance = new PGliteClass(dataDir);
9
+ return instance;
10
+ }
11
+ function getPGliteInstance() {
12
+ if (!instance) {
13
+ throw new Error(
14
+ "PGlite not initialized. Call initPGlite() first."
15
+ );
16
+ }
17
+ return instance;
18
+ }
19
+ function resetPGliteInstance() {
20
+ instance = null;
21
+ }
22
+
23
+ // src/msw.ts
24
+ async function setupMockWorker(config) {
25
+ const {
26
+ dataDir = "idb://simplix-mock",
27
+ migrations,
28
+ seed,
29
+ handlers
30
+ } = config;
31
+ const db = await initPGlite(dataDir);
32
+ for (const migration of migrations) {
33
+ await migration(db);
34
+ }
35
+ for (const seedFn of seed) {
36
+ await seedFn(db);
37
+ }
38
+ const { setupWorker } = await import('msw/browser');
39
+ const worker = setupWorker(...handlers);
40
+ await worker.start({
41
+ onUnhandledRequest: "bypass",
42
+ quiet: true
43
+ });
44
+ }
45
+
46
+ // src/sql/row-mapping.ts
47
+ function toCamelCase(str) {
48
+ return str.replace(/_([a-z])/g, (_, c) => c.toUpperCase());
49
+ }
50
+ function toSnakeCase(str) {
51
+ return str.replace(/([a-z0-9])([A-Z])/g, "$1_$2").toLowerCase();
52
+ }
53
+ function mapRow(row) {
54
+ const result = {};
55
+ for (const [key, value] of Object.entries(row)) {
56
+ const camelKey = toCamelCase(key);
57
+ if ((key.endsWith("_at") || key === "installed_at" || key === "last_seen_at") && value !== null && value !== void 0) {
58
+ result[camelKey] = new Date(value);
59
+ } else {
60
+ result[camelKey] = value;
61
+ }
62
+ }
63
+ return result;
64
+ }
65
+ function mapRows(rows) {
66
+ return rows.map((row) => mapRow(row));
67
+ }
68
+
69
+ // src/sql/query-building.ts
70
+ function buildSetClause(input, startIndex = 1) {
71
+ const parts = [];
72
+ const values = [];
73
+ let index = startIndex;
74
+ for (const [key, value] of Object.entries(input)) {
75
+ if (value === void 0) continue;
76
+ const column = toSnakeCase(key);
77
+ if (typeof value === "object" && value !== null && !(value instanceof Date)) {
78
+ parts.push(`${column} = $${index}::jsonb`);
79
+ values.push(JSON.stringify(value));
80
+ } else {
81
+ parts.push(`${column} = $${index}`);
82
+ values.push(value);
83
+ }
84
+ index++;
85
+ }
86
+ parts.push("updated_at = NOW()");
87
+ return {
88
+ clause: parts.join(", "),
89
+ values,
90
+ nextIndex: index
91
+ };
92
+ }
93
+
94
+ // src/sql/error-mapping.ts
95
+ function mapPgError(err) {
96
+ if (err instanceof Error) {
97
+ const message = err.message;
98
+ if (message.includes("unique") || message.includes("duplicate")) {
99
+ return {
100
+ status: 409,
101
+ code: "unique_violation",
102
+ message: "A record with this value already exists"
103
+ };
104
+ }
105
+ if (message.includes("foreign key") || message.includes("violates foreign key")) {
106
+ return {
107
+ status: 422,
108
+ code: "foreign_key_violation",
109
+ message: "Referenced record does not exist"
110
+ };
111
+ }
112
+ if (message.includes("not-null") || message.includes("null value")) {
113
+ return {
114
+ status: 422,
115
+ code: "not_null_violation",
116
+ message: "Required field is missing"
117
+ };
118
+ }
119
+ if (message.includes("not found") || message.includes("no rows")) {
120
+ return {
121
+ status: 404,
122
+ code: "not_found",
123
+ message: "Record not found"
124
+ };
125
+ }
126
+ }
127
+ return {
128
+ status: 500,
129
+ code: "query_error",
130
+ message: err instanceof Error ? err.message : "Unknown database error"
131
+ };
132
+ }
133
+
134
+ // src/mock-result.ts
135
+ function mockSuccess(data) {
136
+ return { success: true, data };
137
+ }
138
+ function mockFailure(error) {
139
+ return { success: false, error };
140
+ }
141
+
142
+ // src/derive-mock-handlers.ts
143
+ function deriveMockHandlers(config, mockConfig) {
144
+ const handlers = [];
145
+ const { basePath, entities } = config;
146
+ for (const [name, entity] of Object.entries(entities)) {
147
+ const tableName = mockConfig?.[name]?.tableName ?? toSnakeCase(name) + "s";
148
+ if (entity.parent) {
149
+ const listPath = `${basePath}${entity.parent.path}/:${entity.parent.param}${entity.path}`;
150
+ handlers.push(
151
+ http.get(listPath, async ({ params }) => {
152
+ const parentId = params[entity.parent.param];
153
+ const parentColumn = toSnakeCase(entity.parent.param);
154
+ return toResponse(
155
+ await queryList(tableName, parentColumn, parentId)
156
+ );
157
+ })
158
+ );
159
+ } else {
160
+ handlers.push(
161
+ http.get(`${basePath}${entity.path}`, async () => {
162
+ return toResponse(await queryAll(tableName));
163
+ })
164
+ );
165
+ }
166
+ handlers.push(
167
+ http.get(`${basePath}${entity.path}/:id`, async ({ params }) => {
168
+ const id = params.id;
169
+ return toResponse(await queryById(tableName, id));
170
+ })
171
+ );
172
+ if (entity.parent) {
173
+ const createPath = `${basePath}${entity.parent.path}/:${entity.parent.param}${entity.path}`;
174
+ handlers.push(
175
+ http.post(createPath, async ({ request, params }) => {
176
+ const dto = await request.json();
177
+ const parentId = params[entity.parent.param];
178
+ dto[entity.parent.param] = parentId;
179
+ return toResponse(await insertRow(tableName, dto), 201);
180
+ })
181
+ );
182
+ } else {
183
+ handlers.push(
184
+ http.post(`${basePath}${entity.path}`, async ({ request }) => {
185
+ const dto = await request.json();
186
+ return toResponse(await insertRow(tableName, dto), 201);
187
+ })
188
+ );
189
+ }
190
+ handlers.push(
191
+ http.patch(`${basePath}${entity.path}/:id`, async ({ request, params }) => {
192
+ const id = params.id;
193
+ const dto = await request.json();
194
+ return toResponse(await updateRow(tableName, id, dto));
195
+ })
196
+ );
197
+ handlers.push(
198
+ http.delete(`${basePath}${entity.path}/:id`, async ({ params }) => {
199
+ const id = params.id;
200
+ return toResponse(await deleteRow(tableName, id));
201
+ })
202
+ );
203
+ }
204
+ return handlers;
205
+ }
206
+ async function queryAll(tableName) {
207
+ try {
208
+ const db = getPGliteInstance();
209
+ const result = await db.query(`SELECT * FROM ${tableName} ORDER BY created_at DESC`);
210
+ return mockSuccess(mapRows(result.rows));
211
+ } catch (err) {
212
+ const mapped = mapPgError(err);
213
+ return mockFailure({ code: mapped.code, message: mapped.message });
214
+ }
215
+ }
216
+ async function queryList(tableName, parentColumn, parentId) {
217
+ try {
218
+ const db = getPGliteInstance();
219
+ const result = await db.query(
220
+ `SELECT * FROM ${tableName} WHERE ${parentColumn} = $1 ORDER BY created_at DESC`,
221
+ [parentId]
222
+ );
223
+ return mockSuccess(mapRows(result.rows));
224
+ } catch (err) {
225
+ const mapped = mapPgError(err);
226
+ return mockFailure({ code: mapped.code, message: mapped.message });
227
+ }
228
+ }
229
+ async function queryById(tableName, id) {
230
+ try {
231
+ const db = getPGliteInstance();
232
+ const result = await db.query(`SELECT * FROM ${tableName} WHERE id = $1`, [id]);
233
+ if (result.rows.length === 0) {
234
+ return mockFailure({ code: "not_found", message: `${tableName} not found` });
235
+ }
236
+ return mockSuccess(mapRow(result.rows[0]));
237
+ } catch (err) {
238
+ const mapped = mapPgError(err);
239
+ return mockFailure({ code: mapped.code, message: mapped.message });
240
+ }
241
+ }
242
+ async function insertRow(tableName, dto) {
243
+ try {
244
+ const db = getPGliteInstance();
245
+ const columns = [];
246
+ const placeholders = [];
247
+ const values = [];
248
+ let index = 1;
249
+ if (!dto.id) {
250
+ columns.push("id");
251
+ placeholders.push(`$${index}`);
252
+ values.push(crypto.randomUUID());
253
+ index++;
254
+ }
255
+ for (const [key, value] of Object.entries(dto)) {
256
+ if (value === void 0) continue;
257
+ const column = toSnakeCase(key);
258
+ columns.push(column);
259
+ if (typeof value === "object" && value !== null && !(value instanceof Date)) {
260
+ placeholders.push(`$${index}::jsonb`);
261
+ values.push(JSON.stringify(value));
262
+ } else {
263
+ placeholders.push(`$${index}`);
264
+ values.push(value);
265
+ }
266
+ index++;
267
+ }
268
+ const sql = `INSERT INTO ${tableName} (${columns.join(", ")}) VALUES (${placeholders.join(", ")}) RETURNING *`;
269
+ const result = await db.query(sql, values);
270
+ return mockSuccess(mapRow(result.rows[0]));
271
+ } catch (err) {
272
+ const mapped = mapPgError(err);
273
+ return mockFailure({ code: mapped.code, message: mapped.message });
274
+ }
275
+ }
276
+ async function updateRow(tableName, id, dto) {
277
+ try {
278
+ const db = getPGliteInstance();
279
+ const { clause, values, nextIndex } = buildSetClause(dto);
280
+ if (values.length === 0) {
281
+ const result2 = await db.query(
282
+ `UPDATE ${tableName} SET updated_at = NOW() WHERE id = $1 RETURNING *`,
283
+ [id]
284
+ );
285
+ if (result2.rows.length === 0) {
286
+ return mockFailure({ code: "not_found", message: `${tableName} not found` });
287
+ }
288
+ return mockSuccess(mapRow(result2.rows[0]));
289
+ }
290
+ const sql = `UPDATE ${tableName} SET ${clause} WHERE id = $${nextIndex} RETURNING *`;
291
+ const result = await db.query(sql, [...values, id]);
292
+ if (result.rows.length === 0) {
293
+ return mockFailure({ code: "not_found", message: `${tableName} not found` });
294
+ }
295
+ return mockSuccess(mapRow(result.rows[0]));
296
+ } catch (err) {
297
+ const mapped = mapPgError(err);
298
+ return mockFailure({ code: mapped.code, message: mapped.message });
299
+ }
300
+ }
301
+ async function deleteRow(tableName, id) {
302
+ try {
303
+ const db = getPGliteInstance();
304
+ const result = await db.query(`DELETE FROM ${tableName} WHERE id = $1`, [id]);
305
+ if (result.affectedRows === 0) {
306
+ return mockFailure({ code: "not_found", message: `${tableName} not found` });
307
+ }
308
+ return mockSuccess(void 0);
309
+ } catch (err) {
310
+ const mapped = mapPgError(err);
311
+ return mockFailure({ code: mapped.code, message: mapped.message });
312
+ }
313
+ }
314
+ function toResponse(result, successStatus = 200) {
315
+ if (result.success) {
316
+ if (successStatus === 204) {
317
+ return new HttpResponse(null, { status: 204 });
318
+ }
319
+ return HttpResponse.json({ data: result.data }, { status: successStatus });
320
+ }
321
+ const status = result.error?.code === "not_found" ? 404 : result.error?.code === "unique_violation" ? 409 : result.error?.code === "foreign_key_violation" ? 422 : 500;
322
+ return HttpResponse.json(
323
+ { code: result.error?.code, message: result.error?.message },
324
+ { status }
325
+ );
326
+ }
327
+
328
+ // src/sql/migration-helpers.ts
329
+ async function tableExists(db, tableName) {
330
+ const result = await db.query(
331
+ `SELECT EXISTS (
332
+ SELECT FROM information_schema.tables
333
+ WHERE table_name = $1
334
+ ) as exists`,
335
+ [tableName]
336
+ );
337
+ return result.rows[0]?.exists ?? false;
338
+ }
339
+ async function columnExists(db, tableName, columnName) {
340
+ const result = await db.query(
341
+ `SELECT EXISTS (
342
+ SELECT FROM information_schema.columns
343
+ WHERE table_name = $1 AND column_name = $2
344
+ ) as exists`,
345
+ [tableName, columnName]
346
+ );
347
+ return result.rows[0]?.exists ?? false;
348
+ }
349
+ async function executeSql(db, sql) {
350
+ const statements = sql.split(";").map((s) => s.trim()).filter((s) => s.length > 0);
351
+ for (const stmt of statements) {
352
+ await db.query(stmt);
353
+ }
354
+ }
355
+ async function addColumnIfNotExists(db, tableName, columnName, columnDef) {
356
+ const exists = await columnExists(db, tableName, columnName);
357
+ if (!exists) {
358
+ await db.query(`ALTER TABLE ${tableName} ADD COLUMN ${columnName} ${columnDef}`);
359
+ }
360
+ }
361
+
362
+ export { addColumnIfNotExists, buildSetClause, columnExists, deriveMockHandlers, executeSql, getPGliteInstance, initPGlite, mapPgError, mapRow, mapRows, mockFailure, mockSuccess, resetPGliteInstance, setupMockWorker, tableExists, toCamelCase, toSnakeCase };
package/package.json ADDED
@@ -0,0 +1,42 @@
1
+ {
2
+ "name": "@simplix-react/mock",
3
+ "version": "0.0.1",
4
+ "description": "Auto-generated MSW handlers and PGlite repositories from @simplix-react/contract",
5
+ "type": "module",
6
+ "exports": {
7
+ ".": {
8
+ "types": "./dist/index.d.ts",
9
+ "import": "./dist/index.js"
10
+ }
11
+ },
12
+ "files": ["dist"],
13
+ "scripts": {
14
+ "build": "tsup",
15
+ "dev": "tsup --watch",
16
+ "typecheck": "tsc --noEmit",
17
+ "lint": "eslint src",
18
+ "test": "vitest run --passWithNoTests",
19
+ "clean": "rm -rf dist .turbo"
20
+ },
21
+ "peerDependencies": {
22
+ "@simplix-react/contract": "workspace:*",
23
+ "@electric-sql/pglite": ">=0.2.0",
24
+ "msw": ">=2.0.0",
25
+ "zod": ">=4.0.0"
26
+ },
27
+ "peerDependenciesMeta": {
28
+ "@electric-sql/pglite": { "optional": true },
29
+ "msw": { "optional": true }
30
+ },
31
+ "devDependencies": {
32
+ "@simplix-react/config-typescript": "workspace:*",
33
+ "@simplix-react/contract": "workspace:*",
34
+ "@electric-sql/pglite": "^0.3.14",
35
+ "eslint": "^9.39.2",
36
+ "msw": "^2.7.0",
37
+ "tsup": "^8.5.1",
38
+ "typescript": "^5.9.3",
39
+ "vitest": "^3.0.0",
40
+ "zod": "^4.0.0"
41
+ }
42
+ }