@quantod/qq 1.1.19 → 1.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/commands.d.ts +10 -7
- package/dist/commands.d.ts.map +1 -1
- package/dist/commands.js +204 -60
- package/dist/commands.js.map +1 -1
- package/dist/db.d.ts.map +1 -1
- package/dist/db.js +56 -4
- package/dist/db.js.map +1 -1
- package/dist/http.d.ts.map +1 -1
- package/dist/http.js +43 -25
- package/dist/http.js.map +1 -1
- package/dist/index.js +25 -12
- package/dist/index.js.map +1 -1
- package/dist/mcp.d.ts.map +1 -1
- package/dist/mcp.js +39 -40
- package/dist/mcp.js.map +1 -1
- package/dist/resources/chrome.md +3 -3
- package/dist/resources/guide.md +119 -116
- package/dist/sdk-templates/javascript.js +1 -1
- package/dist/sdk-templates/python.d.ts.map +1 -1
- package/dist/sdk-templates/python.js +5 -2
- package/dist/sdk-templates/python.js.map +1 -1
- package/package.json +1 -1
- package/src/commands.ts +196 -64
- package/src/db.ts +59 -4
- package/src/http.ts +46 -32
- package/src/index.ts +23 -14
- package/src/mcp.ts +40 -41
- package/src/resources/chrome.md +3 -3
- package/src/resources/guide.md +119 -116
- package/src/sdk-templates/javascript.ts +1 -1
- package/src/sdk-templates/python.ts +5 -2
package/src/commands.ts
CHANGED
|
@@ -8,22 +8,27 @@ import { openDb, inTransaction } from './db.js';
|
|
|
8
8
|
|
|
9
9
|
export * as yaml from 'js-yaml';
|
|
10
10
|
|
|
11
|
-
export type PayloadFormat = 'yaml' | 'json'
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
11
|
+
export type PayloadFormat = 'yaml' | 'json';
|
|
12
|
+
export type PayloadSelect = '*' | string[];
|
|
13
|
+
|
|
14
|
+
function parsePayload(input: string | object | undefined, format: PayloadFormat = 'yaml'): unknown {
|
|
15
|
+
if (input === undefined || input === null) return undefined;
|
|
16
|
+
if (typeof input !== 'string') return input;
|
|
17
|
+
if (format !== 'yaml' && format !== 'json')
|
|
18
|
+
throw new QQError(`unsupported payload format: "${String(format)}" — use 'yaml' or 'json'`);
|
|
19
|
+
if (format === 'json') {
|
|
20
|
+
try { return JSON.parse(input); }
|
|
21
|
+
catch (e) { throw new QQError(`payload is not valid JSON: ${e instanceof Error ? e.message : String(e)}`); }
|
|
22
|
+
} else {
|
|
23
|
+
try { return load(input, { json: true }); }
|
|
24
|
+
catch (e) { throw new QQError(`payload is not valid YAML: ${e instanceof Error ? e.message : String(e)}`); }
|
|
24
25
|
}
|
|
25
26
|
}
|
|
26
27
|
|
|
28
|
+
function toJsonArg(val: unknown): string | null {
|
|
29
|
+
return val === undefined || val === null ? null : JSON.stringify(val);
|
|
30
|
+
}
|
|
31
|
+
|
|
27
32
|
// ── types ──────────────────────────────────────────────────────────────────────
|
|
28
33
|
|
|
29
34
|
export class QQError extends Error {
|
|
@@ -41,7 +46,7 @@ export interface Item {
|
|
|
41
46
|
priority: number;
|
|
42
47
|
created: string;
|
|
43
48
|
last_modified: string;
|
|
44
|
-
payload?:
|
|
49
|
+
payload?: unknown;
|
|
45
50
|
}
|
|
46
51
|
|
|
47
52
|
export interface FilterOptions {
|
|
@@ -52,11 +57,100 @@ export interface FilterOptions {
|
|
|
52
57
|
modified_after?: string | number;
|
|
53
58
|
limit?: number;
|
|
54
59
|
offset?: number;
|
|
60
|
+
filter?: Record<string, unknown>;
|
|
61
|
+
select?: PayloadSelect;
|
|
55
62
|
}
|
|
56
63
|
|
|
57
64
|
|
|
58
65
|
// ── internal helpers ───────────────────────────────────────────────────────────
|
|
59
66
|
|
|
67
|
+
// SELECT that converts JSONB payload back to JSON text for parsing
|
|
68
|
+
const MSG_SELECT = 'SELECT *, json(payload) AS payload_text FROM messages';
|
|
69
|
+
|
|
70
|
+
function toSqlParam(v: unknown): string | number | null {
|
|
71
|
+
if (v === null || v === undefined) return null;
|
|
72
|
+
if (typeof v === 'boolean') return v ? 1 : 0;
|
|
73
|
+
if (typeof v === 'number') return v;
|
|
74
|
+
if (typeof v === 'string') return v;
|
|
75
|
+
return JSON.stringify(v);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function sanitizeJsonPath(key: string): string {
|
|
79
|
+
if (!/^[a-zA-Z0-9_.:-]+$/.test(key))
|
|
80
|
+
throw new QQError(`invalid filter key "${key}": use alphanumeric characters, dots, underscores, or hyphens`);
|
|
81
|
+
return `$.${key}`;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function buildPayloadFilter(filter: Record<string, unknown>): { conditions: string[]; params: (string | number | null)[] } {
|
|
85
|
+
const conditions: string[] = [];
|
|
86
|
+
const params: (string | number | null)[] = [];
|
|
87
|
+
|
|
88
|
+
for (const [key, value] of Object.entries(filter)) {
|
|
89
|
+
const path = sanitizeJsonPath(key);
|
|
90
|
+
const extract = `json_extract(payload, '${path}')`;
|
|
91
|
+
const numExtract = `CAST(${extract} AS REAL)`;
|
|
92
|
+
|
|
93
|
+
if (value !== null && typeof value === 'object' && !Array.isArray(value)) {
|
|
94
|
+
for (const [op, opVal] of Object.entries(value as Record<string, unknown>)) {
|
|
95
|
+
switch (op) {
|
|
96
|
+
case '$eq': conditions.push(`${extract} = ?`); params.push(toSqlParam(opVal)); break;
|
|
97
|
+
case '$ne': conditions.push(`${extract} != ?`); params.push(toSqlParam(opVal)); break;
|
|
98
|
+
case '$gt': conditions.push(`${numExtract} > ?`); params.push(toSqlParam(opVal)); break;
|
|
99
|
+
case '$gte': conditions.push(`${numExtract} >= ?`); params.push(toSqlParam(opVal)); break;
|
|
100
|
+
case '$lt': conditions.push(`${numExtract} < ?`); params.push(toSqlParam(opVal)); break;
|
|
101
|
+
case '$lte': conditions.push(`${numExtract} <= ?`); params.push(toSqlParam(opVal)); break;
|
|
102
|
+
case '$in':
|
|
103
|
+
if (!Array.isArray(opVal) || opVal.length === 0)
|
|
104
|
+
throw new QQError(`$in requires a non-empty array`);
|
|
105
|
+
conditions.push(`${extract} IN (${(opVal as unknown[]).map(() => '?').join(', ')})`);
|
|
106
|
+
params.push(...(opVal as unknown[]).map(toSqlParam));
|
|
107
|
+
break;
|
|
108
|
+
case '$exists':
|
|
109
|
+
conditions.push(`${extract} IS ${opVal ? 'NOT ' : ''}NULL`);
|
|
110
|
+
break;
|
|
111
|
+
default:
|
|
112
|
+
throw new QQError(`unsupported filter operator: ${op}`);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
} else if (value === null) {
|
|
116
|
+
conditions.push(`${extract} IS NULL`);
|
|
117
|
+
} else {
|
|
118
|
+
conditions.push(`${extract} = ?`);
|
|
119
|
+
params.push(toSqlParam(value));
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
return { conditions, params };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function projectPayload(payload: unknown, select: string[]): unknown {
|
|
127
|
+
if (payload === null || payload === undefined || typeof payload !== 'object' || Array.isArray(payload))
|
|
128
|
+
return payload;
|
|
129
|
+
const source = payload as Record<string, unknown>;
|
|
130
|
+
const result: Record<string, unknown> = {};
|
|
131
|
+
|
|
132
|
+
for (const path of select) {
|
|
133
|
+
const parts = path.split('.');
|
|
134
|
+
let src: unknown = source;
|
|
135
|
+
let dst = result;
|
|
136
|
+
let ok = true;
|
|
137
|
+
|
|
138
|
+
for (let i = 0; i < parts.length - 1; i++) {
|
|
139
|
+
const part = parts[i];
|
|
140
|
+
if (typeof src !== 'object' || src === null) { ok = false; break; }
|
|
141
|
+
src = (src as Record<string, unknown>)[part];
|
|
142
|
+
if (!(part in dst) || typeof dst[part] !== 'object') dst[part] = {};
|
|
143
|
+
dst = dst[part] as Record<string, unknown>;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
if (ok && typeof src === 'object' && src !== null) {
|
|
147
|
+
const lastPart = parts[parts.length - 1];
|
|
148
|
+
dst[lastPart] = (src as Record<string, unknown>)[lastPart];
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
return result;
|
|
153
|
+
}
|
|
60
154
|
|
|
61
155
|
function toGlob(pattern: string): string {
|
|
62
156
|
return pattern.replace(/\*/g, '%').replace(/\?/g, '_');
|
|
@@ -72,7 +166,7 @@ function nextSeq(db: Database.Database, pipeline: string): number {
|
|
|
72
166
|
return (row.m ?? 0) + 1;
|
|
73
167
|
}
|
|
74
168
|
|
|
75
|
-
function rowToItem(row: Record<string, unknown>,
|
|
169
|
+
function rowToItem(row: Record<string, unknown>, select?: PayloadSelect): Item {
|
|
76
170
|
const msg: Item = {
|
|
77
171
|
id: row.id as string,
|
|
78
172
|
stage: row.subqueue as string,
|
|
@@ -82,7 +176,11 @@ function rowToItem(row: Record<string, unknown>, includePayload: boolean): Item
|
|
|
82
176
|
created: new Date(row.created as number).toISOString(),
|
|
83
177
|
last_modified: new Date(row.last_modified as number).toISOString(),
|
|
84
178
|
};
|
|
85
|
-
if (
|
|
179
|
+
if (select !== undefined) {
|
|
180
|
+
const pt = row.payload_text as string | null;
|
|
181
|
+
const parsed = pt != null ? JSON.parse(pt) : null;
|
|
182
|
+
msg.payload = select === '*' ? parsed : projectPayload(parsed, select);
|
|
183
|
+
}
|
|
86
184
|
return msg;
|
|
87
185
|
}
|
|
88
186
|
|
|
@@ -137,13 +235,13 @@ export function deletePipeline(pipeline: string): void {
|
|
|
137
235
|
// ── push ──────────────────────────────────────────────────────────────────────
|
|
138
236
|
|
|
139
237
|
export interface PushOptions {
|
|
140
|
-
payload?: string;
|
|
238
|
+
payload?: string | object;
|
|
141
239
|
priority?: number;
|
|
142
240
|
payloadFormat?: PayloadFormat;
|
|
143
241
|
}
|
|
144
242
|
|
|
145
243
|
export function push(pipeline: string, stage = '', id?: string, { payload, priority = 0.0, payloadFormat = 'yaml' }: PushOptions = {}): string {
|
|
146
|
-
const
|
|
244
|
+
const parsedPayload = parsePayload(payload, payloadFormat);
|
|
147
245
|
const db = openDb();
|
|
148
246
|
try {
|
|
149
247
|
return inTransaction(db, () => {
|
|
@@ -154,8 +252,8 @@ export function push(pipeline: string, stage = '', id?: string, { payload, prior
|
|
|
154
252
|
if (existing) throw new QQError(`item ${actualId} already in the pipeline`);
|
|
155
253
|
const now = Date.now();
|
|
156
254
|
db.prepare(
|
|
157
|
-
'INSERT INTO messages (pipeline, id, subqueue, claimed, seq, payload, priority, created, last_modified) VALUES (?, ?, ?, 0, ?,
|
|
158
|
-
).run(pipeline, actualId, stage, seq,
|
|
255
|
+
'INSERT INTO messages (pipeline, id, subqueue, claimed, seq, payload, priority, created, last_modified) VALUES (?, ?, ?, 0, ?, jsonb(?), ?, ?, ?)'
|
|
256
|
+
).run(pipeline, actualId, stage, seq, toJsonArg(parsedPayload), priority, now, now);
|
|
159
257
|
return actualId;
|
|
160
258
|
});
|
|
161
259
|
} finally {
|
|
@@ -163,7 +261,7 @@ export function push(pipeline: string, stage = '', id?: string, { payload, prior
|
|
|
163
261
|
}
|
|
164
262
|
}
|
|
165
263
|
|
|
166
|
-
// ──
|
|
264
|
+
// ── uploadData / uploadFile ───────────────────────────────────────────────────
|
|
167
265
|
|
|
168
266
|
export type DuplicateMode = 'ignore' | 'append' | 'replace';
|
|
169
267
|
export type BatchWriteFormat = 'jsonl' | 'json' | 'yaml' | 'csv';
|
|
@@ -223,7 +321,27 @@ function parseRecords(data: string, format: BatchWriteFormat): Record<string, un
|
|
|
223
321
|
}
|
|
224
322
|
}
|
|
225
323
|
|
|
226
|
-
|
|
324
|
+
// For CSV: the whole row (minus reserved fields) becomes the payload.
|
|
325
|
+
// For other formats: use the rec.payload field; string values are parsed as YAML for backward compat.
|
|
326
|
+
const CSV_RESERVED = new Set(['id', 'stage', 'priority']);
|
|
327
|
+
|
|
328
|
+
function extractRecordPayload(rec: Record<string, unknown>, format: BatchWriteFormat): unknown {
|
|
329
|
+
if (format === 'csv') {
|
|
330
|
+
const result: Record<string, unknown> = {};
|
|
331
|
+
for (const [k, v] of Object.entries(rec)) {
|
|
332
|
+
if (!CSV_RESERVED.has(k)) result[k] = v;
|
|
333
|
+
}
|
|
334
|
+
return Object.keys(result).length > 0 ? result : undefined;
|
|
335
|
+
}
|
|
336
|
+
const p = rec.payload;
|
|
337
|
+
if (p === null || p === undefined) return undefined;
|
|
338
|
+
if (typeof p === 'string') {
|
|
339
|
+
try { return load(p, { json: true }); } catch { return { _text: p }; }
|
|
340
|
+
}
|
|
341
|
+
return p;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
export function uploadData(
|
|
227
345
|
data: string,
|
|
228
346
|
format: BatchWriteFormat,
|
|
229
347
|
pipeline: string,
|
|
@@ -239,21 +357,19 @@ export function batchWrite(
|
|
|
239
357
|
const recStage = typeof rec.stage === 'string' ? rec.stage : defaultStage;
|
|
240
358
|
const recId = rec.id !== undefined ? String(rec.id) : undefined;
|
|
241
359
|
const recPriority = rec.priority !== undefined ? Number(rec.priority) : 0.0;
|
|
242
|
-
const recPayload = rec
|
|
243
|
-
? (typeof rec.payload === 'string' ? rec.payload : dump(rec.payload))
|
|
244
|
-
: undefined;
|
|
360
|
+
const recPayload = extractRecordPayload(rec, format);
|
|
245
361
|
|
|
246
362
|
try {
|
|
247
363
|
const result = inTransaction(db, (): LoadFileResult => {
|
|
248
364
|
const seq = nextSeq(db, pipeline);
|
|
249
365
|
const actualId = recId ?? String(seq);
|
|
250
|
-
const existing = db.prepare('SELECT
|
|
366
|
+
const existing = db.prepare('SELECT id FROM messages WHERE pipeline = ? AND id = ?').get(pipeline, actualId);
|
|
251
367
|
const now = Date.now();
|
|
252
368
|
|
|
253
369
|
if (!existing) {
|
|
254
370
|
db.prepare(
|
|
255
|
-
'INSERT INTO messages (pipeline, id, subqueue, claimed, seq, payload, priority, created, last_modified) VALUES (?, ?, ?, 0, ?,
|
|
256
|
-
).run(pipeline, actualId, recStage, seq, recPayload
|
|
371
|
+
'INSERT INTO messages (pipeline, id, subqueue, claimed, seq, payload, priority, created, last_modified) VALUES (?, ?, ?, 0, ?, jsonb(?), ?, ?, ?)'
|
|
372
|
+
).run(pipeline, actualId, recStage, seq, toJsonArg(recPayload), recPriority, now, now);
|
|
257
373
|
return { id: actualId, status: 'new' };
|
|
258
374
|
}
|
|
259
375
|
|
|
@@ -261,19 +377,23 @@ export function batchWrite(
|
|
|
261
377
|
return { id: actualId, status: 'duplicate' };
|
|
262
378
|
}
|
|
263
379
|
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
? `${existingPayload}\n${recPayload}`
|
|
269
|
-
: (recPayload ?? existingPayload);
|
|
380
|
+
if (duplicates === 'replace') {
|
|
381
|
+
db.prepare(
|
|
382
|
+
'UPDATE messages SET claimed=0, seq=?, subqueue=?, payload=jsonb(?), last_modified=? WHERE pipeline=? AND id=?'
|
|
383
|
+
).run(seq, recStage, toJsonArg(recPayload), now, pipeline, actualId);
|
|
270
384
|
} else {
|
|
271
|
-
|
|
385
|
+
// 'append' → json_patch merge
|
|
386
|
+
if (recPayload !== undefined) {
|
|
387
|
+
const jsonNew = JSON.stringify(recPayload);
|
|
388
|
+
db.prepare(
|
|
389
|
+
'UPDATE messages SET claimed=0, seq=?, subqueue=?, payload=CASE WHEN payload IS NULL THEN jsonb(?) ELSE jsonb(json_patch(payload, ?)) END, last_modified=? WHERE pipeline=? AND id=?'
|
|
390
|
+
).run(seq, recStage, jsonNew, jsonNew, now, pipeline, actualId);
|
|
391
|
+
} else {
|
|
392
|
+
db.prepare(
|
|
393
|
+
'UPDATE messages SET claimed=0, seq=?, subqueue=?, last_modified=? WHERE pipeline=? AND id=?'
|
|
394
|
+
).run(seq, recStage, now, pipeline, actualId);
|
|
395
|
+
}
|
|
272
396
|
}
|
|
273
|
-
|
|
274
|
-
db.prepare(
|
|
275
|
-
'UPDATE messages SET claimed = 0, seq = ?, subqueue = ?, payload = ?, last_modified = ? WHERE pipeline = ? AND id = ?'
|
|
276
|
-
).run(seq, recStage, newPayload, now, pipeline, actualId);
|
|
277
397
|
return { id: actualId, status: 'duplicate' };
|
|
278
398
|
});
|
|
279
399
|
results.push(result);
|
|
@@ -288,14 +408,14 @@ export function batchWrite(
|
|
|
288
408
|
return results;
|
|
289
409
|
}
|
|
290
410
|
|
|
291
|
-
export function
|
|
411
|
+
export function uploadFile(
|
|
292
412
|
rawPath: string,
|
|
293
413
|
pipeline: string,
|
|
294
414
|
{ stage, deleteAfter = true, duplicates }: LoadFileOptions = {}
|
|
295
415
|
): LoadFileResult[] {
|
|
296
416
|
const filePath = expandEnvVars(rawPath);
|
|
297
417
|
const content = readFileSync(filePath, 'utf8');
|
|
298
|
-
const results =
|
|
418
|
+
const results = uploadData(content, formatFromPath(filePath), pipeline, { stage, duplicates });
|
|
299
419
|
if (deleteAfter && results.every(r => r.status !== 'error')) {
|
|
300
420
|
try { unlinkSync(filePath); } catch { /* best-effort */ }
|
|
301
421
|
}
|
|
@@ -335,13 +455,13 @@ export function claim(pipeline: string, stage: string, id?: string, { strategy =
|
|
|
335
455
|
|
|
336
456
|
if (id !== undefined) {
|
|
337
457
|
row = db.prepare(
|
|
338
|
-
|
|
458
|
+
`${MSG_SELECT} WHERE pipeline = ? AND id = ?`
|
|
339
459
|
).get(pipeline, id) as Record<string, unknown> | undefined;
|
|
340
460
|
if (!row) throw new QQError(`item ${id} not found`);
|
|
341
461
|
if (row.claimed) throw new QQError(`item ${id} already claimed`);
|
|
342
462
|
} else {
|
|
343
463
|
row = db.prepare(
|
|
344
|
-
|
|
464
|
+
`${MSG_SELECT} WHERE pipeline = ? AND subqueue LIKE ? AND claimed = 0 ORDER BY ${CLAIM_ORDER[strategy]} LIMIT 1`
|
|
345
465
|
).get(pipeline, toGlob(stage)) as Record<string, unknown> | undefined;
|
|
346
466
|
if (!row) throw new QQError('no items to claim');
|
|
347
467
|
}
|
|
@@ -351,7 +471,7 @@ export function claim(pipeline: string, stage: string, id?: string, { strategy =
|
|
|
351
471
|
'UPDATE messages SET claimed = 1, last_modified = ? WHERE pipeline = ? AND id = ?'
|
|
352
472
|
).run(now, pipeline, row.id as string);
|
|
353
473
|
|
|
354
|
-
return rowToItem({ ...row, claimed: 1 },
|
|
474
|
+
return rowToItem({ ...row, claimed: 1 }, '*');
|
|
355
475
|
});
|
|
356
476
|
} finally {
|
|
357
477
|
db.close();
|
|
@@ -362,39 +482,45 @@ export function claim(pipeline: string, stage: string, id?: string, { strategy =
|
|
|
362
482
|
|
|
363
483
|
export interface ReleaseOptions {
|
|
364
484
|
target?: string;
|
|
365
|
-
payload?: string;
|
|
485
|
+
payload?: string | object;
|
|
366
486
|
replace?: boolean;
|
|
367
487
|
priority?: number;
|
|
368
488
|
payloadFormat?: PayloadFormat;
|
|
369
489
|
}
|
|
370
490
|
|
|
371
491
|
export function release(pipeline: string, seq: number, { target, payload, replace, priority, payloadFormat = 'yaml' }: ReleaseOptions = {}): void {
|
|
372
|
-
const
|
|
492
|
+
const parsedPayload = parsePayload(payload, payloadFormat);
|
|
373
493
|
const db = openDb();
|
|
374
494
|
try {
|
|
375
495
|
inTransaction(db, () => {
|
|
376
496
|
assertPipelineExists(db, pipeline);
|
|
377
|
-
const row = db.prepare(
|
|
497
|
+
const row = db.prepare(
|
|
498
|
+
'SELECT claimed, subqueue, priority FROM messages WHERE pipeline = ? AND seq = ?'
|
|
499
|
+
).get(pipeline, seq) as { claimed: number; subqueue: string; priority: number } | undefined;
|
|
378
500
|
if (!row) throw new QQError(`claim on seq ${seq} expired`);
|
|
379
501
|
if (!row.claimed) throw new QQError(`item seq ${seq} not claimed`);
|
|
380
502
|
|
|
381
503
|
const newSeq = nextSeq(db, pipeline);
|
|
382
504
|
const now = Date.now();
|
|
383
|
-
const newStage = target ??
|
|
384
|
-
const newPriority = priority ??
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
505
|
+
const newStage = target ?? row.subqueue;
|
|
506
|
+
const newPriority = priority ?? row.priority ?? 0.0;
|
|
507
|
+
|
|
508
|
+
if (parsedPayload !== undefined) {
|
|
509
|
+
if (replace) {
|
|
510
|
+
db.prepare(
|
|
511
|
+
'UPDATE messages SET claimed=0, seq=?, subqueue=?, payload=jsonb(?), priority=?, last_modified=? WHERE pipeline=? AND seq=?'
|
|
512
|
+
).run(newSeq, newStage, toJsonArg(parsedPayload), newPriority, now, pipeline, seq);
|
|
513
|
+
} else {
|
|
514
|
+
const jsonNew = JSON.stringify(parsedPayload);
|
|
515
|
+
db.prepare(
|
|
516
|
+
'UPDATE messages SET claimed=0, seq=?, subqueue=?, payload=CASE WHEN payload IS NULL THEN jsonb(?) ELSE jsonb(json_patch(payload, ?)) END, priority=?, last_modified=? WHERE pipeline=? AND seq=?'
|
|
517
|
+
).run(newSeq, newStage, jsonNew, jsonNew, newPriority, now, pipeline, seq);
|
|
518
|
+
}
|
|
391
519
|
} else {
|
|
392
|
-
|
|
520
|
+
db.prepare(
|
|
521
|
+
'UPDATE messages SET claimed=0, seq=?, subqueue=?, priority=?, last_modified=? WHERE pipeline=? AND seq=?'
|
|
522
|
+
).run(newSeq, newStage, newPriority, now, pipeline, seq);
|
|
393
523
|
}
|
|
394
|
-
|
|
395
|
-
db.prepare(
|
|
396
|
-
'UPDATE messages SET claimed = 0, seq = ?, subqueue = ?, payload = ?, priority = ?, last_modified = ? WHERE pipeline = ? AND seq = ?'
|
|
397
|
-
).run(newSeq, newStage, newPayload, newPriority, now, pipeline, seq);
|
|
398
524
|
});
|
|
399
525
|
} finally {
|
|
400
526
|
db.close();
|
|
@@ -439,9 +565,9 @@ function buildFilterConditions(pipeline: string, stage: string, filters: FilterO
|
|
|
439
565
|
return { conditions, params };
|
|
440
566
|
}
|
|
441
567
|
|
|
442
|
-
// ──
|
|
568
|
+
// ── query ─────────────────────────────────────────────────────────────────────
|
|
443
569
|
|
|
444
|
-
export function
|
|
570
|
+
export function query(pipeline: string, stage = '*', filters: FilterOptions = {}, strategy: ClaimStrategy = 'fifo'): Item[] {
|
|
445
571
|
const db = openDb();
|
|
446
572
|
try {
|
|
447
573
|
assertPipelineExists(db, pipeline);
|
|
@@ -452,7 +578,13 @@ export function batchRead(pipeline: string, stage = '*', filters: FilterOptions
|
|
|
452
578
|
params.push(filters.claimed ? 1 : 0);
|
|
453
579
|
}
|
|
454
580
|
|
|
455
|
-
|
|
581
|
+
if (filters.filter) {
|
|
582
|
+
const { conditions: payloadConditions, params: payloadParams } = buildPayloadFilter(filters.filter);
|
|
583
|
+
conditions.push(...payloadConditions);
|
|
584
|
+
params.push(...payloadParams);
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
let sql = `${MSG_SELECT} WHERE ${conditions.join(' AND ')} ORDER BY ${CLAIM_ORDER[strategy]}`;
|
|
456
588
|
|
|
457
589
|
if (filters.limit !== undefined) {
|
|
458
590
|
sql += ' LIMIT ?';
|
|
@@ -466,7 +598,7 @@ export function batchRead(pipeline: string, stage = '*', filters: FilterOptions
|
|
|
466
598
|
}
|
|
467
599
|
|
|
468
600
|
const rows = db.prepare(sql).all(...params) as Record<string, unknown>[];
|
|
469
|
-
return rows.map(r => rowToItem(r,
|
|
601
|
+
return rows.map(r => rowToItem(r, filters.select));
|
|
470
602
|
} finally {
|
|
471
603
|
db.close();
|
|
472
604
|
}
|
package/src/db.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import Database from 'better-sqlite3';
|
|
2
2
|
import { mkdirSync } from 'node:fs';
|
|
3
3
|
import { join } from 'node:path';
|
|
4
|
+
import { load } from 'js-yaml';
|
|
4
5
|
|
|
5
6
|
const DDL = `
|
|
6
7
|
CREATE TABLE IF NOT EXISTS pipelines (
|
|
@@ -15,7 +16,7 @@ CREATE TABLE IF NOT EXISTS messages (
|
|
|
15
16
|
subqueue TEXT NOT NULL,
|
|
16
17
|
claimed INTEGER NOT NULL DEFAULT 0,
|
|
17
18
|
seq INTEGER NOT NULL,
|
|
18
|
-
payload
|
|
19
|
+
payload BLOB,
|
|
19
20
|
created INTEGER NOT NULL,
|
|
20
21
|
last_modified INTEGER NOT NULL,
|
|
21
22
|
priority REAL NOT NULL DEFAULT 0.0,
|
|
@@ -30,6 +31,57 @@ function dbDir(): string {
|
|
|
30
31
|
return join(process.env.HOME!, '.claude', 'qq');
|
|
31
32
|
}
|
|
32
33
|
|
|
34
|
+
function migratePayloadToJsonb(db: Database.Database): void {
|
|
35
|
+
const cols = db.prepare("PRAGMA table_info(messages)").all() as { name: string; type: string }[];
|
|
36
|
+
const payloadCol = cols.find(c => c.name === 'payload');
|
|
37
|
+
if (!payloadCol || payloadCol.type !== 'TEXT') return;
|
|
38
|
+
|
|
39
|
+
const rows = db.prepare('SELECT pipeline, id, payload FROM messages').all() as {
|
|
40
|
+
pipeline: string; id: string; payload: string | null;
|
|
41
|
+
}[];
|
|
42
|
+
|
|
43
|
+
db.transaction(() => {
|
|
44
|
+
db.prepare(`
|
|
45
|
+
CREATE TABLE messages_new (
|
|
46
|
+
id TEXT NOT NULL,
|
|
47
|
+
pipeline TEXT NOT NULL REFERENCES pipelines(id) ON DELETE CASCADE,
|
|
48
|
+
subqueue TEXT NOT NULL,
|
|
49
|
+
claimed INTEGER NOT NULL DEFAULT 0,
|
|
50
|
+
seq INTEGER NOT NULL,
|
|
51
|
+
payload BLOB,
|
|
52
|
+
created INTEGER NOT NULL,
|
|
53
|
+
last_modified INTEGER NOT NULL,
|
|
54
|
+
priority REAL NOT NULL DEFAULT 0.0,
|
|
55
|
+
PRIMARY KEY (pipeline, id)
|
|
56
|
+
)
|
|
57
|
+
`).run();
|
|
58
|
+
|
|
59
|
+
db.prepare(`
|
|
60
|
+
INSERT INTO messages_new (id, pipeline, subqueue, claimed, seq, payload, created, last_modified, priority)
|
|
61
|
+
SELECT id, pipeline, subqueue, claimed, seq, NULL, created, last_modified, priority
|
|
62
|
+
FROM messages
|
|
63
|
+
`).run();
|
|
64
|
+
|
|
65
|
+
const update = db.prepare('UPDATE messages_new SET payload = jsonb(?) WHERE pipeline = ? AND id = ?');
|
|
66
|
+
for (const row of rows) {
|
|
67
|
+
if (row.payload === null) continue;
|
|
68
|
+
let parsed: unknown;
|
|
69
|
+
try { parsed = JSON.parse(row.payload); }
|
|
70
|
+
catch {
|
|
71
|
+
try { parsed = load(row.payload, { json: true }); }
|
|
72
|
+
catch { parsed = { _text: row.payload }; }
|
|
73
|
+
}
|
|
74
|
+
update.run(JSON.stringify(parsed), row.pipeline, row.id);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
db.prepare('DROP TABLE messages').run();
|
|
78
|
+
db.prepare('ALTER TABLE messages_new RENAME TO messages').run();
|
|
79
|
+
db.prepare('CREATE UNIQUE INDEX idx_seq_unique ON messages (pipeline, seq)').run();
|
|
80
|
+
db.prepare('CREATE INDEX idx_claim ON messages (pipeline, subqueue, claimed, seq)').run();
|
|
81
|
+
db.prepare('CREATE INDEX idx_claim_pri ON messages (pipeline, subqueue, claimed, priority, seq)').run();
|
|
82
|
+
})();
|
|
83
|
+
}
|
|
84
|
+
|
|
33
85
|
export function openDb(): Database.Database {
|
|
34
86
|
const dir = dbDir();
|
|
35
87
|
mkdirSync(dir, { recursive: true });
|
|
@@ -38,11 +90,14 @@ export function openDb(): Database.Database {
|
|
|
38
90
|
db.pragma('busy_timeout = 5000');
|
|
39
91
|
db.pragma('foreign_keys = ON');
|
|
40
92
|
db.exec(DDL);
|
|
41
|
-
|
|
42
|
-
const
|
|
43
|
-
if (!
|
|
93
|
+
|
|
94
|
+
const pipCols = db.prepare("PRAGMA table_info(pipelines)").all() as { name: string }[];
|
|
95
|
+
if (!pipCols.some(c => c.name === 'description')) {
|
|
44
96
|
db.exec('ALTER TABLE pipelines ADD COLUMN description TEXT');
|
|
45
97
|
}
|
|
98
|
+
|
|
99
|
+
migratePayloadToJsonb(db);
|
|
100
|
+
|
|
46
101
|
return db;
|
|
47
102
|
}
|
|
48
103
|
|
package/src/http.ts
CHANGED
|
@@ -4,7 +4,8 @@ import { createServer } from 'node:net';
|
|
|
4
4
|
import { randomBytes } from 'node:crypto';
|
|
5
5
|
import {
|
|
6
6
|
makePipeline, deletePipeline, push, claim, release,
|
|
7
|
-
|
|
7
|
+
query, status, unstick, uploadFile, uploadData, QQError,
|
|
8
|
+
type PayloadSelect, type ClaimStrategy,
|
|
8
9
|
} from './commands.js';
|
|
9
10
|
|
|
10
11
|
export interface HttpServer {
|
|
@@ -65,19 +66,19 @@ export async function startHttp(fixedPort?: number): Promise<HttpServer> {
|
|
|
65
66
|
});
|
|
66
67
|
|
|
67
68
|
app.post('/pipelines/:pipeline/push', (req, res) => {
|
|
68
|
-
const { stage, id, payload, priority
|
|
69
|
-
wrap(res, () => ({ id: push(req.params.pipeline, stage, id, { payload, priority
|
|
69
|
+
const { stage, id, payload, priority } = req.body ?? {};
|
|
70
|
+
wrap(res, () => ({ id: push(req.params.pipeline, stage, id, { payload, priority }) }));
|
|
70
71
|
});
|
|
71
72
|
|
|
72
73
|
app.post('/pipelines/:pipeline/claim', (req, res) => {
|
|
73
|
-
const { stage = '*', id,
|
|
74
|
-
wrap(res, () => claim(req.params.pipeline, stage, id, { strategy }));
|
|
74
|
+
const { stage = '*', id, sort_order } = req.body ?? {};
|
|
75
|
+
wrap(res, () => claim(req.params.pipeline, stage, id, { strategy: sort_order }));
|
|
75
76
|
});
|
|
76
77
|
|
|
77
78
|
app.post('/pipelines/:pipeline/release/:seq', (req, res) => {
|
|
78
79
|
const seq = parseInt(req.params.seq, 10);
|
|
79
|
-
const { target, payload, replace, priority
|
|
80
|
-
wrap(res, () => { release(req.params.pipeline, seq, { target, payload, replace, priority
|
|
80
|
+
const { target, payload, replace, priority } = req.body ?? {};
|
|
81
|
+
wrap(res, () => { release(req.params.pipeline, seq, { target, payload, replace, priority }); });
|
|
81
82
|
});
|
|
82
83
|
|
|
83
84
|
app.get('/pipelines/:pipeline/status', (req, res) => {
|
|
@@ -85,38 +86,51 @@ export async function startHttp(fixedPort?: number): Promise<HttpServer> {
|
|
|
85
86
|
});
|
|
86
87
|
|
|
87
88
|
app.get('/pipelines/:pipeline/items', (req, res) => {
|
|
88
|
-
const { stage = '*',
|
|
89
|
-
wrap(res, () =>
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
89
|
+
const { stage = '*', payload_filter: filterStr, payload_fields: fieldsStr, claimed, ids, created_after, created_before, modified_after, limit, offset, sort_order } = req.query;
|
|
90
|
+
wrap(res, () => {
|
|
91
|
+
let filter: Record<string, unknown> | undefined;
|
|
92
|
+
if (filterStr) {
|
|
93
|
+
try { filter = JSON.parse(String(filterStr)); }
|
|
94
|
+
catch { throw new QQError('payload_filter must be valid JSON'); }
|
|
95
|
+
}
|
|
96
|
+
let select: PayloadSelect | undefined;
|
|
97
|
+
if (fieldsStr) {
|
|
98
|
+
const s = String(fieldsStr);
|
|
99
|
+
select = s === '*' ? '*' : s.split(',').map(f => f.trim()).filter(Boolean);
|
|
100
|
+
}
|
|
101
|
+
return query(
|
|
102
|
+
req.params.pipeline,
|
|
103
|
+
String(stage),
|
|
104
|
+
{
|
|
105
|
+
claimed: claimed !== undefined ? String(claimed).toLowerCase() === 'true' : undefined,
|
|
106
|
+
ids: ids ? String(ids).split(',') : undefined,
|
|
107
|
+
created_after: created_after ? String(created_after) : undefined,
|
|
108
|
+
created_before: created_before ? String(created_before) : undefined,
|
|
109
|
+
modified_after: modified_after ? String(modified_after) : undefined,
|
|
110
|
+
limit: limit ? Number(limit) : undefined,
|
|
111
|
+
offset: offset ? Number(offset) : undefined,
|
|
112
|
+
filter,
|
|
113
|
+
select,
|
|
114
|
+
},
|
|
115
|
+
sort_order ? String(sort_order) as ClaimStrategy : undefined,
|
|
116
|
+
);
|
|
117
|
+
});
|
|
104
118
|
});
|
|
105
119
|
|
|
106
|
-
app.post('/pipelines/:pipeline/
|
|
107
|
-
const { path: filePath, stage,
|
|
120
|
+
app.post('/pipelines/:pipeline/upload-file', (req, res) => {
|
|
121
|
+
const { path: filePath, stage, delete_after, duplicates } = req.body ?? {};
|
|
108
122
|
wrap(res, () => {
|
|
109
|
-
const results =
|
|
123
|
+
const results = uploadFile(filePath, req.params.pipeline, { stage, deleteAfter: delete_after, duplicates });
|
|
110
124
|
const counts = { new: 0, duplicate: 0, error: 0 };
|
|
111
125
|
for (const r of results) counts[r.status]++;
|
|
112
126
|
return { summary: counts, items: results.map(r => ({ [r.id]: r.message ? `${r.status}: ${r.message}` : r.status })) };
|
|
113
127
|
});
|
|
114
128
|
});
|
|
115
129
|
|
|
116
|
-
app.post('/pipelines/:pipeline/
|
|
130
|
+
app.post('/pipelines/:pipeline/upload-data', (req, res) => {
|
|
117
131
|
const { data, format, stage, duplicates } = req.body ?? {};
|
|
118
132
|
wrap(res, () => {
|
|
119
|
-
const results =
|
|
133
|
+
const results = uploadData(data, format, req.params.pipeline, { stage, duplicates });
|
|
120
134
|
const counts = { new: 0, duplicate: 0, error: 0 };
|
|
121
135
|
for (const r of results) counts[r.status]++;
|
|
122
136
|
return { summary: counts, items: results.map(r => ({ [r.id]: r.message ? `${r.status}: ${r.message}` : r.status })) };
|
|
@@ -124,13 +138,13 @@ export async function startHttp(fixedPort?: number): Promise<HttpServer> {
|
|
|
124
138
|
});
|
|
125
139
|
|
|
126
140
|
app.post('/pipelines/:pipeline/unstick', (req, res) => {
|
|
127
|
-
const { stage = '*', ids,
|
|
141
|
+
const { stage = '*', ids, created_after, created_before, modified_after } = req.body ?? {};
|
|
128
142
|
wrap(res, () => ({
|
|
129
143
|
unstuck: unstick(req.params.pipeline, stage, {
|
|
130
144
|
ids,
|
|
131
|
-
created_after
|
|
132
|
-
created_before
|
|
133
|
-
modified_after
|
|
145
|
+
created_after,
|
|
146
|
+
created_before,
|
|
147
|
+
modified_after,
|
|
134
148
|
}),
|
|
135
149
|
}));
|
|
136
150
|
});
|