@simplix-react/mock 0.0.1 → 0.0.3
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 +305 -0
- package/dist/index.d.ts +444 -48
- package/dist/index.js +145 -36
- package/package.json +28 -17
package/README.md
ADDED
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
# @simplix-react/mock
|
|
2
|
+
|
|
3
|
+
Auto-generated MSW handlers and PGlite repositories from `@simplix-react/contract`.
|
|
4
|
+
|
|
5
|
+
> **Prerequisites:** Requires a contract defined with `@simplix-react/contract`.
|
|
6
|
+
|
|
7
|
+
## Installation
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
pnpm add @simplix-react/mock
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Peer dependencies:
|
|
14
|
+
|
|
15
|
+
| Package | Required | Notes |
|
|
16
|
+
| --- | --- | --- |
|
|
17
|
+
| `@simplix-react/contract` | Yes | Provides the API contract definition |
|
|
18
|
+
| `zod` | Yes | `>=4.0.0` |
|
|
19
|
+
| `msw` | Optional | `>=2.0.0` — needed for MSW handler generation |
|
|
20
|
+
| `@electric-sql/pglite` | Optional | `>=0.2.0` — needed for in-browser PostgreSQL |
|
|
21
|
+
|
|
22
|
+
## Quick Example
|
|
23
|
+
|
|
24
|
+
```ts
|
|
25
|
+
import { defineApi, simpleQueryBuilder } from "@simplix-react/contract";
|
|
26
|
+
import {
|
|
27
|
+
setupMockWorker,
|
|
28
|
+
deriveMockHandlers,
|
|
29
|
+
executeSql,
|
|
30
|
+
} from "@simplix-react/mock";
|
|
31
|
+
import { z } from "zod";
|
|
32
|
+
|
|
33
|
+
// 1. Define your contract
|
|
34
|
+
const projectContract = defineApi({
|
|
35
|
+
domain: "project",
|
|
36
|
+
basePath: "/api",
|
|
37
|
+
entities: {
|
|
38
|
+
task: {
|
|
39
|
+
path: "/tasks",
|
|
40
|
+
schema: z.object({
|
|
41
|
+
id: z.string(),
|
|
42
|
+
title: z.string(),
|
|
43
|
+
status: z.enum(["todo", "done"]),
|
|
44
|
+
createdAt: z.string(),
|
|
45
|
+
}),
|
|
46
|
+
createSchema: z.object({ title: z.string() }),
|
|
47
|
+
updateSchema: z.object({ status: z.enum(["todo", "done"]).optional() }),
|
|
48
|
+
},
|
|
49
|
+
},
|
|
50
|
+
queryBuilder: simpleQueryBuilder,
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
// 2. Derive handlers and bootstrap
|
|
54
|
+
await setupMockWorker({
|
|
55
|
+
dataDir: "idb://project-mock",
|
|
56
|
+
migrations: [
|
|
57
|
+
async (db) => {
|
|
58
|
+
await executeSql(db, `
|
|
59
|
+
CREATE TABLE IF NOT EXISTS tasks (
|
|
60
|
+
id TEXT PRIMARY KEY,
|
|
61
|
+
title TEXT NOT NULL,
|
|
62
|
+
status TEXT NOT NULL DEFAULT 'todo',
|
|
63
|
+
created_at TIMESTAMP DEFAULT NOW(),
|
|
64
|
+
updated_at TIMESTAMP DEFAULT NOW()
|
|
65
|
+
)
|
|
66
|
+
`);
|
|
67
|
+
},
|
|
68
|
+
],
|
|
69
|
+
seed: [],
|
|
70
|
+
handlers: deriveMockHandlers(projectContract.config),
|
|
71
|
+
});
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
After `setupMockWorker` resolves, every `fetch("/api/tasks")` call in your application is intercepted by MSW and served from the in-browser PGlite database.
|
|
75
|
+
|
|
76
|
+
## API Overview
|
|
77
|
+
|
|
78
|
+
### Core
|
|
79
|
+
|
|
80
|
+
| Export | Description |
|
|
81
|
+
| --- | --- |
|
|
82
|
+
| `setupMockWorker(config)` | Bootstraps PGlite + MSW in one call |
|
|
83
|
+
| `deriveMockHandlers(config, mockConfig?)` | Generates CRUD MSW handlers from a contract |
|
|
84
|
+
| `MockServerConfig` | Configuration for `setupMockWorker` |
|
|
85
|
+
| `MockEntityConfig` | Per-entity mock configuration (table name, limits, relations) |
|
|
86
|
+
|
|
87
|
+
### PGlite Lifecycle
|
|
88
|
+
|
|
89
|
+
| Export | Description |
|
|
90
|
+
| --- | --- |
|
|
91
|
+
| `initPGlite(dataDir)` | Initializes the PGlite singleton |
|
|
92
|
+
| `getPGliteInstance()` | Returns the active instance (throws if uninitialized) |
|
|
93
|
+
| `resetPGliteInstance()` | Clears the singleton (for test teardown) |
|
|
94
|
+
|
|
95
|
+
### Result Types
|
|
96
|
+
|
|
97
|
+
| Export | Description |
|
|
98
|
+
| --- | --- |
|
|
99
|
+
| `MockResult<T>` | Discriminated success/failure result type |
|
|
100
|
+
| `mockSuccess(data)` | Creates a success result |
|
|
101
|
+
| `mockFailure(error)` | Creates a failure result |
|
|
102
|
+
|
|
103
|
+
### SQL Utilities
|
|
104
|
+
|
|
105
|
+
| Export | Description |
|
|
106
|
+
| --- | --- |
|
|
107
|
+
| `mapRow(row)` | Converts a snake_case DB row to camelCase (with Date parsing) |
|
|
108
|
+
| `mapRows(rows)` | Maps an array of rows |
|
|
109
|
+
| `toCamelCase(str)` | `snake_case` → `camelCase` |
|
|
110
|
+
| `toSnakeCase(str)` | `camelCase` → `snake_case` |
|
|
111
|
+
| `DbRow` | Type alias for `Record<string, unknown>` |
|
|
112
|
+
| `buildSetClause(input)` | Builds a parameterized SQL SET clause |
|
|
113
|
+
| `SetClauseResult` | Return type of `buildSetClause` |
|
|
114
|
+
| `mapPgError(err)` | Maps PGlite errors to HTTP-friendly `MockError` |
|
|
115
|
+
| `MockError` | Structured error with status, code, message |
|
|
116
|
+
|
|
117
|
+
### Migration Helpers
|
|
118
|
+
|
|
119
|
+
| Export | Description |
|
|
120
|
+
| --- | --- |
|
|
121
|
+
| `tableExists(db, tableName)` | Checks if a table exists |
|
|
122
|
+
| `columnExists(db, tableName, columnName)` | Checks if a column exists |
|
|
123
|
+
| `executeSql(db, sql)` | Executes semicolon-separated SQL statements |
|
|
124
|
+
| `addColumnIfNotExists(db, table, column, def)` | Idempotent column addition |
|
|
125
|
+
|
|
126
|
+
## Key Concepts
|
|
127
|
+
|
|
128
|
+
### MSW Handler Derivation
|
|
129
|
+
|
|
130
|
+
`deriveMockHandlers` reads your contract's entity definitions and generates five handlers per entity:
|
|
131
|
+
|
|
132
|
+
```
|
|
133
|
+
GET {basePath}{entityPath} → List (with filter, sort, pagination)
|
|
134
|
+
GET {basePath}{entityPath}/:id → Get by ID (with relation loading)
|
|
135
|
+
POST {basePath}{entityPath} → Create (auto-generates UUID)
|
|
136
|
+
PATCH {basePath}{entityPath}/:id → Partial update
|
|
137
|
+
DELETE {basePath}{entityPath}/:id → Delete
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
For child entities with a `parent` definition, list and create routes are nested under the parent path:
|
|
141
|
+
|
|
142
|
+
```
|
|
143
|
+
GET {basePath}{parentPath}/:parentId{entityPath} → List by parent
|
|
144
|
+
POST {basePath}{parentPath}/:parentId{entityPath} → Create under parent
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
#### Query Parameters
|
|
148
|
+
|
|
149
|
+
List endpoints support the following query parameters:
|
|
150
|
+
|
|
151
|
+
| Parameter | Example | Description |
|
|
152
|
+
| --- | --- | --- |
|
|
153
|
+
| `sort` | `sort=title:asc,createdAt:desc` | Comma-separated `field:direction` pairs |
|
|
154
|
+
| `page` | `page=2` | Offset-based page number (1-indexed) |
|
|
155
|
+
| `limit` | `limit=20` | Rows per page (capped by `maxLimit`) |
|
|
156
|
+
| `{field}` | `status=done` | Equality filter on any column |
|
|
157
|
+
|
|
158
|
+
#### Relation Loading
|
|
159
|
+
|
|
160
|
+
Configure `belongsTo` relations in `MockEntityConfig` to automatically JOIN related data on GET-by-ID requests:
|
|
161
|
+
|
|
162
|
+
```ts
|
|
163
|
+
const handlers = deriveMockHandlers(contract.config, {
|
|
164
|
+
task: {
|
|
165
|
+
relations: {
|
|
166
|
+
project: {
|
|
167
|
+
table: "projects",
|
|
168
|
+
localKey: "projectId",
|
|
169
|
+
foreignKey: "id", // defaults to "id"
|
|
170
|
+
type: "belongsTo",
|
|
171
|
+
},
|
|
172
|
+
},
|
|
173
|
+
},
|
|
174
|
+
});
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
### PGlite Integration
|
|
178
|
+
|
|
179
|
+
PGlite provides a full PostgreSQL database running in the browser via WebAssembly. This package manages a singleton instance:
|
|
180
|
+
|
|
181
|
+
```
|
|
182
|
+
initPGlite(dataDir) → getPGliteInstance() → resetPGliteInstance()
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
Data persists across page reloads via IndexedDB when using an `idb://` data directory.
|
|
186
|
+
|
|
187
|
+
### SQL Utilities
|
|
188
|
+
|
|
189
|
+
The SQL utility modules handle the mapping between JavaScript (camelCase) and PostgreSQL (snake_case) conventions:
|
|
190
|
+
|
|
191
|
+
- **Row mapping** — `mapRow`/`mapRows` convert query results to JS objects, automatically parsing `_at` columns as `Date` instances.
|
|
192
|
+
- **Query building** — `buildSetClause` constructs parameterized UPDATE queries from partial objects, always appending `updated_at = NOW()`.
|
|
193
|
+
- **Error mapping** — `mapPgError` classifies PGlite exceptions into structured errors with appropriate HTTP status codes.
|
|
194
|
+
- **Migration helpers** — Idempotent utilities (`tableExists`, `addColumnIfNotExists`, `executeSql`) for writing safe migration functions.
|
|
195
|
+
|
|
196
|
+
## Guides
|
|
197
|
+
|
|
198
|
+
### Writing Migrations
|
|
199
|
+
|
|
200
|
+
Migrations are async functions that receive a PGlite instance. Use the migration helpers for idempotent operations:
|
|
201
|
+
|
|
202
|
+
```ts
|
|
203
|
+
import type { PGlite } from "@electric-sql/pglite";
|
|
204
|
+
import { tableExists, executeSql, addColumnIfNotExists } from "@simplix-react/mock";
|
|
205
|
+
|
|
206
|
+
export async function migrate(db: PGlite) {
|
|
207
|
+
if (!(await tableExists(db, "tasks"))) {
|
|
208
|
+
await executeSql(db, `
|
|
209
|
+
CREATE TABLE tasks (
|
|
210
|
+
id TEXT PRIMARY KEY,
|
|
211
|
+
title TEXT NOT NULL,
|
|
212
|
+
status TEXT NOT NULL DEFAULT 'todo',
|
|
213
|
+
project_id TEXT,
|
|
214
|
+
created_at TIMESTAMP DEFAULT NOW(),
|
|
215
|
+
updated_at TIMESTAMP DEFAULT NOW()
|
|
216
|
+
)
|
|
217
|
+
`);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// Safe to call multiple times
|
|
221
|
+
await addColumnIfNotExists(db, "tasks", "priority", "INTEGER DEFAULT 0");
|
|
222
|
+
}
|
|
223
|
+
```
|
|
224
|
+
|
|
225
|
+
### Writing Seed Functions
|
|
226
|
+
|
|
227
|
+
Seed functions populate the database with initial data after migrations:
|
|
228
|
+
|
|
229
|
+
```ts
|
|
230
|
+
import type { PGlite } from "@electric-sql/pglite";
|
|
231
|
+
|
|
232
|
+
export async function seed(db: PGlite) {
|
|
233
|
+
await db.query(
|
|
234
|
+
`INSERT INTO tasks (id, title, status) VALUES ($1, $2, $3)
|
|
235
|
+
ON CONFLICT (id) DO NOTHING`,
|
|
236
|
+
["task-1", "Sample Task", "todo"],
|
|
237
|
+
);
|
|
238
|
+
}
|
|
239
|
+
```
|
|
240
|
+
|
|
241
|
+
### Custom Repository Handlers
|
|
242
|
+
|
|
243
|
+
For advanced use cases beyond auto-generated CRUD, use the SQL utilities directly:
|
|
244
|
+
|
|
245
|
+
```ts
|
|
246
|
+
import { http, HttpResponse } from "msw";
|
|
247
|
+
import {
|
|
248
|
+
getPGliteInstance,
|
|
249
|
+
mapRows,
|
|
250
|
+
mapPgError,
|
|
251
|
+
mockSuccess,
|
|
252
|
+
mockFailure,
|
|
253
|
+
} from "@simplix-react/mock";
|
|
254
|
+
|
|
255
|
+
const customHandler = http.get("/api/tasks/overdue", async () => {
|
|
256
|
+
try {
|
|
257
|
+
const db = getPGliteInstance();
|
|
258
|
+
const result = await db.query(
|
|
259
|
+
"SELECT * FROM tasks WHERE status != 'done' AND created_at < NOW() - INTERVAL '7 days'",
|
|
260
|
+
);
|
|
261
|
+
const tasks = mapRows(result.rows as Record<string, unknown>[]);
|
|
262
|
+
return HttpResponse.json({ data: mockSuccess(tasks) });
|
|
263
|
+
} catch (err) {
|
|
264
|
+
const mapped = mapPgError(err);
|
|
265
|
+
return HttpResponse.json(
|
|
266
|
+
{ code: mapped.code, message: mapped.message },
|
|
267
|
+
{ status: mapped.status },
|
|
268
|
+
);
|
|
269
|
+
}
|
|
270
|
+
});
|
|
271
|
+
```
|
|
272
|
+
|
|
273
|
+
### Testing with PGlite
|
|
274
|
+
|
|
275
|
+
Reset the singleton between tests to ensure isolation:
|
|
276
|
+
|
|
277
|
+
```ts
|
|
278
|
+
import { initPGlite, resetPGliteInstance, executeSql } from "@simplix-react/mock";
|
|
279
|
+
import { afterEach, beforeEach, describe, it } from "vitest";
|
|
280
|
+
|
|
281
|
+
describe("task repository", () => {
|
|
282
|
+
beforeEach(async () => {
|
|
283
|
+
const db = await initPGlite("memory://");
|
|
284
|
+
await executeSql(db, `
|
|
285
|
+
CREATE TABLE tasks (id TEXT PRIMARY KEY, title TEXT NOT NULL)
|
|
286
|
+
`);
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
afterEach(() => {
|
|
290
|
+
resetPGliteInstance();
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
it("inserts a task", async () => {
|
|
294
|
+
// ...
|
|
295
|
+
});
|
|
296
|
+
});
|
|
297
|
+
```
|
|
298
|
+
|
|
299
|
+
## Related Packages
|
|
300
|
+
|
|
301
|
+
| Package | Description |
|
|
302
|
+
| --- | --- |
|
|
303
|
+
| [`@simplix-react/contract`](../contract) | Define type-safe API contracts consumed by this package |
|
|
304
|
+
| [`@simplix-react/react`](../react) | React Query hooks derived from the same contract |
|
|
305
|
+
| [`@simplix-react/testing`](../testing) | Test utilities for contract-based mocking |
|
package/dist/index.d.ts
CHANGED
|
@@ -1,76 +1,270 @@
|
|
|
1
1
|
import { PGlite } from '@electric-sql/pglite';
|
|
2
2
|
import * as msw from 'msw';
|
|
3
|
-
import {
|
|
4
|
-
import { z } from 'zod';
|
|
3
|
+
import { AnyEntityDef, AnyOperationDef, ApiContractConfig } from '@simplix-react/contract';
|
|
5
4
|
|
|
6
5
|
/**
|
|
7
|
-
*
|
|
6
|
+
* Initializes a singleton PGlite instance with the given data directory.
|
|
7
|
+
*
|
|
8
|
+
* Uses a dynamic import so that `@electric-sql/pglite` remains an optional peer
|
|
9
|
+
* dependency. Subsequent calls return the already-initialized instance.
|
|
10
|
+
*
|
|
11
|
+
* @param dataDir - The IndexedDB data directory for persistence (e.g. `"idb://simplix-mock"`).
|
|
12
|
+
* @returns The initialized PGlite instance.
|
|
13
|
+
*
|
|
14
|
+
* @example
|
|
15
|
+
* ```ts
|
|
16
|
+
* import { initPGlite } from "@simplix-react/mock";
|
|
17
|
+
*
|
|
18
|
+
* const db = await initPGlite("idb://project-mock");
|
|
19
|
+
* await db.query("SELECT 1");
|
|
20
|
+
* ```
|
|
21
|
+
*
|
|
22
|
+
* @see {@link getPGliteInstance} - Retrieves the current instance without re-initializing.
|
|
23
|
+
* @see {@link resetPGliteInstance} - Clears the singleton for testing.
|
|
8
24
|
*/
|
|
9
25
|
declare function initPGlite(dataDir: string): Promise<PGlite>;
|
|
10
26
|
/**
|
|
11
|
-
*
|
|
12
|
-
*
|
|
27
|
+
* Returns the current PGlite singleton instance.
|
|
28
|
+
*
|
|
29
|
+
* Throws an error if {@link initPGlite} has not been called yet.
|
|
30
|
+
*
|
|
31
|
+
* @returns The active PGlite instance.
|
|
32
|
+
* @throws Error if PGlite has not been initialized.
|
|
33
|
+
*
|
|
34
|
+
* @example
|
|
35
|
+
* ```ts
|
|
36
|
+
* import { getPGliteInstance } from "@simplix-react/mock";
|
|
37
|
+
*
|
|
38
|
+
* const db = getPGliteInstance();
|
|
39
|
+
* const result = await db.query("SELECT * FROM tasks");
|
|
40
|
+
* ```
|
|
13
41
|
*/
|
|
14
42
|
declare function getPGliteInstance(): PGlite;
|
|
15
43
|
/**
|
|
16
|
-
*
|
|
44
|
+
* Resets the PGlite singleton instance to `null`.
|
|
45
|
+
*
|
|
46
|
+
* Intended for use in test teardown to ensure a clean state between test runs.
|
|
47
|
+
*
|
|
48
|
+
* @example
|
|
49
|
+
* ```ts
|
|
50
|
+
* import { resetPGliteInstance } from "@simplix-react/mock";
|
|
51
|
+
*
|
|
52
|
+
* afterEach(() => {
|
|
53
|
+
* resetPGliteInstance();
|
|
54
|
+
* });
|
|
55
|
+
* ```
|
|
17
56
|
*/
|
|
18
57
|
declare function resetPGliteInstance(): void;
|
|
19
58
|
|
|
20
59
|
type RequestHandler = unknown;
|
|
60
|
+
/**
|
|
61
|
+
* Describes the configuration required by {@link setupMockWorker}.
|
|
62
|
+
*
|
|
63
|
+
* Combines PGlite database setup (migrations and seed data) with MSW request
|
|
64
|
+
* handlers into a single bootstrap configuration.
|
|
65
|
+
*
|
|
66
|
+
* @example
|
|
67
|
+
* ```ts
|
|
68
|
+
* import type { MockServerConfig } from "@simplix-react/mock";
|
|
69
|
+
* import { deriveMockHandlers } from "@simplix-react/mock";
|
|
70
|
+
* import { projectContract } from "./contract";
|
|
71
|
+
* import { runMigrations } from "./migrations";
|
|
72
|
+
* import { seedData } from "./seed";
|
|
73
|
+
*
|
|
74
|
+
* const config: MockServerConfig = {
|
|
75
|
+
* dataDir: "idb://project-mock",
|
|
76
|
+
* migrations: [runMigrations],
|
|
77
|
+
* seed: [seedData],
|
|
78
|
+
* handlers: deriveMockHandlers(projectContract.config),
|
|
79
|
+
* };
|
|
80
|
+
* ```
|
|
81
|
+
*
|
|
82
|
+
* @see {@link setupMockWorker} - Consumes this config to bootstrap the mock environment.
|
|
83
|
+
*/
|
|
21
84
|
interface MockServerConfig {
|
|
22
85
|
/**
|
|
23
|
-
* IndexedDB data directory for PGlite persistence
|
|
86
|
+
* IndexedDB data directory for PGlite persistence.
|
|
87
|
+
*
|
|
88
|
+
* @defaultValue `"idb://simplix-mock"`
|
|
24
89
|
*/
|
|
25
90
|
dataDir?: string;
|
|
26
91
|
/**
|
|
27
|
-
* Migration functions to run in order
|
|
92
|
+
* Migration functions to run in order.
|
|
93
|
+
*
|
|
94
|
+
* Each function receives the PGlite instance and should create or alter tables.
|
|
28
95
|
*/
|
|
29
96
|
migrations: Array<(db: PGlite) => Promise<void>>;
|
|
30
97
|
/**
|
|
31
|
-
* Seed functions to run in order (after migrations)
|
|
98
|
+
* Seed functions to run in order (after migrations).
|
|
99
|
+
*
|
|
100
|
+
* Each function receives the PGlite instance and should insert initial data.
|
|
32
101
|
*/
|
|
33
102
|
seed: Array<(db: PGlite) => Promise<void>>;
|
|
34
103
|
/**
|
|
35
|
-
* MSW request handlers
|
|
104
|
+
* MSW request handlers to register with the service worker.
|
|
105
|
+
*
|
|
106
|
+
* Typically produced by {@link deriveMockHandlers}.
|
|
36
107
|
*/
|
|
37
108
|
handlers: RequestHandler[];
|
|
38
109
|
}
|
|
39
110
|
/**
|
|
40
|
-
*
|
|
111
|
+
* Bootstraps a complete mock environment with PGlite and MSW.
|
|
41
112
|
*
|
|
42
|
-
*
|
|
43
|
-
*
|
|
113
|
+
* Performs the following steps in order:
|
|
114
|
+
* 1. Initializes a PGlite instance at the configured `dataDir`
|
|
115
|
+
* 2. Runs all migration functions sequentially
|
|
44
116
|
* 3. Runs all seed functions sequentially
|
|
45
|
-
* 4. Starts the MSW service worker
|
|
117
|
+
* 4. Starts the MSW service worker with the provided handlers
|
|
118
|
+
*
|
|
119
|
+
* @param config - The {@link MockServerConfig} describing database setup and handlers.
|
|
120
|
+
*
|
|
121
|
+
* @example
|
|
122
|
+
* ```ts
|
|
123
|
+
* import { setupMockWorker, deriveMockHandlers } from "@simplix-react/mock";
|
|
124
|
+
* import { projectContract } from "./contract";
|
|
125
|
+
* import { runMigrations } from "./migrations";
|
|
126
|
+
* import { seedData } from "./seed";
|
|
127
|
+
*
|
|
128
|
+
* await setupMockWorker({
|
|
129
|
+
* dataDir: "idb://project-mock",
|
|
130
|
+
* migrations: [runMigrations],
|
|
131
|
+
* seed: [seedData],
|
|
132
|
+
* handlers: deriveMockHandlers(projectContract.config),
|
|
133
|
+
* });
|
|
134
|
+
* ```
|
|
135
|
+
*
|
|
136
|
+
* @see {@link MockServerConfig} - Configuration shape.
|
|
137
|
+
* @see {@link deriveMockHandlers} - Generates MSW handlers from a contract.
|
|
138
|
+
* @see {@link initPGlite} - Underlying PGlite initialization.
|
|
46
139
|
*/
|
|
47
140
|
declare function setupMockWorker(config: MockServerConfig): Promise<void>;
|
|
48
141
|
|
|
49
|
-
type AnyEntityDef = EntityDefinition<z.ZodTypeAny, z.ZodTypeAny, z.ZodTypeAny>;
|
|
50
|
-
type AnyOperationDef = OperationDefinition<z.ZodTypeAny, z.ZodTypeAny>;
|
|
51
142
|
/**
|
|
52
|
-
*
|
|
53
|
-
*
|
|
143
|
+
* Provides per-entity configuration for mock handler generation.
|
|
144
|
+
*
|
|
145
|
+
* Allows overriding the default table name, pagination limits, sort order,
|
|
146
|
+
* and relation loading for a specific entity when calling {@link deriveMockHandlers}.
|
|
147
|
+
*
|
|
148
|
+
* @example
|
|
149
|
+
* ```ts
|
|
150
|
+
* import type { MockEntityConfig } from "@simplix-react/mock";
|
|
151
|
+
*
|
|
152
|
+
* const taskConfig: MockEntityConfig = {
|
|
153
|
+
* tableName: "tasks",
|
|
154
|
+
* defaultLimit: 20,
|
|
155
|
+
* maxLimit: 100,
|
|
156
|
+
* defaultSort: "created_at DESC",
|
|
157
|
+
* relations: {
|
|
158
|
+
* project: {
|
|
159
|
+
* table: "projects",
|
|
160
|
+
* localKey: "projectId",
|
|
161
|
+
* type: "belongsTo",
|
|
162
|
+
* },
|
|
163
|
+
* },
|
|
164
|
+
* };
|
|
165
|
+
* ```
|
|
166
|
+
*
|
|
167
|
+
* @see {@link deriveMockHandlers} - Consumes this config per entity.
|
|
54
168
|
*/
|
|
55
169
|
interface MockEntityConfig {
|
|
170
|
+
/** Overrides the auto-derived PostgreSQL table name. */
|
|
56
171
|
tableName?: string;
|
|
172
|
+
/**
|
|
173
|
+
* Default number of rows per page.
|
|
174
|
+
*
|
|
175
|
+
* @defaultValue 50
|
|
176
|
+
*/
|
|
177
|
+
defaultLimit?: number;
|
|
178
|
+
/**
|
|
179
|
+
* Maximum allowed rows per page.
|
|
180
|
+
*
|
|
181
|
+
* @defaultValue 100
|
|
182
|
+
*/
|
|
183
|
+
maxLimit?: number;
|
|
184
|
+
/**
|
|
185
|
+
* Default SQL ORDER BY clause.
|
|
186
|
+
*
|
|
187
|
+
* @defaultValue `"created_at DESC"`
|
|
188
|
+
*/
|
|
189
|
+
defaultSort?: string;
|
|
190
|
+
/** Map of relation names to their `belongsTo` join configuration. */
|
|
191
|
+
relations?: Record<string, {
|
|
192
|
+
table: string;
|
|
193
|
+
localKey: string;
|
|
194
|
+
foreignKey?: string;
|
|
195
|
+
type: "belongsTo";
|
|
196
|
+
}>;
|
|
57
197
|
}
|
|
58
198
|
/**
|
|
59
|
-
*
|
|
199
|
+
* Derives MSW request handlers from an {@link @simplix-react/contract!ApiContractConfig}.
|
|
200
|
+
*
|
|
201
|
+
* Generates a complete set of CRUD handlers for every entity defined in the contract:
|
|
202
|
+
*
|
|
203
|
+
* - **GET list** — supports query-param filtering, sorting, and offset-based pagination
|
|
204
|
+
* - **GET by id** — supports `belongsTo` relation loading via joins
|
|
205
|
+
* - **POST create** — auto-generates a UUID `id` when not provided
|
|
206
|
+
* - **PATCH update** — partial updates with automatic `updated_at` timestamp
|
|
207
|
+
* - **DELETE** — removes the row by `id`
|
|
208
|
+
*
|
|
209
|
+
* All handlers read from and write to the PGlite singleton managed by
|
|
210
|
+
* {@link initPGlite}/{@link getPGliteInstance}.
|
|
211
|
+
*
|
|
212
|
+
* @typeParam TEntities - The entities map from the contract config.
|
|
213
|
+
* @typeParam TOperations - The operations map from the contract config.
|
|
214
|
+
* @param config - The API contract configuration object.
|
|
215
|
+
* @param mockConfig - Optional per-entity mock configuration keyed by entity name.
|
|
216
|
+
* @returns An array of MSW `HttpHandler` instances ready for use with `setupWorker`.
|
|
217
|
+
*
|
|
218
|
+
* @example
|
|
219
|
+
* ```ts
|
|
220
|
+
* import { deriveMockHandlers } from "@simplix-react/mock";
|
|
221
|
+
* import { projectContract } from "./contract";
|
|
60
222
|
*
|
|
61
|
-
*
|
|
62
|
-
*
|
|
63
|
-
*
|
|
64
|
-
*
|
|
65
|
-
*
|
|
66
|
-
*
|
|
223
|
+
* const handlers = deriveMockHandlers(projectContract.config, {
|
|
224
|
+
* task: {
|
|
225
|
+
* tableName: "tasks",
|
|
226
|
+
* defaultLimit: 20,
|
|
227
|
+
* relations: {
|
|
228
|
+
* project: {
|
|
229
|
+
* table: "projects",
|
|
230
|
+
* localKey: "projectId",
|
|
231
|
+
* type: "belongsTo",
|
|
232
|
+
* },
|
|
233
|
+
* },
|
|
234
|
+
* },
|
|
235
|
+
* });
|
|
236
|
+
* ```
|
|
67
237
|
*
|
|
68
|
-
*
|
|
238
|
+
* @see {@link MockEntityConfig} - Per-entity configuration options.
|
|
239
|
+
* @see {@link setupMockWorker} - High-level bootstrap that accepts these handlers.
|
|
240
|
+
* @see {@link @simplix-react/contract!ApiContractConfig} - The contract config shape.
|
|
69
241
|
*/
|
|
70
242
|
declare function deriveMockHandlers<TEntities extends Record<string, AnyEntityDef>, TOperations extends Record<string, AnyOperationDef>>(config: ApiContractConfig<TEntities, TOperations>, mockConfig?: Record<string, MockEntityConfig>): msw.HttpHandler[];
|
|
71
243
|
|
|
72
244
|
/**
|
|
73
|
-
*
|
|
245
|
+
* Represents the outcome of a mock repository operation.
|
|
246
|
+
*
|
|
247
|
+
* Encapsulates either a successful result with data or a failure with an error code
|
|
248
|
+
* and message. Used throughout `@simplix-react/mock` to provide consistent return
|
|
249
|
+
* types from all database operations.
|
|
250
|
+
*
|
|
251
|
+
* @typeParam T - The type of the data payload on success.
|
|
252
|
+
*
|
|
253
|
+
* @example
|
|
254
|
+
* ```ts
|
|
255
|
+
* import type { MockResult } from "@simplix-react/mock";
|
|
256
|
+
*
|
|
257
|
+
* function handleResult(result: MockResult<{ id: string; name: string }>) {
|
|
258
|
+
* if (result.success) {
|
|
259
|
+
* console.log(result.data?.name);
|
|
260
|
+
* } else {
|
|
261
|
+
* console.error(result.error?.code, result.error?.message);
|
|
262
|
+
* }
|
|
263
|
+
* }
|
|
264
|
+
* ```
|
|
265
|
+
*
|
|
266
|
+
* @see {@link mockSuccess} - Creates a successful result.
|
|
267
|
+
* @see {@link mockFailure} - Creates a failure result.
|
|
74
268
|
*/
|
|
75
269
|
interface MockResult<T> {
|
|
76
270
|
success: boolean;
|
|
@@ -81,11 +275,34 @@ interface MockResult<T> {
|
|
|
81
275
|
};
|
|
82
276
|
}
|
|
83
277
|
/**
|
|
84
|
-
*
|
|
278
|
+
* Creates a successful {@link MockResult} wrapping the given data.
|
|
279
|
+
*
|
|
280
|
+
* @typeParam T - The type of the data payload.
|
|
281
|
+
* @param data - The data to wrap in a success result.
|
|
282
|
+
* @returns A {@link MockResult} with `success: true` and the provided data.
|
|
283
|
+
*
|
|
284
|
+
* @example
|
|
285
|
+
* ```ts
|
|
286
|
+
* import { mockSuccess } from "@simplix-react/mock";
|
|
287
|
+
*
|
|
288
|
+
* const result = mockSuccess({ id: "1", title: "My Task" });
|
|
289
|
+
* // { success: true, data: { id: "1", title: "My Task" } }
|
|
290
|
+
* ```
|
|
85
291
|
*/
|
|
86
292
|
declare function mockSuccess<T>(data: T): MockResult<T>;
|
|
87
293
|
/**
|
|
88
|
-
*
|
|
294
|
+
* Creates a failure {@link MockResult} with the given error code and message.
|
|
295
|
+
*
|
|
296
|
+
* @param error - An object containing an error `code` and human-readable `message`.
|
|
297
|
+
* @returns A {@link MockResult} with `success: false` and the provided error.
|
|
298
|
+
*
|
|
299
|
+
* @example
|
|
300
|
+
* ```ts
|
|
301
|
+
* import { mockFailure } from "@simplix-react/mock";
|
|
302
|
+
*
|
|
303
|
+
* const result = mockFailure({ code: "not_found", message: "Task not found" });
|
|
304
|
+
* // { success: false, error: { code: "not_found", message: "Task not found" } }
|
|
305
|
+
* ```
|
|
89
306
|
*/
|
|
90
307
|
declare function mockFailure(error: {
|
|
91
308
|
code: string;
|
|
@@ -93,65 +310,244 @@ declare function mockFailure(error: {
|
|
|
93
310
|
}): MockResult<never>;
|
|
94
311
|
|
|
95
312
|
/**
|
|
96
|
-
*
|
|
313
|
+
* Represents a raw database row with unknown column values.
|
|
314
|
+
*
|
|
315
|
+
* Used as the input type for row-mapping functions that convert snake_case
|
|
316
|
+
* database columns to camelCase JavaScript properties.
|
|
317
|
+
*
|
|
318
|
+
* @see {@link mapRow} - Maps a single row.
|
|
319
|
+
* @see {@link mapRows} - Maps an array of rows.
|
|
97
320
|
*/
|
|
98
321
|
type DbRow = Record<string, unknown>;
|
|
99
322
|
/**
|
|
100
|
-
*
|
|
323
|
+
* Converts a snake_case string to camelCase.
|
|
324
|
+
*
|
|
325
|
+
* @param str - The snake_case input string.
|
|
326
|
+
* @returns The camelCase equivalent.
|
|
327
|
+
*
|
|
328
|
+
* @example
|
|
329
|
+
* ```ts
|
|
330
|
+
* import { toCamelCase } from "@simplix-react/mock";
|
|
331
|
+
*
|
|
332
|
+
* toCamelCase("created_at"); // "createdAt"
|
|
333
|
+
* toCamelCase("project_id"); // "projectId"
|
|
334
|
+
* ```
|
|
101
335
|
*/
|
|
102
336
|
declare function toCamelCase(str: string): string;
|
|
103
337
|
/**
|
|
104
|
-
*
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
*
|
|
109
|
-
*
|
|
338
|
+
* Maps a single database row from snake_case columns to a camelCase object.
|
|
339
|
+
*
|
|
340
|
+
* Columns ending in `_at` are automatically converted to `Date` objects.
|
|
341
|
+
*
|
|
342
|
+
* @typeParam T - The expected shape of the mapped object.
|
|
343
|
+
* @param row - The raw database row with snake_case keys.
|
|
344
|
+
* @returns The mapped object with camelCase keys.
|
|
345
|
+
*
|
|
346
|
+
* @example
|
|
347
|
+
* ```ts
|
|
348
|
+
* import { mapRow } from "@simplix-react/mock";
|
|
349
|
+
*
|
|
350
|
+
* const row = { id: "1", project_id: "p1", created_at: "2025-01-01T00:00:00Z" };
|
|
351
|
+
* const mapped = mapRow<{ id: string; projectId: string; createdAt: Date }>(row);
|
|
352
|
+
* // { id: "1", projectId: "p1", createdAt: Date("2025-01-01T00:00:00Z") }
|
|
353
|
+
* ```
|
|
354
|
+
*
|
|
355
|
+
* @see {@link mapRows} - Maps an array of rows.
|
|
356
|
+
* @see {@link toCamelCase} - Underlying case conversion.
|
|
110
357
|
*/
|
|
111
358
|
declare function mapRow<T>(row: DbRow): T;
|
|
112
359
|
/**
|
|
113
|
-
*
|
|
360
|
+
* Maps an array of database rows from snake_case to camelCase objects.
|
|
361
|
+
*
|
|
362
|
+
* Delegates to {@link mapRow} for each row.
|
|
363
|
+
*
|
|
364
|
+
* @typeParam T - The expected shape of each mapped object.
|
|
365
|
+
* @param rows - The array of raw database rows.
|
|
366
|
+
* @returns An array of mapped camelCase objects.
|
|
367
|
+
*
|
|
368
|
+
* @example
|
|
369
|
+
* ```ts
|
|
370
|
+
* import { mapRows } from "@simplix-react/mock";
|
|
371
|
+
*
|
|
372
|
+
* const rows = [
|
|
373
|
+
* { id: "1", task_name: "Build" },
|
|
374
|
+
* { id: "2", task_name: "Test" },
|
|
375
|
+
* ];
|
|
376
|
+
* const mapped = mapRows<{ id: string; taskName: string }>(rows);
|
|
377
|
+
* // [{ id: "1", taskName: "Build" }, { id: "2", taskName: "Test" }]
|
|
378
|
+
* ```
|
|
114
379
|
*/
|
|
115
380
|
declare function mapRows<T>(rows: DbRow[]): T[];
|
|
116
381
|
|
|
382
|
+
/**
|
|
383
|
+
* Represents the result of {@link buildSetClause}.
|
|
384
|
+
*
|
|
385
|
+
* Contains the SQL SET clause string, the ordered parameter values, and the next
|
|
386
|
+
* available parameter index for appending additional conditions (e.g. a WHERE clause).
|
|
387
|
+
*
|
|
388
|
+
* @see {@link buildSetClause} - Produces this result.
|
|
389
|
+
*/
|
|
117
390
|
interface SetClauseResult {
|
|
391
|
+
/** The SQL SET clause string (e.g. `"name = $1, updated_at = NOW()"`). */
|
|
118
392
|
clause: string;
|
|
393
|
+
/** The ordered parameter values corresponding to the placeholders. */
|
|
119
394
|
values: unknown[];
|
|
395
|
+
/** The next available `$N` parameter index. */
|
|
120
396
|
nextIndex: number;
|
|
121
397
|
}
|
|
122
398
|
/**
|
|
123
|
-
*
|
|
124
|
-
*
|
|
125
|
-
*
|
|
126
|
-
*
|
|
399
|
+
* Builds a parameterized SQL SET clause from a partial update object.
|
|
400
|
+
*
|
|
401
|
+
* Converts camelCase object keys to snake_case column names, skips `undefined`
|
|
402
|
+
* values, serializes nested objects as JSONB, and automatically appends
|
|
403
|
+
* `updated_at = NOW()`.
|
|
404
|
+
*
|
|
405
|
+
* @typeParam T - The shape of the update DTO.
|
|
406
|
+
* @param input - The partial object whose defined keys become SET assignments.
|
|
407
|
+
* @param startIndex - The starting `$N` placeholder index.
|
|
408
|
+
* @returns A {@link SetClauseResult} with the clause, values, and next index.
|
|
409
|
+
*
|
|
410
|
+
* @example
|
|
411
|
+
* ```ts
|
|
412
|
+
* import { buildSetClause } from "@simplix-react/mock";
|
|
413
|
+
*
|
|
414
|
+
* const { clause, values, nextIndex } = buildSetClause(
|
|
415
|
+
* { title: "Updated Task", status: "done" },
|
|
416
|
+
* );
|
|
417
|
+
* // clause: "title = $1, status = $2, updated_at = NOW()"
|
|
418
|
+
* // values: ["Updated Task", "done"]
|
|
419
|
+
* // nextIndex: 3
|
|
420
|
+
*
|
|
421
|
+
* const sql = `UPDATE tasks SET ${clause} WHERE id = $${nextIndex}`;
|
|
422
|
+
* // sql: "UPDATE tasks SET title = $1, status = $2, updated_at = NOW() WHERE id = $3"
|
|
423
|
+
* ```
|
|
424
|
+
*
|
|
425
|
+
* @see {@link SetClauseResult} - The return type.
|
|
127
426
|
*/
|
|
128
427
|
declare function buildSetClause<T extends object>(input: T, startIndex?: number): SetClauseResult;
|
|
129
428
|
|
|
429
|
+
/**
|
|
430
|
+
* Represents a mapped database error with an HTTP-friendly status code.
|
|
431
|
+
*
|
|
432
|
+
* Produced by {@link mapPgError} from raw PostgreSQL/PGlite exceptions.
|
|
433
|
+
*
|
|
434
|
+
* @see {@link mapPgError} - Creates instances from raw errors.
|
|
435
|
+
*/
|
|
130
436
|
interface MockError {
|
|
437
|
+
/** The HTTP status code corresponding to the error type. */
|
|
131
438
|
status: number;
|
|
439
|
+
/** A machine-readable error code (e.g. `"unique_violation"`, `"not_found"`). */
|
|
132
440
|
code: string;
|
|
441
|
+
/** A human-readable error description. */
|
|
133
442
|
message: string;
|
|
134
443
|
}
|
|
135
444
|
/**
|
|
136
|
-
*
|
|
445
|
+
* Maps a raw PostgreSQL/PGlite error to an HTTP-friendly {@link MockError}.
|
|
446
|
+
*
|
|
447
|
+
* Inspects the error message to classify the error:
|
|
448
|
+
*
|
|
449
|
+
* | Pattern | Code | HTTP Status |
|
|
450
|
+
* | ------------------------ | ----------------------- | ----------- |
|
|
451
|
+
* | unique / duplicate | `unique_violation` | 409 |
|
|
452
|
+
* | foreign key | `foreign_key_violation` | 422 |
|
|
453
|
+
* | not-null / null value | `not_null_violation` | 422 |
|
|
454
|
+
* | not found / no rows | `not_found` | 404 |
|
|
455
|
+
* | (unrecognized) | `query_error` | 500 |
|
|
456
|
+
*
|
|
457
|
+
* @param err - The raw error thrown by a PGlite query.
|
|
458
|
+
* @returns A structured {@link MockError} with status, code, and message.
|
|
459
|
+
*
|
|
460
|
+
* @example
|
|
461
|
+
* ```ts
|
|
462
|
+
* import { mapPgError } from "@simplix-react/mock";
|
|
463
|
+
*
|
|
464
|
+
* try {
|
|
465
|
+
* await db.query("INSERT INTO tasks ...");
|
|
466
|
+
* } catch (err) {
|
|
467
|
+
* const mapped = mapPgError(err);
|
|
468
|
+
* console.error(mapped.code, mapped.status); // e.g. "unique_violation" 409
|
|
469
|
+
* }
|
|
470
|
+
* ```
|
|
137
471
|
*/
|
|
138
472
|
declare function mapPgError(err: unknown): MockError;
|
|
139
473
|
|
|
140
474
|
/**
|
|
141
|
-
*
|
|
475
|
+
* Checks whether a table exists in the database by querying `information_schema.tables`.
|
|
476
|
+
*
|
|
477
|
+
* @param db - The PGlite instance.
|
|
478
|
+
* @param tableName - The name of the table to check.
|
|
479
|
+
* @returns `true` if the table exists, `false` otherwise.
|
|
480
|
+
*
|
|
481
|
+
* @example
|
|
482
|
+
* ```ts
|
|
483
|
+
* import { initPGlite, tableExists } from "@simplix-react/mock";
|
|
484
|
+
*
|
|
485
|
+
* const db = await initPGlite("idb://project-mock");
|
|
486
|
+
* if (!(await tableExists(db, "tasks"))) {
|
|
487
|
+
* await db.query("CREATE TABLE tasks (id TEXT PRIMARY KEY)");
|
|
488
|
+
* }
|
|
489
|
+
* ```
|
|
142
490
|
*/
|
|
143
491
|
declare function tableExists(db: PGlite, tableName: string): Promise<boolean>;
|
|
144
492
|
/**
|
|
145
|
-
*
|
|
493
|
+
* Checks whether a column exists in a table by querying `information_schema.columns`.
|
|
494
|
+
*
|
|
495
|
+
* @param db - The PGlite instance.
|
|
496
|
+
* @param tableName - The table to inspect.
|
|
497
|
+
* @param columnName - The column name to check for.
|
|
498
|
+
* @returns `true` if the column exists, `false` otherwise.
|
|
499
|
+
*
|
|
500
|
+
* @example
|
|
501
|
+
* ```ts
|
|
502
|
+
* import { initPGlite, columnExists } from "@simplix-react/mock";
|
|
503
|
+
*
|
|
504
|
+
* const db = await initPGlite("idb://project-mock");
|
|
505
|
+
* const has = await columnExists(db, "tasks", "priority");
|
|
506
|
+
* ```
|
|
146
507
|
*/
|
|
147
508
|
declare function columnExists(db: PGlite, tableName: string, columnName: string): Promise<boolean>;
|
|
148
509
|
/**
|
|
149
|
-
*
|
|
510
|
+
* Executes multiple SQL statements separated by semicolons.
|
|
511
|
+
*
|
|
512
|
+
* Splits the input on `;`, trims each statement, filters out empty strings,
|
|
513
|
+
* and executes them sequentially.
|
|
514
|
+
*
|
|
515
|
+
* @param db - The PGlite instance.
|
|
516
|
+
* @param sql - A string containing one or more semicolon-separated SQL statements.
|
|
517
|
+
*
|
|
518
|
+
* @example
|
|
519
|
+
* ```ts
|
|
520
|
+
* import { initPGlite, executeSql } from "@simplix-react/mock";
|
|
521
|
+
*
|
|
522
|
+
* const db = await initPGlite("idb://project-mock");
|
|
523
|
+
* await executeSql(db, `
|
|
524
|
+
* CREATE TABLE projects (id TEXT PRIMARY KEY, name TEXT NOT NULL);
|
|
525
|
+
* CREATE TABLE tasks (id TEXT PRIMARY KEY, project_id TEXT REFERENCES projects(id));
|
|
526
|
+
* `);
|
|
527
|
+
* ```
|
|
150
528
|
*/
|
|
151
529
|
declare function executeSql(db: PGlite, sql: string): Promise<void>;
|
|
152
530
|
/**
|
|
153
|
-
*
|
|
531
|
+
* Adds a column to a table only if it does not already exist.
|
|
532
|
+
*
|
|
533
|
+
* Combines {@link columnExists} with an `ALTER TABLE ADD COLUMN` statement
|
|
534
|
+
* for safe, idempotent schema migrations.
|
|
535
|
+
*
|
|
536
|
+
* @param db - The PGlite instance.
|
|
537
|
+
* @param tableName - The target table.
|
|
538
|
+
* @param columnName - The column name to add.
|
|
539
|
+
* @param columnDef - The column type definition (e.g. `"TEXT NOT NULL DEFAULT ''"`).
|
|
540
|
+
*
|
|
541
|
+
* @example
|
|
542
|
+
* ```ts
|
|
543
|
+
* import { initPGlite, addColumnIfNotExists } from "@simplix-react/mock";
|
|
544
|
+
*
|
|
545
|
+
* const db = await initPGlite("idb://project-mock");
|
|
546
|
+
* await addColumnIfNotExists(db, "tasks", "priority", "INTEGER DEFAULT 0");
|
|
547
|
+
* ```
|
|
548
|
+
*
|
|
549
|
+
* @see {@link columnExists} - Used internally to check existence.
|
|
154
550
|
*/
|
|
155
551
|
declare function addColumnIfNotExists(db: PGlite, tableName: string, columnName: string, columnDef: string): Promise<void>;
|
|
156
552
|
|
|
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
|
|
553
|
+
export { type DbRow, type MockEntityConfig, type MockError, type MockResult, type MockServerConfig, type SetClauseResult, addColumnIfNotExists, buildSetClause, columnExists, deriveMockHandlers, executeSql, getPGliteInstance, initPGlite, mapPgError, mapRow, mapRows, mockFailure, mockSuccess, resetPGliteInstance, setupMockWorker, tableExists, toCamelCase };
|
package/dist/index.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { http, HttpResponse } from 'msw';
|
|
2
|
+
import { camelToSnake } from '@simplix-react/contract';
|
|
2
3
|
|
|
3
4
|
// src/pglite.ts
|
|
4
5
|
var instance = null;
|
|
@@ -47,9 +48,6 @@ async function setupMockWorker(config) {
|
|
|
47
48
|
function toCamelCase(str) {
|
|
48
49
|
return str.replace(/_([a-z])/g, (_, c) => c.toUpperCase());
|
|
49
50
|
}
|
|
50
|
-
function toSnakeCase(str) {
|
|
51
|
-
return str.replace(/([a-z0-9])([A-Z])/g, "$1_$2").toLowerCase();
|
|
52
|
-
}
|
|
53
51
|
function mapRow(row) {
|
|
54
52
|
const result = {};
|
|
55
53
|
for (const [key, value] of Object.entries(row)) {
|
|
@@ -65,15 +63,13 @@ function mapRow(row) {
|
|
|
65
63
|
function mapRows(rows) {
|
|
66
64
|
return rows.map((row) => mapRow(row));
|
|
67
65
|
}
|
|
68
|
-
|
|
69
|
-
// src/sql/query-building.ts
|
|
70
66
|
function buildSetClause(input, startIndex = 1) {
|
|
71
67
|
const parts = [];
|
|
72
68
|
const values = [];
|
|
73
69
|
let index = startIndex;
|
|
74
70
|
for (const [key, value] of Object.entries(input)) {
|
|
75
71
|
if (value === void 0) continue;
|
|
76
|
-
const column =
|
|
72
|
+
const column = camelToSnake(key);
|
|
77
73
|
if (typeof value === "object" && value !== null && !(value instanceof Date)) {
|
|
78
74
|
parts.push(`${column} = $${index}::jsonb`);
|
|
79
75
|
values.push(JSON.stringify(value));
|
|
@@ -144,37 +140,65 @@ function deriveMockHandlers(config, mockConfig) {
|
|
|
144
140
|
const handlers = [];
|
|
145
141
|
const { basePath, entities } = config;
|
|
146
142
|
for (const [name, entity] of Object.entries(entities)) {
|
|
147
|
-
const
|
|
143
|
+
const entityConfig = mockConfig?.[name];
|
|
144
|
+
const tableName = entityConfig?.tableName ?? camelToSnake(name) + "s";
|
|
145
|
+
const defaultLimit = entityConfig?.defaultLimit ?? 50;
|
|
146
|
+
const maxLimit = entityConfig?.maxLimit ?? 100;
|
|
147
|
+
const defaultSort = entityConfig?.defaultSort ?? "created_at DESC";
|
|
148
|
+
const relations = entityConfig?.relations;
|
|
148
149
|
if (entity.parent) {
|
|
149
150
|
const listPath = `${basePath}${entity.parent.path}/:${entity.parent.param}${entity.path}`;
|
|
150
151
|
handlers.push(
|
|
151
|
-
http.get(listPath, async ({ params }) => {
|
|
152
|
-
const parentId =
|
|
153
|
-
const parentColumn =
|
|
152
|
+
http.get(listPath, async ({ request, params: routeParams }) => {
|
|
153
|
+
const parentId = routeParams[entity.parent.param];
|
|
154
|
+
const parentColumn = camelToSnake(entity.parent.param);
|
|
155
|
+
const searchParams = parseSearchParams(request.url);
|
|
154
156
|
return toResponse(
|
|
155
|
-
await
|
|
157
|
+
await queryListWithParams(
|
|
158
|
+
tableName,
|
|
159
|
+
parentColumn,
|
|
160
|
+
parentId,
|
|
161
|
+
searchParams,
|
|
162
|
+
defaultLimit,
|
|
163
|
+
maxLimit,
|
|
164
|
+
defaultSort
|
|
165
|
+
)
|
|
156
166
|
);
|
|
157
167
|
})
|
|
158
168
|
);
|
|
159
169
|
} else {
|
|
160
170
|
handlers.push(
|
|
161
|
-
http.get(`${basePath}${entity.path}`, async () => {
|
|
162
|
-
|
|
171
|
+
http.get(`${basePath}${entity.path}`, async ({ request }) => {
|
|
172
|
+
const searchParams = parseSearchParams(request.url);
|
|
173
|
+
return toResponse(
|
|
174
|
+
await queryListWithParams(
|
|
175
|
+
tableName,
|
|
176
|
+
void 0,
|
|
177
|
+
void 0,
|
|
178
|
+
searchParams,
|
|
179
|
+
defaultLimit,
|
|
180
|
+
maxLimit,
|
|
181
|
+
defaultSort
|
|
182
|
+
)
|
|
183
|
+
);
|
|
163
184
|
})
|
|
164
185
|
);
|
|
165
186
|
}
|
|
166
187
|
handlers.push(
|
|
167
|
-
http.get(`${basePath}${entity.path}/:id`, async ({ params }) => {
|
|
168
|
-
const id =
|
|
188
|
+
http.get(`${basePath}${entity.path}/:id`, async ({ params: routeParams }) => {
|
|
189
|
+
const id = routeParams.id;
|
|
190
|
+
if (relations) {
|
|
191
|
+
return toResponse(await queryByIdWithRelations(tableName, id, relations));
|
|
192
|
+
}
|
|
169
193
|
return toResponse(await queryById(tableName, id));
|
|
170
194
|
})
|
|
171
195
|
);
|
|
172
196
|
if (entity.parent) {
|
|
173
197
|
const createPath = `${basePath}${entity.parent.path}/:${entity.parent.param}${entity.path}`;
|
|
174
198
|
handlers.push(
|
|
175
|
-
http.post(createPath, async ({ request, params }) => {
|
|
199
|
+
http.post(createPath, async ({ request, params: routeParams }) => {
|
|
176
200
|
const dto = await request.json();
|
|
177
|
-
const parentId =
|
|
201
|
+
const parentId = routeParams[entity.parent.param];
|
|
178
202
|
dto[entity.parent.param] = parentId;
|
|
179
203
|
return toResponse(await insertRow(tableName, dto), 201);
|
|
180
204
|
})
|
|
@@ -188,52 +212,137 @@ function deriveMockHandlers(config, mockConfig) {
|
|
|
188
212
|
);
|
|
189
213
|
}
|
|
190
214
|
handlers.push(
|
|
191
|
-
http.patch(`${basePath}${entity.path}/:id`, async ({ request, params }) => {
|
|
192
|
-
const id =
|
|
215
|
+
http.patch(`${basePath}${entity.path}/:id`, async ({ request, params: routeParams }) => {
|
|
216
|
+
const id = routeParams.id;
|
|
193
217
|
const dto = await request.json();
|
|
194
218
|
return toResponse(await updateRow(tableName, id, dto));
|
|
195
219
|
})
|
|
196
220
|
);
|
|
197
221
|
handlers.push(
|
|
198
|
-
http.delete(`${basePath}${entity.path}/:id`, async ({ params }) => {
|
|
199
|
-
const id =
|
|
222
|
+
http.delete(`${basePath}${entity.path}/:id`, async ({ params: routeParams }) => {
|
|
223
|
+
const id = routeParams.id;
|
|
200
224
|
return toResponse(await deleteRow(tableName, id));
|
|
201
225
|
})
|
|
202
226
|
);
|
|
203
227
|
}
|
|
204
228
|
return handlers;
|
|
205
229
|
}
|
|
206
|
-
|
|
230
|
+
function parseSearchParams(requestUrl) {
|
|
231
|
+
const url = new URL(requestUrl, "http://localhost");
|
|
232
|
+
const filters = {};
|
|
233
|
+
let sort;
|
|
234
|
+
let page;
|
|
235
|
+
let limit;
|
|
236
|
+
let cursor;
|
|
237
|
+
url.searchParams.forEach((value, key) => {
|
|
238
|
+
if (key === "sort") {
|
|
239
|
+
sort = value;
|
|
240
|
+
} else if (key === "page") {
|
|
241
|
+
page = parseInt(value, 10);
|
|
242
|
+
} else if (key === "limit") {
|
|
243
|
+
limit = parseInt(value, 10);
|
|
244
|
+
} else if (key === "cursor") {
|
|
245
|
+
cursor = value;
|
|
246
|
+
} else {
|
|
247
|
+
filters[key] = value;
|
|
248
|
+
}
|
|
249
|
+
});
|
|
250
|
+
return { filters, sort, page, limit, cursor };
|
|
251
|
+
}
|
|
252
|
+
async function queryListWithParams(tableName, parentColumn, parentId, searchParams, defaultLimit, maxLimit, defaultSort) {
|
|
207
253
|
try {
|
|
208
254
|
const db = getPGliteInstance();
|
|
209
|
-
const
|
|
210
|
-
|
|
255
|
+
const conditions = [];
|
|
256
|
+
const values = [];
|
|
257
|
+
let paramIndex = 1;
|
|
258
|
+
if (parentColumn && parentId) {
|
|
259
|
+
conditions.push(`${parentColumn} = $${paramIndex}`);
|
|
260
|
+
values.push(parentId);
|
|
261
|
+
paramIndex++;
|
|
262
|
+
}
|
|
263
|
+
for (const [key, value] of Object.entries(searchParams.filters)) {
|
|
264
|
+
const column = camelToSnake(key);
|
|
265
|
+
conditions.push(`${column} = $${paramIndex}`);
|
|
266
|
+
values.push(value);
|
|
267
|
+
paramIndex++;
|
|
268
|
+
}
|
|
269
|
+
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
|
|
270
|
+
let orderClause = `ORDER BY ${defaultSort}`;
|
|
271
|
+
if (searchParams.sort) {
|
|
272
|
+
const sortParts = searchParams.sort.split(",").map((s) => {
|
|
273
|
+
const [field, dir] = s.trim().split(":");
|
|
274
|
+
const column = camelToSnake(field);
|
|
275
|
+
const direction = dir === "desc" ? "DESC" : "ASC";
|
|
276
|
+
return `${column} ${direction}`;
|
|
277
|
+
});
|
|
278
|
+
orderClause = `ORDER BY ${sortParts.join(", ")}`;
|
|
279
|
+
}
|
|
280
|
+
const hasPagination = searchParams.page !== void 0 || searchParams.cursor !== void 0;
|
|
281
|
+
const limit = Math.min(searchParams.limit ?? defaultLimit, maxLimit);
|
|
282
|
+
if (hasPagination) {
|
|
283
|
+
const countSql = `SELECT COUNT(*) as count FROM ${tableName} ${whereClause}`;
|
|
284
|
+
const countResult = await db.query(countSql, values);
|
|
285
|
+
const total = parseInt(String(countResult.rows[0].count), 10);
|
|
286
|
+
const page = searchParams.page ?? 1;
|
|
287
|
+
const offset = (page - 1) * limit;
|
|
288
|
+
const dataSql = `SELECT * FROM ${tableName} ${whereClause} ${orderClause} LIMIT ${limit} OFFSET ${offset}`;
|
|
289
|
+
const dataResult = await db.query(dataSql, values);
|
|
290
|
+
const rows = mapRows(dataResult.rows);
|
|
291
|
+
return mockSuccess({
|
|
292
|
+
data: rows,
|
|
293
|
+
meta: {
|
|
294
|
+
total,
|
|
295
|
+
page,
|
|
296
|
+
limit,
|
|
297
|
+
hasNextPage: offset + rows.length < total
|
|
298
|
+
}
|
|
299
|
+
});
|
|
300
|
+
}
|
|
301
|
+
const sql = `SELECT * FROM ${tableName} ${whereClause} ${orderClause}`;
|
|
302
|
+
const result = await db.query(sql, values);
|
|
303
|
+
return mockSuccess({ data: mapRows(result.rows) });
|
|
211
304
|
} catch (err) {
|
|
212
305
|
const mapped = mapPgError(err);
|
|
213
306
|
return mockFailure({ code: mapped.code, message: mapped.message });
|
|
214
307
|
}
|
|
215
308
|
}
|
|
216
|
-
async function
|
|
309
|
+
async function queryById(tableName, id) {
|
|
217
310
|
try {
|
|
218
311
|
const db = getPGliteInstance();
|
|
219
|
-
const result = await db.query(
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
return mockSuccess(
|
|
312
|
+
const result = await db.query(`SELECT * FROM ${tableName} WHERE id = $1`, [id]);
|
|
313
|
+
if (result.rows.length === 0) {
|
|
314
|
+
return mockFailure({ code: "not_found", message: `${tableName} not found` });
|
|
315
|
+
}
|
|
316
|
+
return mockSuccess(mapRow(result.rows[0]));
|
|
224
317
|
} catch (err) {
|
|
225
318
|
const mapped = mapPgError(err);
|
|
226
319
|
return mockFailure({ code: mapped.code, message: mapped.message });
|
|
227
320
|
}
|
|
228
321
|
}
|
|
229
|
-
async function
|
|
322
|
+
async function queryByIdWithRelations(tableName, id, relations) {
|
|
230
323
|
try {
|
|
231
324
|
const db = getPGliteInstance();
|
|
232
|
-
const
|
|
233
|
-
if (
|
|
325
|
+
const mainResult = await db.query(`SELECT * FROM ${tableName} WHERE id = $1`, [id]);
|
|
326
|
+
if (mainResult.rows.length === 0) {
|
|
234
327
|
return mockFailure({ code: "not_found", message: `${tableName} not found` });
|
|
235
328
|
}
|
|
236
|
-
|
|
329
|
+
const row = mainResult.rows[0];
|
|
330
|
+
const mapped = mapRow(row);
|
|
331
|
+
for (const [relationName, relation] of Object.entries(relations)) {
|
|
332
|
+
const localColumn = camelToSnake(relation.localKey);
|
|
333
|
+
const fkValue = row[localColumn];
|
|
334
|
+
if (fkValue) {
|
|
335
|
+
const foreignKey = relation.foreignKey ?? "id";
|
|
336
|
+
const relResult = await db.query(
|
|
337
|
+
`SELECT * FROM ${relation.table} WHERE ${foreignKey} = $1`,
|
|
338
|
+
[fkValue]
|
|
339
|
+
);
|
|
340
|
+
if (relResult.rows.length > 0) {
|
|
341
|
+
mapped[relationName] = mapRow(relResult.rows[0]);
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
return mockSuccess(mapped);
|
|
237
346
|
} catch (err) {
|
|
238
347
|
const mapped = mapPgError(err);
|
|
239
348
|
return mockFailure({ code: mapped.code, message: mapped.message });
|
|
@@ -254,7 +363,7 @@ async function insertRow(tableName, dto) {
|
|
|
254
363
|
}
|
|
255
364
|
for (const [key, value] of Object.entries(dto)) {
|
|
256
365
|
if (value === void 0) continue;
|
|
257
|
-
const column =
|
|
366
|
+
const column = camelToSnake(key);
|
|
258
367
|
columns.push(column);
|
|
259
368
|
if (typeof value === "object" && value !== null && !(value instanceof Date)) {
|
|
260
369
|
placeholders.push(`$${index}::jsonb`);
|
|
@@ -359,4 +468,4 @@ async function addColumnIfNotExists(db, tableName, columnName, columnDef) {
|
|
|
359
468
|
}
|
|
360
469
|
}
|
|
361
470
|
|
|
362
|
-
export { addColumnIfNotExists, buildSetClause, columnExists, deriveMockHandlers, executeSql, getPGliteInstance, initPGlite, mapPgError, mapRow, mapRows, mockFailure, mockSuccess, resetPGliteInstance, setupMockWorker, tableExists, toCamelCase
|
|
471
|
+
export { addColumnIfNotExists, buildSetClause, columnExists, deriveMockHandlers, executeSql, getPGliteInstance, initPGlite, mapPgError, mapRow, mapRows, mockFailure, mockSuccess, resetPGliteInstance, setupMockWorker, tableExists, toCamelCase };
|
package/package.json
CHANGED
|
@@ -1,42 +1,53 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@simplix-react/mock",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.3",
|
|
4
4
|
"description": "Auto-generated MSW handlers and PGlite repositories from @simplix-react/contract",
|
|
5
5
|
"type": "module",
|
|
6
|
+
"sideEffects": false,
|
|
6
7
|
"exports": {
|
|
7
8
|
".": {
|
|
8
9
|
"types": "./dist/index.d.ts",
|
|
9
10
|
"import": "./dist/index.js"
|
|
10
11
|
}
|
|
11
12
|
},
|
|
12
|
-
"files": [
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
"
|
|
17
|
-
"lint": "eslint src",
|
|
18
|
-
"test": "vitest run --passWithNoTests",
|
|
19
|
-
"clean": "rm -rf dist .turbo"
|
|
13
|
+
"files": [
|
|
14
|
+
"dist"
|
|
15
|
+
],
|
|
16
|
+
"publishConfig": {
|
|
17
|
+
"access": "public"
|
|
20
18
|
},
|
|
21
19
|
"peerDependencies": {
|
|
22
|
-
"@simplix-react/contract": "workspace:*",
|
|
23
20
|
"@electric-sql/pglite": ">=0.2.0",
|
|
24
21
|
"msw": ">=2.0.0",
|
|
25
|
-
"zod": ">=4.0.0"
|
|
22
|
+
"zod": ">=4.0.0",
|
|
23
|
+
"@simplix-react/contract": "0.0.3"
|
|
26
24
|
},
|
|
27
25
|
"peerDependenciesMeta": {
|
|
28
|
-
"@electric-sql/pglite": {
|
|
29
|
-
|
|
26
|
+
"@electric-sql/pglite": {
|
|
27
|
+
"optional": true
|
|
28
|
+
},
|
|
29
|
+
"msw": {
|
|
30
|
+
"optional": true
|
|
31
|
+
}
|
|
30
32
|
},
|
|
31
33
|
"devDependencies": {
|
|
32
|
-
"@simplix-react/config-typescript": "workspace:*",
|
|
33
|
-
"@simplix-react/contract": "workspace:*",
|
|
34
34
|
"@electric-sql/pglite": "^0.3.14",
|
|
35
35
|
"eslint": "^9.39.2",
|
|
36
36
|
"msw": "^2.7.0",
|
|
37
37
|
"tsup": "^8.5.1",
|
|
38
38
|
"typescript": "^5.9.3",
|
|
39
39
|
"vitest": "^3.0.0",
|
|
40
|
-
"zod": "^4.0.0"
|
|
40
|
+
"zod": "^4.0.0",
|
|
41
|
+
"@simplix-react/config-eslint": "0.0.1",
|
|
42
|
+
"@simplix-react/contract": "0.0.3",
|
|
43
|
+
"@simplix-react/config-typescript": "0.0.1"
|
|
44
|
+
},
|
|
45
|
+
"scripts": {
|
|
46
|
+
"build": "tsup",
|
|
47
|
+
"dev": "tsup --watch",
|
|
48
|
+
"typecheck": "tsc --noEmit",
|
|
49
|
+
"lint": "eslint src",
|
|
50
|
+
"test": "vitest run --passWithNoTests",
|
|
51
|
+
"clean": "rm -rf dist .turbo"
|
|
41
52
|
}
|
|
42
|
-
}
|
|
53
|
+
}
|