jiva-core 0.3.47 → 0.3.48-dev.0fd3c65
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 +31 -1
- package/dist/code/agent.d.ts +13 -0
- package/dist/code/agent.d.ts.map +1 -1
- package/dist/code/agent.js +81 -7
- package/dist/code/agent.js.map +1 -1
- package/dist/code/benchmark/agent-factory.d.ts +18 -0
- package/dist/code/benchmark/agent-factory.d.ts.map +1 -0
- package/dist/code/benchmark/agent-factory.js +21 -0
- package/dist/code/benchmark/agent-factory.js.map +1 -0
- package/dist/code/benchmark/fixtures.d.ts +28 -0
- package/dist/code/benchmark/fixtures.d.ts.map +1 -0
- package/dist/code/benchmark/fixtures.js +177 -0
- package/dist/code/benchmark/fixtures.js.map +1 -0
- package/dist/code/benchmark/index.d.ts +16 -0
- package/dist/code/benchmark/index.d.ts.map +1 -0
- package/dist/code/benchmark/index.js +14 -0
- package/dist/code/benchmark/index.js.map +1 -0
- package/dist/code/benchmark/microcrm/assets/api.test.mjs +254 -0
- package/dist/code/benchmark/microcrm/assets/app.full.reference.mjs +267 -0
- package/dist/code/benchmark/microcrm/assets/app.reference.mjs +191 -0
- package/dist/code/benchmark/microcrm/assets/bulk.test.mjs +63 -0
- package/dist/code/benchmark/microcrm/assets/idempotency.test.mjs +64 -0
- package/dist/code/benchmark/microcrm/assets/query.test.mjs +96 -0
- package/dist/code/benchmark/microcrm/assets/stats.test.mjs +67 -0
- package/dist/code/benchmark/microcrm/index.d.ts +23 -0
- package/dist/code/benchmark/microcrm/index.d.ts.map +1 -0
- package/dist/code/benchmark/microcrm/index.js +72 -0
- package/dist/code/benchmark/microcrm/index.js.map +1 -0
- package/dist/code/benchmark/microcrm/spec.d.ts +13 -0
- package/dist/code/benchmark/microcrm/spec.d.ts.map +1 -0
- package/dist/code/benchmark/microcrm/spec.js +115 -0
- package/dist/code/benchmark/microcrm/spec.js.map +1 -0
- package/dist/code/benchmark/orchestrator-factory.d.ts +36 -0
- package/dist/code/benchmark/orchestrator-factory.d.ts.map +1 -0
- package/dist/code/benchmark/orchestrator-factory.js +57 -0
- package/dist/code/benchmark/orchestrator-factory.js.map +1 -0
- package/dist/code/benchmark/report.d.ts +8 -0
- package/dist/code/benchmark/report.d.ts.map +1 -0
- package/dist/code/benchmark/report.js +103 -0
- package/dist/code/benchmark/report.js.map +1 -0
- package/dist/code/benchmark/runner.d.ts +29 -0
- package/dist/code/benchmark/runner.d.ts.map +1 -0
- package/dist/code/benchmark/runner.js +219 -0
- package/dist/code/benchmark/runner.js.map +1 -0
- package/dist/code/benchmark/suites.d.ts +19 -0
- package/dist/code/benchmark/suites.d.ts.map +1 -0
- package/dist/code/benchmark/suites.js +48 -0
- package/dist/code/benchmark/suites.js.map +1 -0
- package/dist/code/benchmark/tasks.d.ts +16 -0
- package/dist/code/benchmark/tasks.d.ts.map +1 -0
- package/dist/code/benchmark/tasks.js +153 -0
- package/dist/code/benchmark/tasks.js.map +1 -0
- package/dist/code/benchmark/tests.d.ts +17 -0
- package/dist/code/benchmark/tests.d.ts.map +1 -0
- package/dist/code/benchmark/tests.js +234 -0
- package/dist/code/benchmark/tests.js.map +1 -0
- package/dist/code/benchmark/types.d.ts +161 -0
- package/dist/code/benchmark/types.d.ts.map +1 -0
- package/dist/code/benchmark/types.js +11 -0
- package/dist/code/benchmark/types.js.map +1 -0
- package/dist/code/benchmark/verify.d.ts +21 -0
- package/dist/code/benchmark/verify.d.ts.map +1 -0
- package/dist/code/benchmark/verify.js +108 -0
- package/dist/code/benchmark/verify.js.map +1 -0
- package/dist/code/tools/edit.d.ts.map +1 -1
- package/dist/code/tools/edit.js +12 -3
- package/dist/code/tools/edit.js.map +1 -1
- package/dist/code/tools/write.d.ts.map +1 -1
- package/dist/code/tools/write.js +13 -3
- package/dist/code/tools/write.js.map +1 -1
- package/dist/core/agent-interface.d.ts +5 -0
- package/dist/core/agent-interface.d.ts.map +1 -1
- package/dist/interfaces/cli/index.js +125 -0
- package/dist/interfaces/cli/index.js.map +1 -1
- package/dist/interfaces/cli/repl.d.ts.map +1 -1
- package/dist/interfaces/cli/repl.js +41 -15
- package/dist/interfaces/cli/repl.js.map +1 -1
- package/dist/interfaces/cli/setup-wizard.d.ts.map +1 -1
- package/dist/interfaces/cli/setup-wizard.js +2 -1
- package/dist/interfaces/cli/setup-wizard.js.map +1 -1
- package/dist/interfaces/http/index.d.ts.map +1 -1
- package/dist/interfaces/http/index.js +2 -0
- package/dist/interfaces/http/index.js.map +1 -1
- package/dist/interfaces/http/routes/benchmark.d.ts +14 -0
- package/dist/interfaces/http/routes/benchmark.d.ts.map +1 -0
- package/dist/interfaces/http/routes/benchmark.js +130 -0
- package/dist/interfaces/http/routes/benchmark.js.map +1 -0
- package/dist/models/model-client.d.ts +2 -1
- package/dist/models/model-client.d.ts.map +1 -1
- package/dist/models/model-client.js.map +1 -1
- package/package.json +2 -2
- package/scripts/bench-selftest.mjs +112 -0
- package/scripts/copy-benchmark-assets.mjs +17 -0
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
import http from 'node:http';
|
|
2
|
+
import { DatabaseSync } from 'node:sqlite';
|
|
3
|
+
|
|
4
|
+
const STAGES = ['lead', 'qualified', 'won', 'lost'];
|
|
5
|
+
const STAGE_PROBABILITY = { lead: 0.1, qualified: 0.4, won: 1.0, lost: 0.0 };
|
|
6
|
+
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
7
|
+
const round = (n, dp) => Math.round(n * 10 ** dp) / 10 ** dp;
|
|
8
|
+
|
|
9
|
+
export function createApp(dbPath = ':memory:') {
|
|
10
|
+
const db = new DatabaseSync(dbPath);
|
|
11
|
+
db.exec('PRAGMA foreign_keys = ON;');
|
|
12
|
+
db.exec(`
|
|
13
|
+
CREATE TABLE IF NOT EXISTS contacts (
|
|
14
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
15
|
+
name TEXT NOT NULL,
|
|
16
|
+
email TEXT NOT NULL UNIQUE,
|
|
17
|
+
company TEXT,
|
|
18
|
+
created_at TEXT NOT NULL
|
|
19
|
+
);
|
|
20
|
+
CREATE TABLE IF NOT EXISTS deals (
|
|
21
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
22
|
+
contact_id INTEGER NOT NULL,
|
|
23
|
+
title TEXT NOT NULL,
|
|
24
|
+
amount REAL NOT NULL DEFAULT 0,
|
|
25
|
+
stage TEXT NOT NULL DEFAULT 'lead',
|
|
26
|
+
created_at TEXT NOT NULL,
|
|
27
|
+
FOREIGN KEY (contact_id) REFERENCES contacts(id) ON DELETE CASCADE
|
|
28
|
+
);
|
|
29
|
+
`);
|
|
30
|
+
|
|
31
|
+
// In-memory idempotency-key store: key -> contact id (per app instance).
|
|
32
|
+
const idempotency = new Map();
|
|
33
|
+
|
|
34
|
+
const getContact = (id) => db.prepare('SELECT * FROM contacts WHERE id = ?').get(id) ?? null;
|
|
35
|
+
const getDeal = (id) => db.prepare('SELECT * FROM deals WHERE id = ?').get(id) ?? null;
|
|
36
|
+
|
|
37
|
+
function validateContact(b, requireAll) {
|
|
38
|
+
if (requireAll || b.name !== undefined) {
|
|
39
|
+
if (typeof b.name !== 'string' || b.name.trim() === '') return 'name is required';
|
|
40
|
+
}
|
|
41
|
+
if (requireAll || b.email !== undefined) {
|
|
42
|
+
if (typeof b.email !== 'string' || !EMAIL_RE.test(b.email)) return 'a valid email is required';
|
|
43
|
+
}
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
function validateDeal(b, requireAll) {
|
|
47
|
+
if (requireAll || b.title !== undefined) {
|
|
48
|
+
if (typeof b.title !== 'string' || b.title.trim() === '') return 'title is required';
|
|
49
|
+
}
|
|
50
|
+
if (b.amount !== undefined) {
|
|
51
|
+
if (typeof b.amount !== 'number' || Number.isNaN(b.amount) || b.amount < 0) return 'amount must be a number >= 0';
|
|
52
|
+
}
|
|
53
|
+
if (b.stage !== undefined) {
|
|
54
|
+
if (!STAGES.includes(b.stage)) return 'invalid stage';
|
|
55
|
+
}
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const send = (res, status, obj) => {
|
|
60
|
+
res.writeHead(status, { 'content-type': 'application/json' });
|
|
61
|
+
res.end(obj === undefined ? '' : JSON.stringify(obj));
|
|
62
|
+
};
|
|
63
|
+
const readBody = (req) =>
|
|
64
|
+
new Promise((resolve, reject) => {
|
|
65
|
+
let data = '';
|
|
66
|
+
req.on('data', (c) => (data += c));
|
|
67
|
+
req.on('end', () => {
|
|
68
|
+
if (!data) return resolve({});
|
|
69
|
+
try { resolve(JSON.parse(data)); } catch { reject(new Error('invalid json')); }
|
|
70
|
+
});
|
|
71
|
+
req.on('error', reject);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
const insertDeal = (contactId, d) =>
|
|
75
|
+
db.prepare('INSERT INTO deals (contact_id, title, amount, stage, created_at) VALUES (?, ?, ?, ?, ?)')
|
|
76
|
+
.run(contactId, d.title, d.amount ?? 0, d.stage ?? 'lead', new Date().toISOString());
|
|
77
|
+
|
|
78
|
+
const handler = async (req, res) => {
|
|
79
|
+
const url = new URL(req.url, 'http://localhost');
|
|
80
|
+
const parts = url.pathname.split('/').filter(Boolean);
|
|
81
|
+
const method = req.method;
|
|
82
|
+
|
|
83
|
+
try {
|
|
84
|
+
if (parts[0] === 'contacts') {
|
|
85
|
+
// POST /contacts (with optional Idempotency-Key)
|
|
86
|
+
if (parts.length === 1 && method === 'POST') {
|
|
87
|
+
const key = req.headers['idempotency-key'];
|
|
88
|
+
if (key && idempotency.has(key)) {
|
|
89
|
+
return send(res, 200, getContact(idempotency.get(key)));
|
|
90
|
+
}
|
|
91
|
+
let body;
|
|
92
|
+
try { body = await readBody(req); } catch { return send(res, 400, { error: 'invalid json' }); }
|
|
93
|
+
const err = validateContact(body, true);
|
|
94
|
+
if (err) return send(res, 400, { error: err });
|
|
95
|
+
try {
|
|
96
|
+
const info = db
|
|
97
|
+
.prepare('INSERT INTO contacts (name, email, company, created_at) VALUES (?, ?, ?, ?)')
|
|
98
|
+
.run(body.name, body.email, body.company ?? null, new Date().toISOString());
|
|
99
|
+
const id = Number(info.lastInsertRowid);
|
|
100
|
+
if (key) idempotency.set(key, id);
|
|
101
|
+
return send(res, 201, getContact(id));
|
|
102
|
+
} catch (e) {
|
|
103
|
+
if (String(e.message).includes('UNIQUE')) return send(res, 409, { error: 'email already exists' });
|
|
104
|
+
throw e;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
if (parts.length === 1 && method === 'GET') {
|
|
108
|
+
const q = url.searchParams.get('q');
|
|
109
|
+
const limit = Math.max(0, parseInt(url.searchParams.get('limit') ?? '50', 10) || 50);
|
|
110
|
+
const offset = Math.max(0, parseInt(url.searchParams.get('offset') ?? '0', 10) || 0);
|
|
111
|
+
let where = '';
|
|
112
|
+
const args = [];
|
|
113
|
+
if (q) { where = 'WHERE name LIKE ? OR email LIKE ?'; args.push('%' + q + '%', '%' + q + '%'); }
|
|
114
|
+
const total = db.prepare(`SELECT COUNT(*) AS c FROM contacts ${where}`).get(...args).c;
|
|
115
|
+
const data = db.prepare(`SELECT * FROM contacts ${where} ORDER BY id LIMIT ? OFFSET ?`).all(...args, limit, offset);
|
|
116
|
+
return send(res, 200, { data, total });
|
|
117
|
+
}
|
|
118
|
+
if (parts.length === 2) {
|
|
119
|
+
const id = Number(parts[1]);
|
|
120
|
+
const existing = getContact(id);
|
|
121
|
+
if (method === 'GET') return existing ? send(res, 200, existing) : send(res, 404, { error: 'not found' });
|
|
122
|
+
if (method === 'PATCH') {
|
|
123
|
+
if (!existing) return send(res, 404, { error: 'not found' });
|
|
124
|
+
let body;
|
|
125
|
+
try { body = await readBody(req); } catch { return send(res, 400, { error: 'invalid json' }); }
|
|
126
|
+
const err = validateContact(body, false);
|
|
127
|
+
if (err) return send(res, 400, { error: err });
|
|
128
|
+
const name = body.name ?? existing.name;
|
|
129
|
+
const email = body.email ?? existing.email;
|
|
130
|
+
const company = body.company !== undefined ? body.company : existing.company;
|
|
131
|
+
try {
|
|
132
|
+
db.prepare('UPDATE contacts SET name = ?, email = ?, company = ? WHERE id = ?').run(name, email, company, id);
|
|
133
|
+
} catch (e) {
|
|
134
|
+
if (String(e.message).includes('UNIQUE')) return send(res, 409, { error: 'email already exists' });
|
|
135
|
+
throw e;
|
|
136
|
+
}
|
|
137
|
+
return send(res, 200, getContact(id));
|
|
138
|
+
}
|
|
139
|
+
if (method === 'DELETE') {
|
|
140
|
+
if (!existing) return send(res, 404, { error: 'not found' });
|
|
141
|
+
db.prepare('DELETE FROM contacts WHERE id = ?').run(id);
|
|
142
|
+
return send(res, 204);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
// GET /contacts/:id/stats
|
|
146
|
+
if (parts.length === 3 && parts[2] === 'stats' && method === 'GET') {
|
|
147
|
+
const id = Number(parts[1]);
|
|
148
|
+
if (!getContact(id)) return send(res, 404, { error: 'contact not found' });
|
|
149
|
+
const deals = db.prepare('SELECT * FROM deals WHERE contact_id = ?').all(id);
|
|
150
|
+
const totalDeals = deals.length;
|
|
151
|
+
const totalValue = deals.reduce((s, d) => s + d.amount, 0);
|
|
152
|
+
const wonDeals = deals.filter((d) => d.stage === 'won');
|
|
153
|
+
const wonValue = wonDeals.reduce((s, d) => s + d.amount, 0);
|
|
154
|
+
const winRate = totalDeals === 0 ? 0 : round(wonDeals.length / totalDeals, 4);
|
|
155
|
+
const weightedPipeline = round(deals.reduce((s, d) => s + d.amount * (STAGE_PROBABILITY[d.stage] ?? 0), 0), 2);
|
|
156
|
+
return send(res, 200, { totalDeals, totalValue, wonValue, winRate, weightedPipeline });
|
|
157
|
+
}
|
|
158
|
+
// /contacts/:id/deals and /contacts/:id/deals/bulk
|
|
159
|
+
if ((parts.length === 3 || parts.length === 4) && parts[2] === 'deals') {
|
|
160
|
+
const id = Number(parts[1]);
|
|
161
|
+
const contact = getContact(id);
|
|
162
|
+
if (!contact) return send(res, 404, { error: 'contact not found' });
|
|
163
|
+
|
|
164
|
+
// POST /contacts/:id/deals/bulk — atomic
|
|
165
|
+
if (parts.length === 4 && parts[3] === 'bulk' && method === 'POST') {
|
|
166
|
+
let body;
|
|
167
|
+
try { body = await readBody(req); } catch { return send(res, 400, { error: 'invalid json' }); }
|
|
168
|
+
const list = body?.deals;
|
|
169
|
+
if (!Array.isArray(list) || list.length === 0) return send(res, 400, { error: 'deals must be a non-empty array' });
|
|
170
|
+
for (const d of list) {
|
|
171
|
+
const err = validateDeal(d, true);
|
|
172
|
+
if (err) return send(res, 400, { error: err }); // reject whole batch, insert nothing
|
|
173
|
+
}
|
|
174
|
+
db.exec('BEGIN');
|
|
175
|
+
try {
|
|
176
|
+
const created = [];
|
|
177
|
+
for (const d of list) created.push(getDeal(Number(insertDeal(id, d).lastInsertRowid)));
|
|
178
|
+
db.exec('COMMIT');
|
|
179
|
+
return send(res, 201, { data: created, total: created.length });
|
|
180
|
+
} catch (e) {
|
|
181
|
+
db.exec('ROLLBACK');
|
|
182
|
+
throw e;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
if (parts.length === 3 && method === 'POST') {
|
|
187
|
+
let body;
|
|
188
|
+
try { body = await readBody(req); } catch { return send(res, 400, { error: 'invalid json' }); }
|
|
189
|
+
const err = validateDeal(body, true);
|
|
190
|
+
if (err) return send(res, 400, { error: err });
|
|
191
|
+
return send(res, 201, getDeal(Number(insertDeal(id, body).lastInsertRowid)));
|
|
192
|
+
}
|
|
193
|
+
if (parts.length === 3 && method === 'GET') {
|
|
194
|
+
const data = db.prepare('SELECT * FROM deals WHERE contact_id = ? ORDER BY id').all(id);
|
|
195
|
+
return send(res, 200, { data, total: data.length });
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
if (parts[0] === 'deals') {
|
|
201
|
+
if (parts.length === 2 && parts[1] === 'summary' && method === 'GET') {
|
|
202
|
+
const summary = {};
|
|
203
|
+
for (const s of STAGES) summary[s] = { count: 0, totalAmount: 0 };
|
|
204
|
+
const rows = db
|
|
205
|
+
.prepare('SELECT stage, COUNT(*) AS count, COALESCE(SUM(amount), 0) AS totalAmount FROM deals GROUP BY stage')
|
|
206
|
+
.all();
|
|
207
|
+
for (const r of rows) summary[r.stage] = { count: r.count, totalAmount: r.totalAmount };
|
|
208
|
+
return send(res, 200, { summary });
|
|
209
|
+
}
|
|
210
|
+
// GET /deals — advanced filtering / sorting / pagination
|
|
211
|
+
if (parts.length === 1 && method === 'GET') {
|
|
212
|
+
const sp = url.searchParams;
|
|
213
|
+
const where = [];
|
|
214
|
+
const args = [];
|
|
215
|
+
const stage = sp.get('stage');
|
|
216
|
+
if (stage !== null) { where.push('stage = ?'); args.push(stage); }
|
|
217
|
+
for (const [param, op] of [['minAmount', '>='], ['maxAmount', '<=']]) {
|
|
218
|
+
const v = sp.get(param);
|
|
219
|
+
if (v !== null) {
|
|
220
|
+
const n = Number(v);
|
|
221
|
+
if (Number.isNaN(n)) return send(res, 400, { error: `invalid ${param}` });
|
|
222
|
+
where.push(`amount ${op} ?`);
|
|
223
|
+
args.push(n);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
let orderBy = 'id';
|
|
227
|
+
let dir = 'ASC';
|
|
228
|
+
const sort = sp.get('sort');
|
|
229
|
+
if (sort !== null) {
|
|
230
|
+
const [field, d = 'asc'] = sort.split(':');
|
|
231
|
+
if (!['amount', 'created_at', 'id'].includes(field) || !['asc', 'desc'].includes(d.toLowerCase())) {
|
|
232
|
+
return send(res, 400, { error: 'invalid sort' });
|
|
233
|
+
}
|
|
234
|
+
orderBy = field;
|
|
235
|
+
dir = d.toUpperCase();
|
|
236
|
+
}
|
|
237
|
+
const whereSql = where.length ? `WHERE ${where.join(' AND ')}` : '';
|
|
238
|
+
const total = db.prepare(`SELECT COUNT(*) AS c FROM deals ${whereSql}`).get(...args).c;
|
|
239
|
+
const limit = Math.max(0, parseInt(sp.get('limit') ?? '50', 10) || 50);
|
|
240
|
+
const offset = Math.max(0, parseInt(sp.get('offset') ?? '0', 10) || 0);
|
|
241
|
+
const data = db.prepare(`SELECT * FROM deals ${whereSql} ORDER BY ${orderBy} ${dir} LIMIT ? OFFSET ?`).all(...args, limit, offset);
|
|
242
|
+
return send(res, 200, { data, total, hasMore: offset + data.length < total });
|
|
243
|
+
}
|
|
244
|
+
if (parts.length === 2 && method === 'PATCH') {
|
|
245
|
+
const id = Number(parts[1]);
|
|
246
|
+
const existing = getDeal(id);
|
|
247
|
+
if (!existing) return send(res, 404, { error: 'not found' });
|
|
248
|
+
let body;
|
|
249
|
+
try { body = await readBody(req); } catch { return send(res, 400, { error: 'invalid json' }); }
|
|
250
|
+
const err = validateDeal(body, false);
|
|
251
|
+
if (err) return send(res, 400, { error: err });
|
|
252
|
+
const title = body.title ?? existing.title;
|
|
253
|
+
const amount = body.amount ?? existing.amount;
|
|
254
|
+
const stage = body.stage ?? existing.stage;
|
|
255
|
+
db.prepare('UPDATE deals SET title = ?, amount = ?, stage = ? WHERE id = ?').run(title, amount, stage, id);
|
|
256
|
+
return send(res, 200, getDeal(id));
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
return send(res, 404, { error: 'not found' });
|
|
261
|
+
} catch (e) {
|
|
262
|
+
return send(res, 500, { error: 'internal error: ' + (e?.message || String(e)) });
|
|
263
|
+
}
|
|
264
|
+
};
|
|
265
|
+
|
|
266
|
+
return http.createServer(handler);
|
|
267
|
+
}
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
import http from 'node:http';
|
|
2
|
+
import { DatabaseSync } from 'node:sqlite';
|
|
3
|
+
|
|
4
|
+
const STAGES = ['lead', 'qualified', 'won', 'lost'];
|
|
5
|
+
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
6
|
+
|
|
7
|
+
export function createApp(dbPath = ':memory:') {
|
|
8
|
+
const db = new DatabaseSync(dbPath);
|
|
9
|
+
db.exec('PRAGMA foreign_keys = ON;');
|
|
10
|
+
db.exec(`
|
|
11
|
+
CREATE TABLE IF NOT EXISTS contacts (
|
|
12
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
13
|
+
name TEXT NOT NULL,
|
|
14
|
+
email TEXT NOT NULL UNIQUE,
|
|
15
|
+
company TEXT,
|
|
16
|
+
created_at TEXT NOT NULL
|
|
17
|
+
);
|
|
18
|
+
CREATE TABLE IF NOT EXISTS deals (
|
|
19
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
20
|
+
contact_id INTEGER NOT NULL,
|
|
21
|
+
title TEXT NOT NULL,
|
|
22
|
+
amount REAL NOT NULL DEFAULT 0,
|
|
23
|
+
stage TEXT NOT NULL DEFAULT 'lead',
|
|
24
|
+
created_at TEXT NOT NULL,
|
|
25
|
+
FOREIGN KEY (contact_id) REFERENCES contacts(id) ON DELETE CASCADE
|
|
26
|
+
);
|
|
27
|
+
`);
|
|
28
|
+
|
|
29
|
+
const getContact = (id) => db.prepare('SELECT * FROM contacts WHERE id = ?').get(id) ?? null;
|
|
30
|
+
const getDeal = (id) => db.prepare('SELECT * FROM deals WHERE id = ?').get(id) ?? null;
|
|
31
|
+
|
|
32
|
+
function validateContact(b, requireAll) {
|
|
33
|
+
if (requireAll || b.name !== undefined) {
|
|
34
|
+
if (typeof b.name !== 'string' || b.name.trim() === '') return 'name is required';
|
|
35
|
+
}
|
|
36
|
+
if (requireAll || b.email !== undefined) {
|
|
37
|
+
if (typeof b.email !== 'string' || !EMAIL_RE.test(b.email)) return 'a valid email is required';
|
|
38
|
+
}
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
function validateDeal(b, requireAll) {
|
|
42
|
+
if (requireAll || b.title !== undefined) {
|
|
43
|
+
if (typeof b.title !== 'string' || b.title.trim() === '') return 'title is required';
|
|
44
|
+
}
|
|
45
|
+
if (b.amount !== undefined) {
|
|
46
|
+
if (typeof b.amount !== 'number' || Number.isNaN(b.amount) || b.amount < 0) return 'amount must be a number >= 0';
|
|
47
|
+
}
|
|
48
|
+
if (b.stage !== undefined) {
|
|
49
|
+
if (!STAGES.includes(b.stage)) return 'invalid stage';
|
|
50
|
+
}
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const send = (res, status, obj) => {
|
|
55
|
+
res.writeHead(status, { 'content-type': 'application/json' });
|
|
56
|
+
res.end(obj === undefined ? '' : JSON.stringify(obj));
|
|
57
|
+
};
|
|
58
|
+
const readBody = (req) =>
|
|
59
|
+
new Promise((resolve, reject) => {
|
|
60
|
+
let data = '';
|
|
61
|
+
req.on('data', (c) => (data += c));
|
|
62
|
+
req.on('end', () => {
|
|
63
|
+
if (!data) return resolve({});
|
|
64
|
+
try { resolve(JSON.parse(data)); } catch { reject(new Error('invalid json')); }
|
|
65
|
+
});
|
|
66
|
+
req.on('error', reject);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
const handler = async (req, res) => {
|
|
70
|
+
const url = new URL(req.url, 'http://localhost');
|
|
71
|
+
const parts = url.pathname.split('/').filter(Boolean);
|
|
72
|
+
const method = req.method;
|
|
73
|
+
|
|
74
|
+
try {
|
|
75
|
+
if (parts[0] === 'contacts') {
|
|
76
|
+
if (parts.length === 1 && method === 'POST') {
|
|
77
|
+
let body;
|
|
78
|
+
try { body = await readBody(req); } catch { return send(res, 400, { error: 'invalid json' }); }
|
|
79
|
+
const err = validateContact(body, true);
|
|
80
|
+
if (err) return send(res, 400, { error: err });
|
|
81
|
+
try {
|
|
82
|
+
const info = db
|
|
83
|
+
.prepare('INSERT INTO contacts (name, email, company, created_at) VALUES (?, ?, ?, ?)')
|
|
84
|
+
.run(body.name, body.email, body.company ?? null, new Date().toISOString());
|
|
85
|
+
return send(res, 201, getContact(Number(info.lastInsertRowid)));
|
|
86
|
+
} catch (e) {
|
|
87
|
+
if (String(e.message).includes('UNIQUE')) return send(res, 409, { error: 'email already exists' });
|
|
88
|
+
throw e;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
if (parts.length === 1 && method === 'GET') {
|
|
92
|
+
const q = url.searchParams.get('q');
|
|
93
|
+
const limit = Math.max(0, parseInt(url.searchParams.get('limit') ?? '50', 10) || 50);
|
|
94
|
+
const offset = Math.max(0, parseInt(url.searchParams.get('offset') ?? '0', 10) || 0);
|
|
95
|
+
let where = '';
|
|
96
|
+
const args = [];
|
|
97
|
+
if (q) { where = 'WHERE name LIKE ? OR email LIKE ?'; args.push('%' + q + '%', '%' + q + '%'); }
|
|
98
|
+
const total = db.prepare(`SELECT COUNT(*) AS c FROM contacts ${where}`).get(...args).c;
|
|
99
|
+
const data = db.prepare(`SELECT * FROM contacts ${where} ORDER BY id LIMIT ? OFFSET ?`).all(...args, limit, offset);
|
|
100
|
+
return send(res, 200, { data, total });
|
|
101
|
+
}
|
|
102
|
+
if (parts.length === 2) {
|
|
103
|
+
const id = Number(parts[1]);
|
|
104
|
+
const existing = getContact(id);
|
|
105
|
+
if (method === 'GET') return existing ? send(res, 200, existing) : send(res, 404, { error: 'not found' });
|
|
106
|
+
if (method === 'PATCH') {
|
|
107
|
+
if (!existing) return send(res, 404, { error: 'not found' });
|
|
108
|
+
let body;
|
|
109
|
+
try { body = await readBody(req); } catch { return send(res, 400, { error: 'invalid json' }); }
|
|
110
|
+
const err = validateContact(body, false);
|
|
111
|
+
if (err) return send(res, 400, { error: err });
|
|
112
|
+
const name = body.name ?? existing.name;
|
|
113
|
+
const email = body.email ?? existing.email;
|
|
114
|
+
const company = body.company !== undefined ? body.company : existing.company;
|
|
115
|
+
try {
|
|
116
|
+
db.prepare('UPDATE contacts SET name = ?, email = ?, company = ? WHERE id = ?').run(name, email, company, id);
|
|
117
|
+
} catch (e) {
|
|
118
|
+
if (String(e.message).includes('UNIQUE')) return send(res, 409, { error: 'email already exists' });
|
|
119
|
+
throw e;
|
|
120
|
+
}
|
|
121
|
+
return send(res, 200, getContact(id));
|
|
122
|
+
}
|
|
123
|
+
if (method === 'DELETE') {
|
|
124
|
+
if (!existing) return send(res, 404, { error: 'not found' });
|
|
125
|
+
db.prepare('DELETE FROM contacts WHERE id = ?').run(id);
|
|
126
|
+
return send(res, 204);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
if (parts.length === 3 && parts[2] === 'deals') {
|
|
130
|
+
const id = Number(parts[1]);
|
|
131
|
+
const contact = getContact(id);
|
|
132
|
+
if (!contact) return send(res, 404, { error: 'contact not found' });
|
|
133
|
+
if (method === 'POST') {
|
|
134
|
+
let body;
|
|
135
|
+
try { body = await readBody(req); } catch { return send(res, 400, { error: 'invalid json' }); }
|
|
136
|
+
const err = validateDeal(body, true);
|
|
137
|
+
if (err) return send(res, 400, { error: err });
|
|
138
|
+
const info = db
|
|
139
|
+
.prepare('INSERT INTO deals (contact_id, title, amount, stage, created_at) VALUES (?, ?, ?, ?, ?)')
|
|
140
|
+
.run(id, body.title, body.amount ?? 0, body.stage ?? 'lead', new Date().toISOString());
|
|
141
|
+
return send(res, 201, getDeal(Number(info.lastInsertRowid)));
|
|
142
|
+
}
|
|
143
|
+
if (method === 'GET') {
|
|
144
|
+
const data = db.prepare('SELECT * FROM deals WHERE contact_id = ? ORDER BY id').all(id);
|
|
145
|
+
return send(res, 200, { data, total: data.length });
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
if (parts[0] === 'deals') {
|
|
151
|
+
if (parts.length === 2 && parts[1] === 'summary' && method === 'GET') {
|
|
152
|
+
const summary = {};
|
|
153
|
+
for (const s of STAGES) summary[s] = { count: 0, totalAmount: 0 };
|
|
154
|
+
const rows = db
|
|
155
|
+
.prepare('SELECT stage, COUNT(*) AS count, COALESCE(SUM(amount), 0) AS totalAmount FROM deals GROUP BY stage')
|
|
156
|
+
.all();
|
|
157
|
+
for (const r of rows) summary[r.stage] = { count: r.count, totalAmount: r.totalAmount };
|
|
158
|
+
return send(res, 200, { summary });
|
|
159
|
+
}
|
|
160
|
+
if (parts.length === 1 && method === 'GET') {
|
|
161
|
+
const stage = url.searchParams.get('stage');
|
|
162
|
+
let where = '';
|
|
163
|
+
const args = [];
|
|
164
|
+
if (stage) { where = 'WHERE stage = ?'; args.push(stage); }
|
|
165
|
+
const data = db.prepare(`SELECT * FROM deals ${where} ORDER BY id`).all(...args);
|
|
166
|
+
return send(res, 200, { data, total: data.length });
|
|
167
|
+
}
|
|
168
|
+
if (parts.length === 2 && method === 'PATCH') {
|
|
169
|
+
const id = Number(parts[1]);
|
|
170
|
+
const existing = getDeal(id);
|
|
171
|
+
if (!existing) return send(res, 404, { error: 'not found' });
|
|
172
|
+
let body;
|
|
173
|
+
try { body = await readBody(req); } catch { return send(res, 400, { error: 'invalid json' }); }
|
|
174
|
+
const err = validateDeal(body, false);
|
|
175
|
+
if (err) return send(res, 400, { error: err });
|
|
176
|
+
const title = body.title ?? existing.title;
|
|
177
|
+
const amount = body.amount ?? existing.amount;
|
|
178
|
+
const stage = body.stage ?? existing.stage;
|
|
179
|
+
db.prepare('UPDATE deals SET title = ?, amount = ?, stage = ? WHERE id = ?').run(title, amount, stage, id);
|
|
180
|
+
return send(res, 200, getDeal(id));
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
return send(res, 404, { error: 'not found' });
|
|
185
|
+
} catch (e) {
|
|
186
|
+
return send(res, 500, { error: 'internal error: ' + (e?.message || String(e)) });
|
|
187
|
+
}
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
return http.createServer(handler);
|
|
191
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { test } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { createApp } from '../src/app.js';
|
|
4
|
+
|
|
5
|
+
async function withServer(run) {
|
|
6
|
+
const server = createApp(':memory:');
|
|
7
|
+
await new Promise((res) => server.listen(0, res));
|
|
8
|
+
const { port } = server.address();
|
|
9
|
+
try { await run(`http://127.0.0.1:${port}`); } finally { await new Promise((res) => server.close(res)); }
|
|
10
|
+
}
|
|
11
|
+
async function req(base, method, path, body) {
|
|
12
|
+
const init = { method, headers: {} };
|
|
13
|
+
if (body !== undefined) { init.headers['content-type'] = 'application/json'; init.body = JSON.stringify(body); }
|
|
14
|
+
const res = await fetch(base + path, init);
|
|
15
|
+
const text = await res.text();
|
|
16
|
+
let json = null; try { json = text ? JSON.parse(text) : null; } catch { json = null; }
|
|
17
|
+
return { status: res.status, body: json };
|
|
18
|
+
}
|
|
19
|
+
const mkContact = () => ({ name: 'A', email: `a${Math.random().toString(36).slice(2)}@x.com` });
|
|
20
|
+
|
|
21
|
+
test('bulk create inserts all deals (201) and returns them', () =>
|
|
22
|
+
withServer(async (base) => {
|
|
23
|
+
const c = await req(base, 'POST', '/contacts', mkContact());
|
|
24
|
+
const r = await req(base, 'POST', `/contacts/${c.body.id}/deals/bulk`, { deals: [{ title: 'a', amount: 1 }, { title: 'b', amount: 2 }, { title: 'c' }] });
|
|
25
|
+
assert.equal(r.status, 201);
|
|
26
|
+
assert.equal(r.body.data.length, 3);
|
|
27
|
+
assert.equal(r.body.total, 3);
|
|
28
|
+
}));
|
|
29
|
+
|
|
30
|
+
test('bulk for unknown contact → 404', () =>
|
|
31
|
+
withServer(async (base) => {
|
|
32
|
+
const r = await req(base, 'POST', '/contacts/99999/deals/bulk', { deals: [{ title: 'a' }] });
|
|
33
|
+
assert.equal(r.status, 404);
|
|
34
|
+
}));
|
|
35
|
+
|
|
36
|
+
test('bulk with empty array → 400', () =>
|
|
37
|
+
withServer(async (base) => {
|
|
38
|
+
const c = await req(base, 'POST', '/contacts', mkContact());
|
|
39
|
+
const r = await req(base, 'POST', `/contacts/${c.body.id}/deals/bulk`, { deals: [] });
|
|
40
|
+
assert.equal(r.status, 400);
|
|
41
|
+
}));
|
|
42
|
+
|
|
43
|
+
test('bulk with non-array deals → 400', () =>
|
|
44
|
+
withServer(async (base) => {
|
|
45
|
+
const c = await req(base, 'POST', '/contacts', mkContact());
|
|
46
|
+
const r = await req(base, 'POST', `/contacts/${c.body.id}/deals/bulk`, { deals: 'nope' });
|
|
47
|
+
assert.equal(r.status, 400);
|
|
48
|
+
}));
|
|
49
|
+
|
|
50
|
+
test('bulk with one invalid item → 400', () =>
|
|
51
|
+
withServer(async (base) => {
|
|
52
|
+
const c = await req(base, 'POST', '/contacts', mkContact());
|
|
53
|
+
const r = await req(base, 'POST', `/contacts/${c.body.id}/deals/bulk`, { deals: [{ title: 'ok' }, { title: '' }] });
|
|
54
|
+
assert.equal(r.status, 400);
|
|
55
|
+
}));
|
|
56
|
+
|
|
57
|
+
test('bulk is atomic — an invalid item inserts NOTHING', () =>
|
|
58
|
+
withServer(async (base) => {
|
|
59
|
+
const c = await req(base, 'POST', '/contacts', mkContact());
|
|
60
|
+
await req(base, 'POST', `/contacts/${c.body.id}/deals/bulk`, { deals: [{ title: 'ok', amount: 10 }, { title: 'bad', amount: -1 }] });
|
|
61
|
+
const list = await req(base, 'GET', `/contacts/${c.body.id}/deals`);
|
|
62
|
+
assert.equal(list.body.total, 0);
|
|
63
|
+
}));
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { test } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { createApp } from '../src/app.js';
|
|
4
|
+
|
|
5
|
+
async function withServer(run) {
|
|
6
|
+
const server = createApp(':memory:');
|
|
7
|
+
await new Promise((res) => server.listen(0, res));
|
|
8
|
+
const { port } = server.address();
|
|
9
|
+
try { await run(`http://127.0.0.1:${port}`); } finally { await new Promise((res) => server.close(res)); }
|
|
10
|
+
}
|
|
11
|
+
async function req(base, method, path, body, headers = {}) {
|
|
12
|
+
const init = { method, headers: { ...headers } };
|
|
13
|
+
if (body !== undefined) { init.headers['content-type'] = 'application/json'; init.body = JSON.stringify(body); }
|
|
14
|
+
const res = await fetch(base + path, init);
|
|
15
|
+
const text = await res.text();
|
|
16
|
+
let json = null; try { json = text ? JSON.parse(text) : null; } catch { json = null; }
|
|
17
|
+
return { status: res.status, body: json };
|
|
18
|
+
}
|
|
19
|
+
const mkContact = (over = {}) => ({ name: 'A', email: `a${Math.random().toString(36).slice(2)}@x.com`, ...over });
|
|
20
|
+
|
|
21
|
+
test('repeating a POST with the same Idempotency-Key does not create a duplicate', () =>
|
|
22
|
+
withServer(async (base) => {
|
|
23
|
+
const c = mkContact();
|
|
24
|
+
const first = await req(base, 'POST', '/contacts', c, { 'idempotency-key': 'abc' });
|
|
25
|
+
assert.equal(first.status, 201);
|
|
26
|
+
const second = await req(base, 'POST', '/contacts', c, { 'idempotency-key': 'abc' });
|
|
27
|
+
assert.equal(second.body.id, first.body.id);
|
|
28
|
+
const list = await req(base, 'GET', '/contacts');
|
|
29
|
+
assert.equal(list.body.total, 1);
|
|
30
|
+
}));
|
|
31
|
+
|
|
32
|
+
test('replay with the same key returns 200 (not a new 201)', () =>
|
|
33
|
+
withServer(async (base) => {
|
|
34
|
+
const c = mkContact();
|
|
35
|
+
await req(base, 'POST', '/contacts', c, { 'idempotency-key': 'k1' });
|
|
36
|
+
const replay = await req(base, 'POST', '/contacts', c, { 'idempotency-key': 'k1' });
|
|
37
|
+
assert.equal(replay.status, 200);
|
|
38
|
+
}));
|
|
39
|
+
|
|
40
|
+
test('different keys create different contacts', () =>
|
|
41
|
+
withServer(async (base) => {
|
|
42
|
+
await req(base, 'POST', '/contacts', mkContact(), { 'idempotency-key': 'k1' });
|
|
43
|
+
await req(base, 'POST', '/contacts', mkContact(), { 'idempotency-key': 'k2' });
|
|
44
|
+
const list = await req(base, 'GET', '/contacts');
|
|
45
|
+
assert.equal(list.body.total, 2);
|
|
46
|
+
}));
|
|
47
|
+
|
|
48
|
+
test('no key behaves normally — each POST creates', () =>
|
|
49
|
+
withServer(async (base) => {
|
|
50
|
+
await req(base, 'POST', '/contacts', mkContact());
|
|
51
|
+
await req(base, 'POST', '/contacts', mkContact());
|
|
52
|
+
const list = await req(base, 'GET', '/contacts');
|
|
53
|
+
assert.equal(list.body.total, 2);
|
|
54
|
+
}));
|
|
55
|
+
|
|
56
|
+
test('replayed key returns the original even if the body changed (no 409)', () =>
|
|
57
|
+
withServer(async (base) => {
|
|
58
|
+
const original = mkContact();
|
|
59
|
+
const first = await req(base, 'POST', '/contacts', original, { 'idempotency-key': 'same' });
|
|
60
|
+
const replay = await req(base, 'POST', '/contacts', mkContact(), { 'idempotency-key': 'same' });
|
|
61
|
+
assert.equal(replay.status, 200);
|
|
62
|
+
assert.equal(replay.body.id, first.body.id);
|
|
63
|
+
assert.equal(replay.body.email, original.email);
|
|
64
|
+
}));
|