@quantod/qq 1.1.19 → 1.2.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/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' | 'text';
12
-
13
- function processPayload(payload: string | undefined, format: PayloadFormat = 'yaml'): string | undefined {
14
- if (!payload) return payload;
15
- switch (format) {
16
- case 'yaml':
17
- try { return dump(load(payload, { json: true })).trimEnd(); }
18
- catch (e) { throw new QQError(`payload is not valid YAML: ${e instanceof Error ? e.message : String(e)}`); }
19
- case 'json':
20
- try { return JSON.stringify(JSON.parse(payload)); }
21
- catch (e) { throw new QQError(`payload is not valid JSON: ${e instanceof Error ? e.message : String(e)}`); }
22
- case 'text':
23
- return payload;
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?: string | null;
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>, includePayload: boolean): Item {
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 (includePayload) msg.payload = (row.payload as string | null) ?? null;
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 processedPayload = processPayload(payload, payloadFormat);
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, processedPayload ?? null, priority, now, now);
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
- // ── batchWrite / loadFile ─────────────────────────────────────────────────────
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
- export function batchWrite(
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.payload != null
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 * FROM messages WHERE pipeline = ? AND id = ?').get(pipeline, actualId) as Record<string, unknown> | undefined;
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 ?? null, recPriority, now, now);
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
- let newPayload: string | null;
265
- if (duplicates === 'append') {
266
- const existingPayload = (existing.payload as string | null)?.trimEnd() ?? null;
267
- newPayload = existingPayload && recPayload
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
- newPayload = recPayload ?? null;
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 loadFile(
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 = batchWrite(content, formatFromPath(filePath), pipeline, { stage, duplicates });
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
- 'SELECT * FROM messages WHERE pipeline = ? AND id = ?'
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
- `SELECT * FROM messages WHERE pipeline = ? AND subqueue LIKE ? AND claimed = 0 ORDER BY ${CLAIM_ORDER[strategy]} LIMIT 1`
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 }, true);
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 processedPayload = processPayload(payload, payloadFormat);
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('SELECT * FROM messages WHERE pipeline = ? AND seq = ?').get(pipeline, seq) as Record<string, unknown> | undefined;
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 ?? (row.subqueue as string);
384
- const newPriority = priority ?? (row.priority as number) ?? 0.0;
385
-
386
- let newPayload: string | null;
387
- if (processedPayload !== undefined) {
388
- newPayload = replace
389
- ? processedPayload
390
- : [(row.payload as string | null)?.trimEnd() ?? null, processedPayload].filter(Boolean).join('\n');
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
- newPayload = (row.payload as string | null) ?? null;
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
- // ── batchRead ─────────────────────────────────────────────────────────────────
568
+ // ── query ─────────────────────────────────────────────────────────────────────
443
569
 
444
- export function batchRead(pipeline: string, stage = '*', filters: FilterOptions = {}, includePayload = true, strategy: ClaimStrategy = 'fifo'): Item[] {
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
- let sql = `SELECT * FROM messages WHERE ${conditions.join(' AND ')} ORDER BY ${CLAIM_ORDER[strategy]}`;
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, includePayload));
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 TEXT,
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
- // Migration: add description column if missing (existing DBs pre-date this column)
42
- const cols = db.prepare("PRAGMA table_info(pipelines)").all() as { name: string }[];
43
- if (!cols.some(c => c.name === 'description')) {
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
- batchRead, status, unstick, loadFile, batchWrite, QQError,
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,8 +66,8 @@ 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, payloadFormat } = req.body ?? {};
69
- wrap(res, () => ({ id: push(req.params.pipeline, stage, id, { payload, priority, payloadFormat }) }));
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) => {
@@ -76,8 +77,8 @@ export async function startHttp(fixedPort?: number): Promise<HttpServer> {
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, payloadFormat } = req.body ?? {};
80
- wrap(res, () => { release(req.params.pipeline, seq, { target, payload, replace, priority, payloadFormat }); });
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 = '*', includePayload, claimed, ids, createdAfter, createdBefore, modifiedAfter, limit, offset, strategy } = req.query;
89
- wrap(res, () => batchRead(
90
- req.params.pipeline,
91
- String(stage),
92
- {
93
- claimed: claimed !== undefined ? String(claimed).toLowerCase() === 'true' : undefined,
94
- ids: ids ? String(ids).split(',') : undefined,
95
- created_after: createdAfter ? String(createdAfter) : undefined,
96
- created_before: createdBefore ? String(createdBefore) : undefined,
97
- modified_after: modifiedAfter ? String(modifiedAfter) : undefined,
98
- limit: limit ? Number(limit) : undefined,
99
- offset: offset ? Number(offset) : undefined,
100
- },
101
- String(includePayload).toLowerCase() === 'true',
102
- strategy ? String(strategy) as import('./commands.js').ClaimStrategy : undefined,
103
- ));
89
+ const { stage = '*', filter: filterStr, select: selectStr, claimed, ids, createdAfter, createdBefore, modifiedAfter, limit, offset, strategy } = 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('filter must be valid JSON'); }
95
+ }
96
+ let select: PayloadSelect | undefined;
97
+ if (selectStr) {
98
+ const s = String(selectStr);
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: createdAfter ? String(createdAfter) : undefined,
108
+ created_before: createdBefore ? String(createdBefore) : undefined,
109
+ modified_after: modifiedAfter ? String(modifiedAfter) : undefined,
110
+ limit: limit ? Number(limit) : undefined,
111
+ offset: offset ? Number(offset) : undefined,
112
+ filter,
113
+ select,
114
+ },
115
+ strategy ? String(strategy) as ClaimStrategy : undefined,
116
+ );
117
+ });
104
118
  });
105
119
 
106
- app.post('/pipelines/:pipeline/load', (req, res) => {
120
+ app.post('/pipelines/:pipeline/upload-file', (req, res) => {
107
121
  const { path: filePath, stage, deleteAfter, duplicates } = req.body ?? {};
108
122
  wrap(res, () => {
109
- const results = loadFile(filePath, req.params.pipeline, { stage, deleteAfter, duplicates });
123
+ const results = uploadFile(filePath, req.params.pipeline, { stage, deleteAfter, 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/batch-write', (req, res) => {
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 = batchWrite(data, format, req.params.pipeline, { stage, duplicates });
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 })) };