openzca 0.1.50 → 0.1.51

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 CHANGED
@@ -19,7 +19,9 @@ Or run without installing:
19
19
  npx openzca --help
20
20
  ```
21
21
 
22
- Requires Node.js 18+.
22
+ Requires Node.js 22.13+.
23
+
24
+ The built-in DB backend now uses Node's official `node:sqlite` module, so no extra `sqlite3` native addon is installed.
23
25
 
24
26
  ## Quick start
25
27
 
package/dist/cli.js CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // src/cli.ts
4
- import { createRequire } from "module";
4
+ import { createRequire as createRequire2 } from "module";
5
5
  import { spawn as spawn2 } from "child_process";
6
6
  import fsSync from "fs";
7
7
  import fs6 from "fs/promises";
@@ -214,13 +214,273 @@ async function clearCache(profileName) {
214
214
  // src/lib/db.ts
215
215
  import crypto from "crypto";
216
216
  import fs2 from "fs/promises";
217
+ import { createRequire } from "module";
217
218
  import path2 from "path";
218
- import { open } from "sqlite";
219
- import sqlite3 from "sqlite3";
219
+ import { Worker } from "worker_threads";
220
+ var require2 = createRequire(import.meta.url);
221
+ function buildDbError(error) {
222
+ const built = new Error(error.message);
223
+ built.name = error.name || "Error";
224
+ built.stack = error.stack ?? built.stack;
225
+ built.code = error.code;
226
+ return built;
227
+ }
228
+ function resolveWorkerSpec() {
229
+ const currentUrl = new URL(import.meta.url);
230
+ if (currentUrl.pathname.endsWith("/src/lib/db.ts")) {
231
+ return {
232
+ url: new URL("./db-worker.ts", currentUrl),
233
+ execArgv: ["--import", require2.resolve("tsx")]
234
+ };
235
+ }
236
+ return {
237
+ url: new URL("./db-worker.js", currentUrl)
238
+ };
239
+ }
240
+ var Database = class _Database {
241
+ #worker;
242
+ #closed = false;
243
+ #closing = false;
244
+ #released = false;
245
+ #nextId = 1;
246
+ #activeRequests = 0;
247
+ #idleTimer;
248
+ #closePromise;
249
+ #pending = /* @__PURE__ */ new Map();
250
+ #ready;
251
+ #exited;
252
+ #releaseConnection;
253
+ constructor(worker, releaseConnection) {
254
+ this.#worker = worker;
255
+ this.#releaseConnection = releaseConnection;
256
+ let resolveReady;
257
+ let rejectReady;
258
+ this.#ready = new Promise((resolve, reject) => {
259
+ resolveReady = resolve;
260
+ rejectReady = reject;
261
+ });
262
+ let resolveExited;
263
+ this.#exited = new Promise((resolve) => {
264
+ resolveExited = resolve;
265
+ });
266
+ const rejectPending = (error) => {
267
+ for (const { reject } of this.#pending.values()) {
268
+ reject(error);
269
+ }
270
+ this.#pending.clear();
271
+ };
272
+ worker.on("message", (message) => {
273
+ if (message.type === "ready") {
274
+ resolveReady();
275
+ return;
276
+ }
277
+ if (message.type === "fatal") {
278
+ const error = buildDbError(message.error);
279
+ rejectReady(error);
280
+ rejectPending(error);
281
+ return;
282
+ }
283
+ const pending = this.#pending.get(message.id);
284
+ if (!pending) {
285
+ return;
286
+ }
287
+ this.#pending.delete(message.id);
288
+ if (message.type === "error") {
289
+ pending.reject(buildDbError(message.error));
290
+ return;
291
+ }
292
+ pending.resolve(message.result);
293
+ });
294
+ worker.once("error", (error) => {
295
+ rejectReady(error);
296
+ rejectPending(error instanceof Error ? error : new Error(String(error)));
297
+ });
298
+ worker.once("exit", (code) => {
299
+ this.#closed = true;
300
+ this.#release();
301
+ resolveExited();
302
+ const error = new Error(
303
+ code === 0 ? "DB worker exited" : `DB worker exited with code ${code}`
304
+ );
305
+ if (code !== 0) {
306
+ rejectReady(error);
307
+ }
308
+ if (this.#pending.size > 0) {
309
+ rejectPending(error);
310
+ }
311
+ });
312
+ }
313
+ static async open(filename, onClosed) {
314
+ const { url, execArgv } = resolveWorkerSpec();
315
+ const worker = new Worker(url, {
316
+ execArgv,
317
+ workerData: { filename }
318
+ });
319
+ worker.unref();
320
+ const db = new _Database(worker, onClosed);
321
+ await db.#ready;
322
+ return db;
323
+ }
324
+ get isClosing() {
325
+ return this.#closing || this.#closed;
326
+ }
327
+ #release() {
328
+ if (this.#released) {
329
+ return;
330
+ }
331
+ this.#released = true;
332
+ this.#releaseConnection();
333
+ }
334
+ #clearIdleClose() {
335
+ if (this.#idleTimer) {
336
+ clearTimeout(this.#idleTimer);
337
+ this.#idleTimer = void 0;
338
+ }
339
+ }
340
+ #scheduleIdleClose() {
341
+ this.#clearIdleClose();
342
+ this.#idleTimer = setTimeout(() => {
343
+ if (this.#activeRequests === 0 && !this.#closed) {
344
+ void this.close().catch(() => {
345
+ });
346
+ }
347
+ }, 100);
348
+ this.#idleTimer.unref();
349
+ }
350
+ async #request(type, payload) {
351
+ if (this.#closed) {
352
+ throw new Error("DB worker is closed");
353
+ }
354
+ if (this.#closing && type !== "close") {
355
+ throw new Error("DB worker is closing");
356
+ }
357
+ this.#clearIdleClose();
358
+ this.#activeRequests += 1;
359
+ await this.#ready;
360
+ const id = this.#nextId;
361
+ this.#nextId += 1;
362
+ const request = payload === void 0 ? { id, type } : { id, type, payload };
363
+ const result = new Promise((resolve, reject) => {
364
+ this.#pending.set(id, { resolve, reject });
365
+ });
366
+ try {
367
+ this.#worker.postMessage(request);
368
+ } catch (error) {
369
+ this.#pending.delete(id);
370
+ throw error;
371
+ }
372
+ try {
373
+ return await result;
374
+ } finally {
375
+ this.#activeRequests -= 1;
376
+ if (this.#activeRequests === 0 && !this.#closed && !this.#closing) {
377
+ this.#scheduleIdleClose();
378
+ }
379
+ }
380
+ }
381
+ async exec(sql) {
382
+ await this.#request("exec", { sql });
383
+ }
384
+ async run(sql, params = []) {
385
+ return await this.#request("run", { sql, params });
386
+ }
387
+ async get(sql, params = []) {
388
+ const result = await this.#request("get", { sql, params });
389
+ return result ?? void 0;
390
+ }
391
+ async all(sql, params = []) {
392
+ return await this.#request("all", { sql, params });
393
+ }
394
+ async batch(commands, transactional = false) {
395
+ await this.#request("batch", { commands, transactional });
396
+ }
397
+ async close() {
398
+ if (this.#closed) {
399
+ return;
400
+ }
401
+ if (this.#closePromise) {
402
+ return this.#closePromise;
403
+ }
404
+ this.#closePromise = (async () => {
405
+ this.#closing = true;
406
+ this.#release();
407
+ this.#clearIdleClose();
408
+ await this.#request("close");
409
+ this.#closed = true;
410
+ await this.#exited;
411
+ })();
412
+ return this.#closePromise;
413
+ }
414
+ };
220
415
  var DB_CONFIG_FILE = "db.json";
221
416
  var DB_FILENAME = "messages.sqlite";
222
417
  var connections = /* @__PURE__ */ new Map();
223
418
  var writeQueues = /* @__PURE__ */ new Map();
419
+ var UPSERT_THREAD_SQL = `
420
+ INSERT INTO threads (
421
+ profile, scope_thread_id, raw_thread_id, thread_type, peer_id, title,
422
+ is_pinned, is_hidden, is_archived, raw_json, created_at, updated_at
423
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
424
+ ON CONFLICT(profile, scope_thread_id) DO UPDATE SET
425
+ raw_thread_id = excluded.raw_thread_id,
426
+ thread_type = excluded.thread_type,
427
+ peer_id = COALESCE(excluded.peer_id, threads.peer_id),
428
+ title = COALESCE(excluded.title, threads.title),
429
+ is_pinned = excluded.is_pinned,
430
+ is_hidden = excluded.is_hidden,
431
+ is_archived = excluded.is_archived,
432
+ raw_json = COALESCE(excluded.raw_json, threads.raw_json),
433
+ updated_at = excluded.updated_at
434
+ `;
435
+ var INSERT_THREAD_MEMBER_SQL = `
436
+ INSERT INTO thread_members (
437
+ profile, scope_thread_id, user_id, display_name, zalo_name, avatar,
438
+ account_status, member_type, raw_json, snapshot_at_ms, created_at, updated_at
439
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
440
+ `;
441
+ var UPSERT_MESSAGE_SQL = `
442
+ INSERT INTO messages (
443
+ profile, message_uid, scope_thread_id, raw_thread_id, thread_type,
444
+ msg_id, cli_msg_id, action_id, sender_id, sender_name, to_id,
445
+ timestamp_ms, msg_type, content_text, content_json,
446
+ quote_msg_id, quote_cli_msg_id, quote_owner_id, quote_text,
447
+ source, raw_message_json, raw_payload_json, created_at, updated_at
448
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
449
+ ON CONFLICT(profile, message_uid) DO UPDATE SET
450
+ scope_thread_id = excluded.scope_thread_id,
451
+ raw_thread_id = excluded.raw_thread_id,
452
+ thread_type = excluded.thread_type,
453
+ msg_id = COALESCE(excluded.msg_id, messages.msg_id),
454
+ cli_msg_id = COALESCE(excluded.cli_msg_id, messages.cli_msg_id),
455
+ action_id = COALESCE(excluded.action_id, messages.action_id),
456
+ sender_id = COALESCE(excluded.sender_id, messages.sender_id),
457
+ sender_name = COALESCE(excluded.sender_name, messages.sender_name),
458
+ to_id = COALESCE(excluded.to_id, messages.to_id),
459
+ timestamp_ms = excluded.timestamp_ms,
460
+ msg_type = COALESCE(excluded.msg_type, messages.msg_type),
461
+ content_text = COALESCE(excluded.content_text, messages.content_text),
462
+ content_json = COALESCE(excluded.content_json, messages.content_json),
463
+ quote_msg_id = COALESCE(excluded.quote_msg_id, messages.quote_msg_id),
464
+ quote_cli_msg_id = COALESCE(excluded.quote_cli_msg_id, messages.quote_cli_msg_id),
465
+ quote_owner_id = COALESCE(excluded.quote_owner_id, messages.quote_owner_id),
466
+ quote_text = COALESCE(excluded.quote_text, messages.quote_text),
467
+ source = excluded.source,
468
+ raw_message_json = COALESCE(excluded.raw_message_json, messages.raw_message_json),
469
+ raw_payload_json = COALESCE(excluded.raw_payload_json, messages.raw_payload_json),
470
+ updated_at = excluded.updated_at
471
+ `;
472
+ var INSERT_MESSAGE_MEDIA_SQL = `
473
+ INSERT INTO message_media (
474
+ profile, message_uid, item_index, media_kind, media_url,
475
+ media_path, media_type, raw_json, created_at, updated_at
476
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
477
+ `;
478
+ var INSERT_MESSAGE_MENTION_SQL = `
479
+ INSERT INTO message_mentions (
480
+ profile, message_uid, item_index, target_user_id, pos, len,
481
+ mention_type, raw_json, created_at, updated_at
482
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
483
+ `;
224
484
  function nowIso2() {
225
485
  return (/* @__PURE__ */ new Date()).toISOString();
226
486
  }
@@ -326,165 +586,21 @@ async function resolveDbPath(profile) {
326
586
  }
327
587
  return path2.isAbsolute(configured) ? configured : path2.resolve(getProfileDir(profile), configured);
328
588
  }
329
- async function migrateDb(db) {
330
- await db.exec(`
331
- PRAGMA journal_mode = WAL;
332
- PRAGMA foreign_keys = ON;
333
-
334
- CREATE TABLE IF NOT EXISTS threads (
335
- profile TEXT NOT NULL,
336
- scope_thread_id TEXT NOT NULL,
337
- raw_thread_id TEXT NOT NULL,
338
- thread_type TEXT NOT NULL,
339
- peer_id TEXT,
340
- title TEXT,
341
- is_pinned INTEGER NOT NULL DEFAULT 0,
342
- is_hidden INTEGER NOT NULL DEFAULT 0,
343
- is_archived INTEGER NOT NULL DEFAULT 0,
344
- raw_json TEXT,
345
- created_at TEXT NOT NULL,
346
- updated_at TEXT NOT NULL,
347
- PRIMARY KEY (profile, scope_thread_id)
348
- );
349
-
350
- CREATE TABLE IF NOT EXISTS thread_members (
351
- profile TEXT NOT NULL,
352
- scope_thread_id TEXT NOT NULL,
353
- user_id TEXT NOT NULL,
354
- display_name TEXT,
355
- zalo_name TEXT,
356
- avatar TEXT,
357
- account_status INTEGER,
358
- member_type INTEGER,
359
- raw_json TEXT,
360
- snapshot_at_ms INTEGER NOT NULL,
361
- created_at TEXT NOT NULL,
362
- updated_at TEXT NOT NULL,
363
- PRIMARY KEY (profile, scope_thread_id, user_id)
364
- );
365
-
366
- CREATE TABLE IF NOT EXISTS friends (
367
- profile TEXT NOT NULL,
368
- user_id TEXT NOT NULL,
369
- display_name TEXT,
370
- zalo_name TEXT,
371
- avatar TEXT,
372
- account_status INTEGER,
373
- raw_json TEXT,
374
- created_at TEXT NOT NULL,
375
- updated_at TEXT NOT NULL,
376
- PRIMARY KEY (profile, user_id)
377
- );
378
-
379
- CREATE TABLE IF NOT EXISTS self_profiles (
380
- profile TEXT NOT NULL,
381
- user_id TEXT NOT NULL,
382
- display_name TEXT,
383
- info_json TEXT,
384
- created_at TEXT NOT NULL,
385
- updated_at TEXT NOT NULL,
386
- PRIMARY KEY (profile)
387
- );
388
-
389
- CREATE TABLE IF NOT EXISTS messages (
390
- profile TEXT NOT NULL,
391
- message_uid TEXT NOT NULL,
392
- scope_thread_id TEXT NOT NULL,
393
- raw_thread_id TEXT NOT NULL,
394
- thread_type TEXT NOT NULL,
395
- msg_id TEXT,
396
- cli_msg_id TEXT,
397
- action_id TEXT,
398
- sender_id TEXT,
399
- sender_name TEXT,
400
- to_id TEXT,
401
- timestamp_ms INTEGER NOT NULL,
402
- msg_type TEXT,
403
- content_text TEXT,
404
- content_json TEXT,
405
- quote_msg_id TEXT,
406
- quote_cli_msg_id TEXT,
407
- quote_owner_id TEXT,
408
- quote_text TEXT,
409
- source TEXT NOT NULL,
410
- raw_message_json TEXT,
411
- raw_payload_json TEXT,
412
- created_at TEXT NOT NULL,
413
- updated_at TEXT NOT NULL,
414
- PRIMARY KEY (profile, message_uid)
415
- );
416
-
417
- CREATE TABLE IF NOT EXISTS message_media (
418
- profile TEXT NOT NULL,
419
- message_uid TEXT NOT NULL,
420
- item_index INTEGER NOT NULL,
421
- media_kind TEXT,
422
- media_url TEXT,
423
- media_path TEXT,
424
- media_type TEXT,
425
- raw_json TEXT,
426
- created_at TEXT NOT NULL,
427
- updated_at TEXT NOT NULL,
428
- PRIMARY KEY (profile, message_uid, item_index)
429
- );
430
-
431
- CREATE TABLE IF NOT EXISTS message_mentions (
432
- profile TEXT NOT NULL,
433
- message_uid TEXT NOT NULL,
434
- item_index INTEGER NOT NULL,
435
- target_user_id TEXT NOT NULL,
436
- pos INTEGER,
437
- len INTEGER,
438
- mention_type INTEGER,
439
- raw_json TEXT,
440
- created_at TEXT NOT NULL,
441
- updated_at TEXT NOT NULL,
442
- PRIMARY KEY (profile, message_uid, item_index)
443
- );
444
-
445
- CREATE TABLE IF NOT EXISTS sync_state (
446
- profile TEXT NOT NULL,
447
- scope TEXT NOT NULL,
448
- scope_thread_id TEXT NOT NULL,
449
- thread_type TEXT NOT NULL,
450
- status TEXT NOT NULL,
451
- completeness TEXT,
452
- cursor TEXT,
453
- last_sync_at TEXT,
454
- error TEXT,
455
- created_at TEXT NOT NULL,
456
- updated_at TEXT NOT NULL,
457
- PRIMARY KEY (profile, scope)
458
- );
459
-
460
- CREATE INDEX IF NOT EXISTS idx_messages_thread_time
461
- ON messages (profile, scope_thread_id, timestamp_ms DESC);
462
- CREATE INDEX IF NOT EXISTS idx_messages_msg_id
463
- ON messages (profile, msg_id);
464
- CREATE INDEX IF NOT EXISTS idx_messages_cli_msg_id
465
- ON messages (profile, cli_msg_id);
466
- CREATE INDEX IF NOT EXISTS idx_threads_type
467
- ON threads (profile, thread_type, updated_at DESC);
468
- CREATE INDEX IF NOT EXISTS idx_members_thread
469
- ON thread_members (profile, scope_thread_id);
470
- CREATE INDEX IF NOT EXISTS idx_friends_name
471
- ON friends (profile, display_name, zalo_name, user_id);
472
- `);
473
- }
474
589
  async function openDb(profile) {
475
590
  const filename = await resolveDbPath(profile);
476
591
  await fs2.mkdir(path2.dirname(filename), { recursive: true });
477
- const db = await open({
478
- filename,
479
- driver: sqlite3.Database
592
+ return Database.open(filename, () => {
593
+ connections.delete(profile);
480
594
  });
481
- await migrateDb(db);
482
- return db;
483
595
  }
484
596
  async function getDb(profile) {
485
597
  const existing = connections.get(profile);
486
598
  if (existing) {
487
- return existing;
599
+ const db = await existing;
600
+ if (!db.isClosing) {
601
+ return db;
602
+ }
603
+ connections.delete(profile);
488
604
  }
489
605
  const created = openDb(profile).catch((error) => {
490
606
  connections.delete(profile);
@@ -596,77 +712,48 @@ function enqueueDbWrite(profile, task) {
596
712
  async function persistThread(record) {
597
713
  const db = await getDb(record.profile);
598
714
  const now = nowIso2();
599
- await db.run(
600
- `
601
- INSERT INTO threads (
602
- profile, scope_thread_id, raw_thread_id, thread_type, peer_id, title,
603
- is_pinned, is_hidden, is_archived, raw_json, created_at, updated_at
604
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
605
- ON CONFLICT(profile, scope_thread_id) DO UPDATE SET
606
- raw_thread_id = excluded.raw_thread_id,
607
- thread_type = excluded.thread_type,
608
- peer_id = COALESCE(excluded.peer_id, threads.peer_id),
609
- title = COALESCE(excluded.title, threads.title),
610
- is_pinned = excluded.is_pinned,
611
- is_hidden = excluded.is_hidden,
612
- is_archived = excluded.is_archived,
613
- raw_json = COALESCE(excluded.raw_json, threads.raw_json),
614
- updated_at = excluded.updated_at
615
- `,
616
- [
617
- record.profile,
618
- record.scopeThreadId,
619
- record.rawThreadId,
620
- record.threadType,
621
- record.peerId ?? null,
622
- record.title ?? null,
623
- record.isPinned ? 1 : 0,
624
- record.isHidden ? 1 : 0,
625
- record.isArchived ? 1 : 0,
626
- record.rawJson ?? null,
627
- now,
628
- now
629
- ]
630
- );
715
+ await db.run(UPSERT_THREAD_SQL, [
716
+ record.profile,
717
+ record.scopeThreadId,
718
+ record.rawThreadId,
719
+ record.threadType,
720
+ record.peerId ?? null,
721
+ record.title ?? null,
722
+ record.isPinned ? 1 : 0,
723
+ record.isHidden ? 1 : 0,
724
+ record.isArchived ? 1 : 0,
725
+ record.rawJson ?? null,
726
+ now,
727
+ now
728
+ ]);
631
729
  }
632
730
  async function replaceThreadMembers(profile, scopeThreadId, members) {
633
731
  const db = await getDb(profile);
634
732
  const now = nowIso2();
635
- await db.exec("BEGIN");
636
- try {
637
- await db.run(
638
- `DELETE FROM thread_members WHERE profile = ? AND scope_thread_id = ?`,
639
- [profile, scopeThreadId]
640
- );
641
- for (const member of members) {
642
- await db.run(
643
- `
644
- INSERT INTO thread_members (
645
- profile, scope_thread_id, user_id, display_name, zalo_name, avatar,
646
- account_status, member_type, raw_json, snapshot_at_ms, created_at, updated_at
647
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
648
- `,
649
- [
650
- member.profile,
651
- member.scopeThreadId,
652
- member.userId,
653
- member.displayName ?? null,
654
- member.zaloName ?? null,
655
- member.avatar ?? null,
656
- member.accountStatus ?? null,
657
- member.memberType ?? null,
658
- member.rawJson ?? null,
659
- member.snapshotAtMs,
660
- now,
661
- now
662
- ]
663
- );
664
- }
665
- await db.exec("COMMIT");
666
- } catch (error) {
667
- await db.exec("ROLLBACK");
668
- throw error;
669
- }
733
+ const commands = [
734
+ {
735
+ sql: `DELETE FROM thread_members WHERE profile = ? AND scope_thread_id = ?`,
736
+ params: [profile, scopeThreadId]
737
+ },
738
+ ...members.map((member) => ({
739
+ sql: INSERT_THREAD_MEMBER_SQL,
740
+ params: [
741
+ member.profile,
742
+ member.scopeThreadId,
743
+ member.userId,
744
+ member.displayName ?? null,
745
+ member.zaloName ?? null,
746
+ member.avatar ?? null,
747
+ member.accountStatus ?? null,
748
+ member.memberType ?? null,
749
+ member.rawJson ?? null,
750
+ member.snapshotAtMs,
751
+ now,
752
+ now
753
+ ]
754
+ }))
755
+ ];
756
+ await db.batch(commands, true);
670
757
  }
671
758
  async function persistFriend(record) {
672
759
  const db = await getDb(record.profile);
@@ -726,49 +813,27 @@ async function persistMessage(record) {
726
813
  const db = await getDb(record.profile);
727
814
  const now = nowIso2();
728
815
  const messageUid = toMessageUid(record);
729
- await db.exec("BEGIN");
730
- try {
731
- await persistThread({
732
- profile: record.profile,
733
- scopeThreadId: record.scopeThreadId,
734
- rawThreadId: record.rawThreadId,
735
- threadType: record.threadType,
736
- peerId: record.peerId,
737
- title: record.title
738
- });
739
- await db.run(
740
- `
741
- INSERT INTO messages (
742
- profile, message_uid, scope_thread_id, raw_thread_id, thread_type,
743
- msg_id, cli_msg_id, action_id, sender_id, sender_name, to_id,
744
- timestamp_ms, msg_type, content_text, content_json,
745
- quote_msg_id, quote_cli_msg_id, quote_owner_id, quote_text,
746
- source, raw_message_json, raw_payload_json, created_at, updated_at
747
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
748
- ON CONFLICT(profile, message_uid) DO UPDATE SET
749
- scope_thread_id = excluded.scope_thread_id,
750
- raw_thread_id = excluded.raw_thread_id,
751
- thread_type = excluded.thread_type,
752
- msg_id = COALESCE(excluded.msg_id, messages.msg_id),
753
- cli_msg_id = COALESCE(excluded.cli_msg_id, messages.cli_msg_id),
754
- action_id = COALESCE(excluded.action_id, messages.action_id),
755
- sender_id = COALESCE(excluded.sender_id, messages.sender_id),
756
- sender_name = COALESCE(excluded.sender_name, messages.sender_name),
757
- to_id = COALESCE(excluded.to_id, messages.to_id),
758
- timestamp_ms = excluded.timestamp_ms,
759
- msg_type = COALESCE(excluded.msg_type, messages.msg_type),
760
- content_text = COALESCE(excluded.content_text, messages.content_text),
761
- content_json = COALESCE(excluded.content_json, messages.content_json),
762
- quote_msg_id = COALESCE(excluded.quote_msg_id, messages.quote_msg_id),
763
- quote_cli_msg_id = COALESCE(excluded.quote_cli_msg_id, messages.quote_cli_msg_id),
764
- quote_owner_id = COALESCE(excluded.quote_owner_id, messages.quote_owner_id),
765
- quote_text = COALESCE(excluded.quote_text, messages.quote_text),
766
- source = excluded.source,
767
- raw_message_json = COALESCE(excluded.raw_message_json, messages.raw_message_json),
768
- raw_payload_json = COALESCE(excluded.raw_payload_json, messages.raw_payload_json),
769
- updated_at = excluded.updated_at
770
- `,
771
- [
816
+ const commands = [
817
+ {
818
+ sql: UPSERT_THREAD_SQL,
819
+ params: [
820
+ record.profile,
821
+ record.scopeThreadId,
822
+ record.rawThreadId,
823
+ record.threadType,
824
+ record.peerId ?? null,
825
+ record.title ?? null,
826
+ 0,
827
+ 0,
828
+ 0,
829
+ null,
830
+ now,
831
+ now
832
+ ]
833
+ },
834
+ {
835
+ sql: UPSERT_MESSAGE_SQL,
836
+ params: [
772
837
  record.profile,
773
838
  messageUid,
774
839
  record.scopeThreadId,
@@ -794,64 +859,47 @@ async function persistMessage(record) {
794
859
  now,
795
860
  now
796
861
  ]
797
- );
798
- await db.run(
799
- `DELETE FROM message_media WHERE profile = ? AND message_uid = ?`,
800
- [record.profile, messageUid]
801
- );
802
- await db.run(
803
- `DELETE FROM message_mentions WHERE profile = ? AND message_uid = ?`,
804
- [record.profile, messageUid]
805
- );
806
- for (const [index, media] of (record.media ?? []).entries()) {
807
- await db.run(
808
- `
809
- INSERT INTO message_media (
810
- profile, message_uid, item_index, media_kind, media_url,
811
- media_path, media_type, raw_json, created_at, updated_at
812
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
813
- `,
814
- [
815
- record.profile,
816
- messageUid,
817
- index,
818
- media.mediaKind ?? null,
819
- media.mediaUrl ?? null,
820
- media.mediaPath ?? null,
821
- media.mediaType ?? null,
822
- media.rawJson ?? null,
823
- now,
824
- now
825
- ]
826
- );
827
- }
828
- for (const [index, mention] of (record.mentions ?? []).entries()) {
829
- await db.run(
830
- `
831
- INSERT INTO message_mentions (
832
- profile, message_uid, item_index, target_user_id, pos, len,
833
- mention_type, raw_json, created_at, updated_at
834
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
835
- `,
836
- [
837
- record.profile,
838
- messageUid,
839
- index,
840
- mention.uid,
841
- mention.pos ?? null,
842
- mention.len ?? null,
843
- mention.type ?? null,
844
- mention.rawJson ?? null,
845
- now,
846
- now
847
- ]
848
- );
849
- }
850
- await db.exec("COMMIT");
851
- } catch (error) {
852
- await db.exec("ROLLBACK");
853
- throw error;
854
- }
862
+ },
863
+ {
864
+ sql: `DELETE FROM message_media WHERE profile = ? AND message_uid = ?`,
865
+ params: [record.profile, messageUid]
866
+ },
867
+ {
868
+ sql: `DELETE FROM message_mentions WHERE profile = ? AND message_uid = ?`,
869
+ params: [record.profile, messageUid]
870
+ },
871
+ ...(record.media ?? []).map((media, index) => ({
872
+ sql: INSERT_MESSAGE_MEDIA_SQL,
873
+ params: [
874
+ record.profile,
875
+ messageUid,
876
+ index,
877
+ media.mediaKind ?? null,
878
+ media.mediaUrl ?? null,
879
+ media.mediaPath ?? null,
880
+ media.mediaType ?? null,
881
+ media.rawJson ?? null,
882
+ now,
883
+ now
884
+ ]
885
+ })),
886
+ ...(record.mentions ?? []).map((mention, index) => ({
887
+ sql: INSERT_MESSAGE_MENTION_SQL,
888
+ params: [
889
+ record.profile,
890
+ messageUid,
891
+ index,
892
+ mention.uid,
893
+ mention.pos ?? null,
894
+ mention.len ?? null,
895
+ mention.type ?? null,
896
+ mention.rawJson ?? null,
897
+ now,
898
+ now
899
+ ]
900
+ }))
901
+ ];
902
+ await db.batch(commands, true);
855
903
  }
856
904
  async function setSyncState(params) {
857
905
  const db = await getDb(params.profile);
@@ -2720,8 +2768,8 @@ function inferReplyMessageThreadId(params) {
2720
2768
  }
2721
2769
 
2722
2770
  // src/cli.ts
2723
- var require2 = createRequire(import.meta.url);
2724
- var { version: PKG_VERSION } = require2("../package.json");
2771
+ var require3 = createRequire2(import.meta.url);
2772
+ var { version: PKG_VERSION } = require3("../package.json");
2725
2773
  var program = new Command();
2726
2774
  var EMOJI_REACTION_MAP = {
2727
2775
  "\u2764\uFE0F": Reactions.HEART,
@@ -0,0 +1,292 @@
1
+ // src/lib/db-worker.ts
2
+ import { parentPort, workerData } from "worker_threads";
3
+ var INIT_SQL = `
4
+ PRAGMA journal_mode = WAL;
5
+ PRAGMA synchronous = FULL;
6
+ PRAGMA busy_timeout = 5000;
7
+ PRAGMA foreign_keys = ON;
8
+
9
+ CREATE TABLE IF NOT EXISTS threads (
10
+ profile TEXT NOT NULL,
11
+ scope_thread_id TEXT NOT NULL,
12
+ raw_thread_id TEXT NOT NULL,
13
+ thread_type TEXT NOT NULL,
14
+ peer_id TEXT,
15
+ title TEXT,
16
+ is_pinned INTEGER NOT NULL DEFAULT 0,
17
+ is_hidden INTEGER NOT NULL DEFAULT 0,
18
+ is_archived INTEGER NOT NULL DEFAULT 0,
19
+ raw_json TEXT,
20
+ created_at TEXT NOT NULL,
21
+ updated_at TEXT NOT NULL,
22
+ PRIMARY KEY (profile, scope_thread_id)
23
+ );
24
+
25
+ CREATE TABLE IF NOT EXISTS thread_members (
26
+ profile TEXT NOT NULL,
27
+ scope_thread_id TEXT NOT NULL,
28
+ user_id TEXT NOT NULL,
29
+ display_name TEXT,
30
+ zalo_name TEXT,
31
+ avatar TEXT,
32
+ account_status INTEGER,
33
+ member_type INTEGER,
34
+ raw_json TEXT,
35
+ snapshot_at_ms INTEGER NOT NULL,
36
+ created_at TEXT NOT NULL,
37
+ updated_at TEXT NOT NULL,
38
+ PRIMARY KEY (profile, scope_thread_id, user_id)
39
+ );
40
+
41
+ CREATE TABLE IF NOT EXISTS friends (
42
+ profile TEXT NOT NULL,
43
+ user_id TEXT NOT NULL,
44
+ display_name TEXT,
45
+ zalo_name TEXT,
46
+ avatar TEXT,
47
+ account_status INTEGER,
48
+ raw_json TEXT,
49
+ created_at TEXT NOT NULL,
50
+ updated_at TEXT NOT NULL,
51
+ PRIMARY KEY (profile, user_id)
52
+ );
53
+
54
+ CREATE TABLE IF NOT EXISTS self_profiles (
55
+ profile TEXT NOT NULL,
56
+ user_id TEXT NOT NULL,
57
+ display_name TEXT,
58
+ info_json TEXT,
59
+ created_at TEXT NOT NULL,
60
+ updated_at TEXT NOT NULL,
61
+ PRIMARY KEY (profile)
62
+ );
63
+
64
+ CREATE TABLE IF NOT EXISTS messages (
65
+ profile TEXT NOT NULL,
66
+ message_uid TEXT NOT NULL,
67
+ scope_thread_id TEXT NOT NULL,
68
+ raw_thread_id TEXT NOT NULL,
69
+ thread_type TEXT NOT NULL,
70
+ msg_id TEXT,
71
+ cli_msg_id TEXT,
72
+ action_id TEXT,
73
+ sender_id TEXT,
74
+ sender_name TEXT,
75
+ to_id TEXT,
76
+ timestamp_ms INTEGER NOT NULL,
77
+ msg_type TEXT,
78
+ content_text TEXT,
79
+ content_json TEXT,
80
+ quote_msg_id TEXT,
81
+ quote_cli_msg_id TEXT,
82
+ quote_owner_id TEXT,
83
+ quote_text TEXT,
84
+ source TEXT NOT NULL,
85
+ raw_message_json TEXT,
86
+ raw_payload_json TEXT,
87
+ created_at TEXT NOT NULL,
88
+ updated_at TEXT NOT NULL,
89
+ PRIMARY KEY (profile, message_uid)
90
+ );
91
+
92
+ CREATE TABLE IF NOT EXISTS message_media (
93
+ profile TEXT NOT NULL,
94
+ message_uid TEXT NOT NULL,
95
+ item_index INTEGER NOT NULL,
96
+ media_kind TEXT,
97
+ media_url TEXT,
98
+ media_path TEXT,
99
+ media_type TEXT,
100
+ raw_json TEXT,
101
+ created_at TEXT NOT NULL,
102
+ updated_at TEXT NOT NULL,
103
+ PRIMARY KEY (profile, message_uid, item_index)
104
+ );
105
+
106
+ CREATE TABLE IF NOT EXISTS message_mentions (
107
+ profile TEXT NOT NULL,
108
+ message_uid TEXT NOT NULL,
109
+ item_index INTEGER NOT NULL,
110
+ target_user_id TEXT NOT NULL,
111
+ pos INTEGER,
112
+ len INTEGER,
113
+ mention_type INTEGER,
114
+ raw_json TEXT,
115
+ created_at TEXT NOT NULL,
116
+ updated_at TEXT NOT NULL,
117
+ PRIMARY KEY (profile, message_uid, item_index)
118
+ );
119
+
120
+ CREATE TABLE IF NOT EXISTS sync_state (
121
+ profile TEXT NOT NULL,
122
+ scope TEXT NOT NULL,
123
+ scope_thread_id TEXT NOT NULL,
124
+ thread_type TEXT NOT NULL,
125
+ status TEXT NOT NULL,
126
+ completeness TEXT,
127
+ cursor TEXT,
128
+ last_sync_at TEXT,
129
+ error TEXT,
130
+ created_at TEXT NOT NULL,
131
+ updated_at TEXT NOT NULL,
132
+ PRIMARY KEY (profile, scope)
133
+ );
134
+
135
+ CREATE INDEX IF NOT EXISTS idx_messages_thread_time
136
+ ON messages (profile, scope_thread_id, timestamp_ms DESC);
137
+ CREATE INDEX IF NOT EXISTS idx_messages_msg_id
138
+ ON messages (profile, msg_id);
139
+ CREATE INDEX IF NOT EXISTS idx_messages_cli_msg_id
140
+ ON messages (profile, cli_msg_id);
141
+ CREATE INDEX IF NOT EXISTS idx_threads_type
142
+ ON threads (profile, thread_type, updated_at DESC);
143
+ CREATE INDEX IF NOT EXISTS idx_members_thread
144
+ ON thread_members (profile, scope_thread_id);
145
+ CREATE INDEX IF NOT EXISTS idx_friends_name
146
+ ON friends (profile, display_name, zalo_name, user_id);
147
+ `;
148
+ if (!parentPort) {
149
+ throw new Error("DB worker requires parentPort");
150
+ }
151
+ var port = parentPort;
152
+ function serializeError(error) {
153
+ if (error instanceof Error) {
154
+ return {
155
+ name: error.name,
156
+ message: error.message,
157
+ stack: error.stack,
158
+ code: typeof error.code === "string" ? error.code : void 0
159
+ };
160
+ }
161
+ return {
162
+ name: "Error",
163
+ message: String(error)
164
+ };
165
+ }
166
+ async function loadSqliteModule() {
167
+ const originalEmitWarning = process.emitWarning;
168
+ const importDynamic = new Function("specifier", "return import(specifier);");
169
+ process.emitWarning = ((warning, options, ...args) => {
170
+ const type = typeof options === "string" ? options : void 0;
171
+ const message = warning instanceof Error ? warning.message : String(warning);
172
+ if (type === "ExperimentalWarning" && message.includes("SQLite")) {
173
+ return;
174
+ }
175
+ Reflect.apply(originalEmitWarning, process, [warning, options, ...args]);
176
+ });
177
+ try {
178
+ return await importDynamic("node:sqlite");
179
+ } finally {
180
+ process.emitWarning = originalEmitWarning;
181
+ }
182
+ }
183
+ function setDefensiveMode(db) {
184
+ const maybeDb = db;
185
+ if (typeof maybeDb.enableDefensive === "function") {
186
+ maybeDb.enableDefensive(true);
187
+ }
188
+ }
189
+ function runStatement(db, statement) {
190
+ return db.prepare(statement.sql).run(...statement.params ?? []);
191
+ }
192
+ function getStatement(db, statement) {
193
+ return db.prepare(statement.sql).get(...statement.params ?? []);
194
+ }
195
+ function allStatement(db, statement) {
196
+ return db.prepare(statement.sql).all(...statement.params ?? []);
197
+ }
198
+ async function main() {
199
+ const { DatabaseSync } = await loadSqliteModule();
200
+ const { filename } = workerData;
201
+ const db = new DatabaseSync(filename);
202
+ db.exec(INIT_SQL);
203
+ setDefensiveMode(db);
204
+ port.postMessage({ type: "ready" });
205
+ port.on("message", (message) => {
206
+ try {
207
+ switch (message.type) {
208
+ case "exec":
209
+ db.exec(message.payload.sql);
210
+ port.postMessage({
211
+ type: "result",
212
+ id: message.id,
213
+ result: null
214
+ });
215
+ return;
216
+ case "run":
217
+ port.postMessage({
218
+ type: "result",
219
+ id: message.id,
220
+ result: runStatement(db, message.payload)
221
+ });
222
+ return;
223
+ case "get":
224
+ port.postMessage({
225
+ type: "result",
226
+ id: message.id,
227
+ result: getStatement(db, message.payload) ?? null
228
+ });
229
+ return;
230
+ case "all":
231
+ port.postMessage({
232
+ type: "result",
233
+ id: message.id,
234
+ result: allStatement(db, message.payload)
235
+ });
236
+ return;
237
+ case "batch":
238
+ if (message.payload.transactional) {
239
+ db.exec("BEGIN");
240
+ }
241
+ try {
242
+ for (const command of message.payload.commands) {
243
+ if ((command.params ?? []).length === 0) {
244
+ db.exec(command.sql);
245
+ } else {
246
+ runStatement(db, command);
247
+ }
248
+ }
249
+ if (message.payload.transactional) {
250
+ db.exec("COMMIT");
251
+ }
252
+ } catch (error) {
253
+ if (message.payload.transactional) {
254
+ try {
255
+ db.exec("ROLLBACK");
256
+ } catch {
257
+ }
258
+ }
259
+ throw error;
260
+ }
261
+ port.postMessage({
262
+ type: "result",
263
+ id: message.id,
264
+ result: null
265
+ });
266
+ return;
267
+ case "close":
268
+ db.close();
269
+ port.postMessage({
270
+ type: "result",
271
+ id: message.id,
272
+ result: null
273
+ });
274
+ setImmediate(() => process.exit(0));
275
+ return;
276
+ }
277
+ } catch (error) {
278
+ port.postMessage({
279
+ type: "error",
280
+ id: message.id,
281
+ error: serializeError(error)
282
+ });
283
+ }
284
+ });
285
+ }
286
+ void main().catch((error) => {
287
+ port.postMessage({
288
+ type: "fatal",
289
+ error: serializeError(error)
290
+ });
291
+ process.exit(1);
292
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openzca",
3
- "version": "0.1.50",
3
+ "version": "0.1.51",
4
4
  "description": "Open-source zca-compatible CLI to integrate Zalo with OpenClaw",
5
5
  "type": "module",
6
6
  "bin": {
@@ -14,11 +14,14 @@
14
14
  "README.md"
15
15
  ],
16
16
  "scripts": {
17
- "build": "tsup src/cli.ts --format esm --target node18 --out-dir dist --clean",
17
+ "build": "npm run build:cli && npm run build:worker",
18
+ "build:cli": "tsup src/cli.ts --format esm --target node22 --out-dir dist --clean",
19
+ "build:worker": "tsup src/lib/db-worker.ts --format esm --target node22 --out-dir dist",
18
20
  "dev": "tsx src/cli.ts",
19
21
  "test": "tsx --test tests/*.test.ts",
20
22
  "typecheck": "tsc -p tsconfig.json",
21
23
  "lint": "tsc -p tsconfig.json --noEmit",
24
+ "prepare": "npm run build",
22
25
  "prepublishOnly": "npm run build"
23
26
  },
24
27
  "keywords": [
@@ -39,15 +42,13 @@
39
42
  "url": "https://github.com/darkamenosa/openzca/issues"
40
43
  },
41
44
  "engines": {
42
- "node": ">=18"
45
+ "node": ">=22.13.0"
43
46
  },
44
47
  "dependencies": {
45
48
  "@types/qrcode-terminal": "^0.12.2",
46
49
  "commander": "^14.0.3",
47
50
  "image-size": "^2.0.2",
48
51
  "qrcode-terminal": "^0.12.0",
49
- "sqlite": "^5.1.1",
50
- "sqlite3": "^6.0.1",
51
52
  "zca-js": "^2.1.2"
52
53
  },
53
54
  "devDependencies": {