@wirecat/cli-core 0.483.0 → 0.485.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/CHANGELOG.md CHANGED
@@ -7,6 +7,27 @@ shape of this file.
7
7
 
8
8
  ## Unreleased
9
9
 
10
+ ## 0.485.0 — 30.10.2026
11
+
12
+ ### Fixed
13
+
14
+ - **A store write no longer fails with "database is locked" while another process writes steadily.** SQLite's own wait
15
+ for the write lock sleeps up to 100 ms between tries, so a process that commits and begins again at once was never
16
+ seen free, and the waiter failed after the 5 s `busy_timeout` (`mail sync` against `memo sync`). A write now tries
17
+ for the lock every millisecond within the same `busy_timeout`. `saveAccount` takes the lock the same way instead
18
+ of as a bare statement.
19
+
20
+ ## 0.484.0 — 30.10.2026
21
+
22
+ ### Fixed
23
+
24
+ - **Every name filter ignores case in any script, not only Latin** — SQLite's `lower()` folds A–Z only, so
25
+ `projects list 'name:*ТАРТ*'` missed «Тартус». Store version 38 keeps a folded copy, with an index where a filter
26
+ reads it, of project names, chat titles, contact names, mail thread subjects and mailbox names, beside the copies
27
+ tasks, meetings, attachments and files already had. `projects list name:`, `tags list name:` and mail search
28
+ `mailbox:`/`label:` compare those copies. A trigger empties a copy whose name an older build changed, and the next
29
+ open refills it, as it fills the copies of rows stored before version 38.
30
+
10
31
  ## 0.483.0 — 30.10.2026
11
32
 
12
33
  ### Fixed
@@ -2057,7 +2078,6 @@ shape of this file.
2057
2078
  - **An import says the zone it read file times in, and a host can explain its file names.** `RunReport.timezone` is
2058
2079
  set when every imported file's meeting carries one zone; `MeetingCommandDeps.importHelp` adds the host's note
2059
2080
  (file name patterns, how their time is read) under `meetings import --help`.
2060
- ||||||| Stash base
2061
2081
  - **Meeting search finds words spread over neighbouring lines.** `"циан риелтор"~3` in a meeting query finds a
2062
2082
  transcript line with one of the words when the others are at most three lines away in the same transcript; `~0`
2063
2083
  is one line, any order; at most 20. The words widen to garbled spellings as single words do. It needs
@@ -112,7 +112,7 @@ export const QUERY_FIELDS = [
112
112
  aliases: [],
113
113
  type: "kw",
114
114
  operators: terms,
115
- normalization: "a folder or label by its id or name, ASCII case folded; mail only",
115
+ normalization: "a folder or label by its id (ASCII case folded) or name (case folded in any script); mail only",
116
116
  support: "A1",
117
117
  },
118
118
  {
@@ -18,7 +18,7 @@ export const TAGGING_QUERY = {
18
18
  const TAGGING_COLUMNS = {
19
19
  tag: { type: "kw", sql: "x.tag" },
20
20
  type: { type: "enum", sql: "x.tag_type" },
21
- name: { type: "kw", sql: "COALESCE(x.chat_title, x.name)" },
21
+ name: { type: "kw", sql: "x.name_folded", folded: true },
22
22
  sources: {
23
23
  where: (node) => node.operator === "wildcard"
24
24
  ? { sql: "1", params: [] }
@@ -11,8 +11,11 @@ export const accountAccountsOver = (context) => {
11
11
  const { accountPk, findAccountPk } = keysOver(context);
12
12
  return {
13
13
  saveAccount: async (key, { name }) => {
14
- const pk = accountPk(key, name);
15
- linkInbox(database, `${key.provider}:${key.account}`, pk);
14
+ const pk = write(database, () => {
15
+ const made = accountPk(key, name);
16
+ linkInbox(database, `${key.provider}:${key.account}`, made);
17
+ return made;
18
+ });
16
19
  linkOwnIdentities(context);
17
20
  return pk;
18
21
  },
@@ -1,5 +1,6 @@
1
1
  import { CliError } from "@wirecat/cli-base";
2
2
  import { and, eq, inArray, or, sql } from "./drizzle/core.js";
3
+ import { folded } from "./folded.js";
3
4
  import { identityPk } from "./identities.js";
4
5
  import { bound } from "./query-filter.js";
5
6
  import { chatEventPeople, chatEvents, chatMembers, chats, chunks, fetchLeases, identities, involvements, memberCounts, memberObservationMembers, memberObservations, memberStays, messageReactions, messageRevisions, messages, messageTranscripts, pollVotes, syncRanges, } from "./schema.js";
@@ -112,7 +113,14 @@ export const answeredChatPk = (context, accountKey, chatId, typed) => {
112
113
  }
113
114
  return Number(orm
114
115
  .insert(chats)
115
- .values({ accountId: accountKey, externalId: chatId, ...like, createdAt: now(), updatedAt: now() })
116
+ .values({
117
+ accountId: accountKey,
118
+ externalId: chatId,
119
+ ...like,
120
+ titleFolded: folded(like.title),
121
+ createdAt: now(),
122
+ updatedAt: now(),
123
+ })
116
124
  .returning({ pk: chats.id })
117
125
  .get()?.pk);
118
126
  };
@@ -133,6 +141,7 @@ export const upsertChat = (context, accountKey, chat) => {
133
141
  parentChatId: parent,
134
142
  scope: chat.scope ?? null,
135
143
  title: chat.title ?? null,
144
+ titleFolded: folded(chat.title),
136
145
  unreadCount: chat.unreadCount ?? null,
137
146
  lastMessageAt: toMs(chat.lastMessageAt),
138
147
  participantsCount: chat.participantsCount ?? null,
@@ -148,7 +157,7 @@ export const upsertChat = (context, accountKey, chat) => {
148
157
  kind: sql `excluded.kind`,
149
158
  ...(chat.parentChatId === undefined ? {} : { parentChatId: sql `excluded.parent_chat_id` }),
150
159
  scope: sql `coalesce(excluded.scope, ${chats.scope})`,
151
- ...(chat.title === undefined ? {} : { title: sql `excluded.title` }),
160
+ ...(chat.title === undefined ? {} : { title: sql `excluded.title`, titleFolded: sql `excluded.title_folded` }),
152
161
  ...(chat.unreadCount === undefined
153
162
  ? {}
154
163
  : { unreadCount: sql `excluded.unread_count`, unreadCountedAt: sql `excluded.unread_counted_at` }),
@@ -423,8 +432,8 @@ export const metadata = (context, key, chatId) => {
423
432
  };
424
433
  export const saveMetadata = (context, key, entry) => {
425
434
  revisedBy(context, "details", () => context.database
426
- .prepare("UPDATE chats SET title=?, username=?, description=?, details_fetched_at=?, updated_at=? WHERE id=?")
427
- .run(entry.title, entry.username, entry.description, context.now(), context.now(), metadataChatPk(context, key, entry.chatId)));
435
+ .prepare("UPDATE chats SET title=?, title_folded=?, username=?, description=?, details_fetched_at=?, updated_at=? WHERE id=?")
436
+ .run(entry.title, folded(entry.title), entry.username, entry.description, context.now(), context.now(), metadataChatPk(context, key, entry.chatId)));
428
437
  return metadata(context, key, entry.chatId);
429
438
  };
430
439
  /** Called inside the store's transaction: claims and effective search tags change together. */
@@ -1,4 +1,5 @@
1
1
  import { CliError } from "@wirecat/cli-base";
2
+ import { fold } from "../../normalize.js";
2
3
  import { ownerPerson } from "../actors.js";
3
4
  import { stateOfThing, thingOf } from "../things.js";
4
5
  export const planPeople = (database, snapshot, mappings, bindings, conflicts) => {
@@ -62,8 +63,8 @@ export const copyPeople = (database, snapshot, mappings, now) => {
62
63
  throw new CliError("configuration_error", "an identity belongs to another destination person; the transfer was rolled back");
63
64
  if (!row)
64
65
  row = database
65
- .prepare("INSERT INTO identities (provider,external_id,name,username,bot,created_at,updated_at) VALUES (?,?,?,?,?,?,?) RETURNING id")
66
- .get(String(identity.provider), String(identity.external_id), identity.name ?? null, identity.username ?? null, identity.bot ?? null, identity.created_at ?? now(), identity.updated_at ?? now());
66
+ .prepare("INSERT INTO identities (provider,external_id,name,name_folded,username,bot,created_at,updated_at) VALUES (?,?,?,?,?,?,?,?) RETURNING id")
67
+ .get(String(identity.provider), String(identity.external_id), identity.name ?? null, identity.name == null ? null : fold(String(identity.name)), identity.username ?? null, identity.bot ?? null, identity.created_at ?? now(), identity.updated_at ?? now());
67
68
  if (!database.prepare("SELECT identity_id FROM identity_links WHERE identity_id=?").get(Number(row?.id)))
68
69
  database
69
70
  .prepare("INSERT INTO identity_links (identity_id,person_id,method,confidence,author,source,created_at,updated_at) VALUES (?,?,?,?,?,?,?,?)")
@@ -1,9 +1,10 @@
1
1
  import { formatLocator } from "../../domain/locator.js";
2
2
  import { formatReference } from "../../domain/references.js";
3
- import { normalize } from "../normalize.js";
3
+ import { fold, normalize } from "../normalize.js";
4
4
  import { chunkAttachments, drainEmailIndex, dropAttachmentChunks, MAIL_PIECES, queueThreads, threadPieces, titleOf, } from "./email-index.js";
5
5
  import { recountEmailThread as recount, setEmailFlags } from "./email-state.js";
6
6
  import { bool, flag, fromJson, int, page, str, toJson } from "./events.js";
7
+ import { folded } from "./folded.js";
7
8
  import { ensurePerson } from "./identities.js";
8
9
  import { mailRulesOver } from "./mail-rules.js";
9
10
  import { attachmentOf, saveAttachment, wordsQuery } from "./meetings.js";
@@ -144,22 +145,24 @@ const identityOf = (database, { address, name }, now) => {
144
145
  .get(key);
145
146
  if (found) {
146
147
  if (found.name === null && name)
147
- database.prepare("UPDATE identities SET name = ?, updated_at = ? WHERE id = ?").run(name, now, Number(found.id));
148
+ database
149
+ .prepare("UPDATE identities SET name = ?, name_folded = ?, updated_at = ? WHERE id = ?")
150
+ .run(name, fold(name), now, Number(found.id));
148
151
  return Number(found.id);
149
152
  }
150
153
  return Number(database
151
- .prepare("INSERT INTO identities (provider, external_id, name, created_at, updated_at) VALUES ('email', ?, ?, ?, ?) RETURNING id")
152
- .get(key, name, now, now)?.id);
154
+ .prepare("INSERT INTO identities (provider, external_id, name, name_folded, created_at, updated_at) VALUES ('email', ?, ?, ?, ?, ?) RETURNING id")
155
+ .get(key, name, folded(name), now, now)?.id);
153
156
  };
154
157
  const mailboxPk = (database, accountId, box, now) => {
155
158
  if (!box.externalId)
156
159
  throw new Error("Invalid mailbox key");
157
160
  return Number(database
158
- .prepare(`INSERT INTO mailboxes (account_id, external_id, name, kind, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)
159
- ON CONFLICT (account_id, external_id) DO UPDATE SET name = excluded.name,
161
+ .prepare(`INSERT INTO mailboxes (account_id, external_id, name, name_folded, kind, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)
162
+ ON CONFLICT (account_id, external_id) DO UPDATE SET name = excluded.name, name_folded = excluded.name_folded,
160
163
  kind = coalesce(excluded.kind, kind), updated_at = excluded.updated_at
161
164
  RETURNING id`)
162
- .get(accountId, box.externalId, box.name, box.kind, now, now)?.id);
165
+ .get(accountId, box.externalId, box.name, fold(box.name), box.kind, now, now)?.id);
163
166
  };
164
167
  const join = (database, emailId, mailboxId, now) => database
165
168
  .prepare("INSERT OR IGNORE INTO email_mailboxes (email_id, mailbox_id, created_at) VALUES (?, ?, ?)")
@@ -253,7 +256,9 @@ const saveThread = (database, orm, input) => {
253
256
  RETURNING id`)
254
257
  .get(accountId, externalId, now, now)?.id);
255
258
  if (input.subject !== undefined)
256
- database.prepare("UPDATE email_threads SET subject = ? WHERE id = ?").run(input.subject, threadId);
259
+ database
260
+ .prepare("UPDATE email_threads SET subject = ?, subject_folded = ? WHERE id = ?")
261
+ .run(input.subject, folded(input.subject), threadId);
257
262
  if (input.metadata !== undefined)
258
263
  database.prepare("UPDATE email_threads SET metadata = ? WHERE id = ?").run(toJson(input.metadata), threadId);
259
264
  const moved = new Set();
@@ -265,9 +270,10 @@ const saveThread = (database, orm, input) => {
265
270
  const first = database
266
271
  .prepare("SELECT subject FROM emails WHERE email_thread_id = ? ORDER BY coalesce(sent_at, received_at), id LIMIT 1")
267
272
  .get(threadId);
273
+ const subject = titleOf(str(first?.subject));
268
274
  database
269
- .prepare("UPDATE email_threads SET subject = ? WHERE id = ? AND subject IS NULL")
270
- .run(titleOf(str(first?.subject)), threadId);
275
+ .prepare("UPDATE email_threads SET subject = ?, subject_folded = ? WHERE id = ? AND subject IS NULL")
276
+ .run(subject, folded(subject), threadId);
271
277
  for (const id of [threadId, ...moved])
272
278
  recount(database, id, now);
273
279
  queueThreads(database, moved);
@@ -1,5 +1,9 @@
1
1
  import type { CacheDatabase } from "../driver.js";
2
- /** Every name a list filter compares, beside its `fold()`ed copy: SQLite's `lower()` folds ASCII only. */
2
+ /**
3
+ * Every name a filter compares, beside its `fold()`ed copy: SQLite's `lower()`, `NOCASE` and `LIKE` fold ASCII only,
4
+ * and Bun cannot register a function that folds the rest. A writer sets the copy; a trigger empties a copy whose name
5
+ * changed without it (version 38), and `fillFolded` refills it on the next open.
6
+ */
3
7
  export declare const FOLDED: readonly [{
4
8
  readonly table: "tasks";
5
9
  readonly column: "title";
@@ -18,6 +22,21 @@ export declare const FOLDED: readonly [{
18
22
  }, {
19
23
  readonly table: "files";
20
24
  readonly column: "path";
25
+ }, {
26
+ readonly table: "projects";
27
+ readonly column: "name";
28
+ }, {
29
+ readonly table: "chats";
30
+ readonly column: "title";
31
+ }, {
32
+ readonly table: "identities";
33
+ readonly column: "name";
34
+ }, {
35
+ readonly table: "email_threads";
36
+ readonly column: "subject";
37
+ }, {
38
+ readonly table: "mailboxes";
39
+ readonly column: "name";
21
40
  }];
22
41
  export declare const folded: (value: string | null | undefined) => string | null;
23
42
  /**
@@ -1,6 +1,10 @@
1
1
  import { fold } from "../normalize.js";
2
2
  import { writeBatch } from "./transaction.js";
3
- /** Every name a list filter compares, beside its `fold()`ed copy: SQLite's `lower()` folds ASCII only. */
3
+ /**
4
+ * Every name a filter compares, beside its `fold()`ed copy: SQLite's `lower()`, `NOCASE` and `LIKE` fold ASCII only,
5
+ * and Bun cannot register a function that folds the rest. A writer sets the copy; a trigger empties a copy whose name
6
+ * changed without it (version 38), and `fillFolded` refills it on the next open.
7
+ */
4
8
  export const FOLDED = [
5
9
  { table: "tasks", column: "title" },
6
10
  { table: "tasks", column: "close_reason" },
@@ -8,6 +12,11 @@ export const FOLDED = [
8
12
  { table: "attachments", column: "local_path" },
9
13
  { table: "meetings", column: "title" },
10
14
  { table: "files", column: "path" },
15
+ { table: "projects", column: "name" },
16
+ { table: "chats", column: "title" },
17
+ { table: "identities", column: "name" },
18
+ { table: "email_threads", column: "subject" },
19
+ { table: "mailboxes", column: "name" },
11
20
  ];
12
21
  export const folded = (value) => value === null || value === undefined ? null : fold(value);
13
22
  /**
@@ -1,6 +1,7 @@
1
1
  import { CliError } from "@wirecat/cli-base";
2
2
  import { fold } from "../normalize.js";
3
3
  import { and, asc, desc, eq, inArray, isNotNull, isNull, ne, sql } from "./drizzle/core.js";
4
+ import { folded } from "./folded.js";
4
5
  import { resolvePersonLinks } from "./person-resolution.js";
5
6
  import { accountIdentities, accounts, aliases, chatMembers, chats, identities, identityLinkEvents, identityLinks, identityRevisions, messages, persons, } from "./schema.js";
6
7
  import { toIso } from "./values.js";
@@ -129,7 +130,7 @@ const saveIdentity = ({ orm, now, database }, provider, nativeId, name, facts, r
129
130
  changed.phone !== found.phone) {
130
131
  orm
131
132
  .update(identities)
132
- .set({ ...changed, updatedAt: now() })
133
+ .set({ ...changed, nameFolded: folded(changed.name), updatedAt: now() })
133
134
  .where(eq(identities.id, found.pk))
134
135
  .run();
135
136
  }
@@ -149,6 +150,7 @@ const saveIdentity = ({ orm, now, database }, provider, nativeId, name, facts, r
149
150
  provider,
150
151
  externalId: nativeId,
151
152
  name,
153
+ nameFolded: folded(name),
152
154
  metadata: facts.metadata == null ? null : JSON.stringify(facts.metadata),
153
155
  username: facts.username ?? null,
154
156
  bot: flag(facts.isBot),
@@ -5,6 +5,7 @@ import { formatReference, parseReference } from "../../domain/references.js";
5
5
  import { normalizeTag } from "../../domain/tags.js";
6
6
  import { normalizeVocabularyName } from "../../domain/vocabulary.js";
7
7
  import { formatMeetingReference } from "../../meetings/index.js";
8
+ import { fold } from "../normalize.js";
8
9
  import { ulid } from "../ulid.js";
9
10
  import { ownerPerson } from "./actors.js";
10
11
  import { linkKind, RELATION_KINDS } from "./link-kinds.js";
@@ -485,9 +486,9 @@ export const knowledgeStoreOver = (context) => {
485
486
  }
486
487
  const at = now();
487
488
  const row = database
488
- .prepare("INSERT INTO projects (key, name, description, type, organization_id, scope, tasks_count, status, created_at, updated_at) " +
489
- "VALUES (?, ?, ?, ?, ?, ?, 0, 'active', ?, ?) RETURNING *")
490
- .get(chosen, title, description === undefined ? null : textOf(description, 2000), oneOf(type, PROJECT_TYPES, "a project's type"), organizationId, oneOf(scope, SCOPES, "a scope"), at, at);
489
+ .prepare("INSERT INTO projects (key, name, name_folded, description, type, organization_id, scope, tasks_count, status, created_at, updated_at) " +
490
+ "VALUES (?, ?, ?, ?, ?, ?, ?, 0, 'active', ?, ?) RETURNING *")
491
+ .get(chosen, title, fold(title), description === undefined ? null : textOf(description, 2000), oneOf(type, PROJECT_TYPES, "a project's type"), organizationId, oneOf(scope, SCOPES, "a scope"), at, at);
491
492
  return projectOf(row);
492
493
  }),
493
494
  projects: async (query) => {
@@ -496,7 +497,7 @@ export const knowledgeStoreOver = (context) => {
496
497
  queryFilter(query, {
497
498
  id: { type: "kw", sql: "CAST(id AS TEXT)" },
498
499
  key: { type: "kw", sql: "key" },
499
- name: { type: "kw", sql: "name" },
500
+ name: { type: "kw", sql: "name_folded", folded: true },
500
501
  kind: { type: "enum", sql: `CASE WHEN length(key) = ${INBOX_KEY_LENGTH} THEN 'inbox' ELSE 'project' END` },
501
502
  type: { type: "enum", sql: "type" },
502
503
  organization_id: { type: "kw", sql: "CAST(organization_id AS TEXT)" },
@@ -9,7 +9,7 @@ import { isStemmed } from "../../search/lucene/resolved.js";
9
9
  import { exhausted, QUERY_LIMITS, searchError } from "../../search/lucene/types.js";
10
10
  import { inSource } from "../../search/query.js";
11
11
  import { STEM_SEPARATOR } from "../../search/stem.js";
12
- import { normalize } from "../normalize.js";
12
+ import { fold, normalize } from "../normalize.js";
13
13
  import { linkMatch } from "./mention-links.js";
14
14
  import { valueMatch } from "./phone-countries.js";
15
15
  import { canonicalTagNamed } from "./tags.js";
@@ -379,7 +379,7 @@ const mailbox = ({ value }, { mail }) => always(mail
379
379
  // Not tied to m.account_id: a correlated subquery re-reads every mailbox link for each email (minutes on
380
380
  // 880k links), and an email only ever links to its own account's mailboxes anyway.
381
381
  `m.id IN (SELECT em.email_id FROM email_mailboxes em WHERE em.mailbox_id IN
382
- (SELECT b.id FROM mailboxes b WHERE lower(b.external_id) = lower(?) OR lower(b.name) = lower(?)))`, value, value)
382
+ (SELECT b.id FROM mailboxes b WHERE lower(b.external_id) = lower(?) OR b.name_folded = ?))`, value, fold(value))
383
383
  : bound("0"));
384
384
  const is = ({ value }, { mail }) => {
385
385
  const state = value.toLowerCase();
@@ -50,6 +50,8 @@ export const MANIFEST = [
50
50
  { name: "20261029162435_version-36-attachment-contact", version: 36, minCompatible: 34 },
51
51
  { name: "20261029180959_version-37-identity-phone", version: 37, minCompatible: 34 },
52
52
  { name: "20261029181000_version-37-contact-mention-triggers", version: 37, minCompatible: 34 },
53
+ { name: "20261029224106_version-38-folded-names", version: 38, minCompatible: 34 },
54
+ { name: "20261029224107_version-38-folded-triggers", version: 38, minCompatible: 34 },
53
55
  ];
54
56
  export const generatedMigrations = (manifest = MANIFEST, generated = GENERATED) => {
55
57
  const migrations = [];
@@ -797,5 +797,38 @@ export const GENERATED = [
797
797
  "-- A shared contact card has no text, only a phone: queue it for mentions like an attachment's text.\n-- Cards stored before version 37 wait for `mentions rebuild`.\nCREATE TRIGGER mention_pending_attachment_contact_ai AFTER INSERT ON attachments\n WHEN new.attachable_type = 'message' AND new.contact IS NOT NULL BEGIN\n INSERT OR IGNORE INTO mention_pending (source_type, source_id) VALUES ('attachment', new.id);\nEND;",
798
798
  "CREATE TRIGGER mention_pending_attachment_contact_au AFTER UPDATE OF contact ON attachments\n WHEN new.attachable_type = 'message' AND old.contact IS NOT new.contact BEGIN\n INSERT OR IGNORE INTO mention_pending (source_type, source_id) VALUES ('attachment', new.id);\nEND;"
799
799
  ]
800
+ },
801
+ {
802
+ "name": "20261029224106_version-38-folded-names",
803
+ "statements": [
804
+ "ALTER TABLE `chats` ADD `title_folded` text;",
805
+ "ALTER TABLE `email_threads` ADD `subject_folded` text;",
806
+ "ALTER TABLE `identities` ADD `name_folded` text;",
807
+ "ALTER TABLE `mailboxes` ADD `name_folded` text;",
808
+ "ALTER TABLE `projects` ADD `name_folded` text;",
809
+ "CREATE INDEX `chats_title_to_fold` ON `chats` (`id`) WHERE title IS NOT NULL AND title_folded IS NULL;",
810
+ "CREATE INDEX `email_threads_subject_to_fold` ON `email_threads` (`id`) WHERE subject IS NOT NULL AND subject_folded IS NULL;",
811
+ "CREATE INDEX `identities_name_to_fold` ON `identities` (`id`) WHERE name IS NOT NULL AND name_folded IS NULL;",
812
+ "CREATE INDEX `mailboxes_by_name_folded` ON `mailboxes` (`name_folded`);",
813
+ "CREATE INDEX `mailboxes_name_to_fold` ON `mailboxes` (`id`) WHERE name_folded IS NULL;",
814
+ "CREATE INDEX `projects_by_name_folded` ON `projects` (`name_folded`);",
815
+ "CREATE INDEX `projects_name_to_fold` ON `projects` (`id`) WHERE name_folded IS NULL;"
816
+ ]
817
+ },
818
+ {
819
+ "name": "20261029224107_version-38-folded-triggers",
820
+ "statements": [
821
+ "-- A build older than 38, or a write that forgets the copy, changes a name without its folded copy: empty the copy\n-- so the next open refills it (fillFolded) rather than leave a stale one that filters would trust.\nCREATE TRIGGER tasks_title_folded_au AFTER UPDATE OF title ON tasks\n WHEN new.title IS NOT old.title AND new.title_folded IS old.title_folded BEGIN\n UPDATE tasks SET title_folded = NULL WHERE id = new.id;\nEND;",
822
+ "CREATE TRIGGER tasks_close_reason_folded_au AFTER UPDATE OF close_reason ON tasks\n WHEN new.close_reason IS NOT old.close_reason AND new.close_reason_folded IS old.close_reason_folded BEGIN\n UPDATE tasks SET close_reason_folded = NULL WHERE id = new.id;\nEND;",
823
+ "CREATE TRIGGER attachments_name_folded_au AFTER UPDATE OF name ON attachments\n WHEN new.name IS NOT old.name AND new.name_folded IS old.name_folded BEGIN\n UPDATE attachments SET name_folded = NULL WHERE id = new.id;\nEND;",
824
+ "CREATE TRIGGER attachments_local_path_folded_au AFTER UPDATE OF local_path ON attachments\n WHEN new.local_path IS NOT old.local_path AND new.local_path_folded IS old.local_path_folded BEGIN\n UPDATE attachments SET local_path_folded = NULL WHERE id = new.id;\nEND;",
825
+ "CREATE TRIGGER meetings_title_folded_au AFTER UPDATE OF title ON meetings\n WHEN new.title IS NOT old.title AND new.title_folded IS old.title_folded BEGIN\n UPDATE meetings SET title_folded = NULL WHERE id = new.id;\nEND;",
826
+ "CREATE TRIGGER files_path_folded_au AFTER UPDATE OF path ON files\n WHEN new.path IS NOT old.path AND new.path_folded IS old.path_folded BEGIN\n UPDATE files SET path_folded = NULL WHERE id = new.id;\nEND;",
827
+ "CREATE TRIGGER projects_name_folded_au AFTER UPDATE OF name ON projects\n WHEN new.name IS NOT old.name AND new.name_folded IS old.name_folded BEGIN\n UPDATE projects SET name_folded = NULL WHERE id = new.id;\nEND;",
828
+ "CREATE TRIGGER chats_title_folded_au AFTER UPDATE OF title ON chats\n WHEN new.title IS NOT old.title AND new.title_folded IS old.title_folded BEGIN\n UPDATE chats SET title_folded = NULL WHERE id = new.id;\nEND;",
829
+ "CREATE TRIGGER identities_name_folded_au AFTER UPDATE OF name ON identities\n WHEN new.name IS NOT old.name AND new.name_folded IS old.name_folded BEGIN\n UPDATE identities SET name_folded = NULL WHERE id = new.id;\nEND;",
830
+ "CREATE TRIGGER email_threads_subject_folded_au AFTER UPDATE OF subject ON email_threads\n WHEN new.subject IS NOT old.subject AND new.subject_folded IS old.subject_folded BEGIN\n UPDATE email_threads SET subject_folded = NULL WHERE id = new.id;\nEND;",
831
+ "CREATE TRIGGER mailboxes_name_folded_au AFTER UPDATE OF name ON mailboxes\n WHEN new.name IS NOT old.name AND new.name_folded IS old.name_folded BEGIN\n UPDATE mailboxes SET name_folded = NULL WHERE id = new.id;\nEND;"
832
+ ]
800
833
  }
801
834
  ];
@@ -1058,6 +1058,22 @@ export declare const identities: import("drizzle-orm/sqlite-core").SQLiteTableWi
1058
1058
  identity: undefined;
1059
1059
  generated: undefined;
1060
1060
  }, {}>;
1061
+ nameFolded: import("drizzle-orm/sqlite-core").SQLiteColumn<{
1062
+ name: string;
1063
+ tableName: "identities";
1064
+ dataType: "string";
1065
+ data: string;
1066
+ driverParam: string;
1067
+ notNull: false;
1068
+ hasDefault: false;
1069
+ isPrimaryKey: false;
1070
+ isAutoincrement: false;
1071
+ hasRuntimeDefault: false;
1072
+ enumValues: [string, ...string[]];
1073
+ baseColumn: never;
1074
+ identity: undefined;
1075
+ generated: undefined;
1076
+ }, {}>;
1061
1077
  };
1062
1078
  dialect: 'sqlite';
1063
1079
  isAlias: false;
@@ -2725,6 +2741,22 @@ export declare const chats: import("drizzle-orm/sqlite-core").SQLiteTableWithCol
2725
2741
  identity: undefined;
2726
2742
  generated: undefined;
2727
2743
  }, {}>;
2744
+ titleFolded: import("drizzle-orm/sqlite-core").SQLiteColumn<{
2745
+ name: string;
2746
+ tableName: "chats";
2747
+ dataType: "string";
2748
+ data: string;
2749
+ driverParam: string;
2750
+ notNull: false;
2751
+ hasDefault: false;
2752
+ isPrimaryKey: false;
2753
+ isAutoincrement: false;
2754
+ hasRuntimeDefault: false;
2755
+ enumValues: [string, ...string[]];
2756
+ baseColumn: never;
2757
+ identity: undefined;
2758
+ generated: undefined;
2759
+ }, {}>;
2728
2760
  };
2729
2761
  dialect: 'sqlite';
2730
2762
  isAlias: false;
@@ -5184,6 +5216,22 @@ export declare const emailThreads: import("drizzle-orm/sqlite-core").SQLiteTable
5184
5216
  identity: undefined;
5185
5217
  generated: undefined;
5186
5218
  }, {}>;
5219
+ subjectFolded: import("drizzle-orm/sqlite-core").SQLiteColumn<{
5220
+ name: string;
5221
+ tableName: "email_threads";
5222
+ dataType: "string";
5223
+ data: string;
5224
+ driverParam: string;
5225
+ notNull: false;
5226
+ hasDefault: false;
5227
+ isPrimaryKey: false;
5228
+ isAutoincrement: false;
5229
+ hasRuntimeDefault: false;
5230
+ enumValues: [string, ...string[]];
5231
+ baseColumn: never;
5232
+ identity: undefined;
5233
+ generated: undefined;
5234
+ }, {}>;
5187
5235
  };
5188
5236
  dialect: 'sqlite';
5189
5237
  isAlias: false;
@@ -6048,6 +6096,22 @@ export declare const mailboxes: import("drizzle-orm/sqlite-core").SQLiteTableWit
6048
6096
  identity: undefined;
6049
6097
  generated: undefined;
6050
6098
  }, {}>;
6099
+ nameFolded: import("drizzle-orm/sqlite-core").SQLiteColumn<{
6100
+ name: string;
6101
+ tableName: "mailboxes";
6102
+ dataType: "string";
6103
+ data: string;
6104
+ driverParam: string;
6105
+ notNull: false;
6106
+ hasDefault: false;
6107
+ isPrimaryKey: false;
6108
+ isAutoincrement: false;
6109
+ hasRuntimeDefault: false;
6110
+ enumValues: [string, ...string[]];
6111
+ baseColumn: never;
6112
+ identity: undefined;
6113
+ generated: undefined;
6114
+ }, {}>;
6051
6115
  };
6052
6116
  dialect: 'sqlite';
6053
6117
  isAlias: false;
@@ -11851,6 +11915,22 @@ export declare const projects: import("drizzle-orm/sqlite-core").SQLiteTableWith
11851
11915
  identity: undefined;
11852
11916
  generated: undefined;
11853
11917
  }, {}>;
11918
+ nameFolded: import("drizzle-orm/sqlite-core").SQLiteColumn<{
11919
+ name: string;
11920
+ tableName: "projects";
11921
+ dataType: "string";
11922
+ data: string;
11923
+ driverParam: string;
11924
+ notNull: false;
11925
+ hasDefault: false;
11926
+ isPrimaryKey: false;
11927
+ isAutoincrement: false;
11928
+ hasRuntimeDefault: false;
11929
+ enumValues: [string, ...string[]];
11930
+ baseColumn: never;
11931
+ identity: undefined;
11932
+ generated: undefined;
11933
+ }, {}>;
11854
11934
  };
11855
11935
  dialect: 'sqlite';
11856
11936
  isAlias: false;
@@ -101,9 +101,11 @@ export const identities = sqliteTable("identities", {
101
101
  description: text("description"),
102
102
  email: text("email"),
103
103
  phone: text("phone"),
104
+ nameFolded: text("name_folded"),
104
105
  }, (table) => [
105
106
  unique().on(table.provider, table.externalId),
106
107
  index("identities_by_email").on(table.email).where(sql `email IS NOT NULL`),
108
+ index("identities_name_to_fold").on(table.id).where(sql `name IS NOT NULL AND name_folded IS NULL`),
107
109
  ]);
108
110
  export const identityLinks = sqliteTable("identity_links", {
109
111
  identityId: integer("identity_id")
@@ -272,9 +274,11 @@ export const chats = sqliteTable("chats", {
272
274
  archived: integer("archived"),
273
275
  markedUnread: integer("marked_unread"),
274
276
  unreadCountedAt: integer("unread_counted_at"),
277
+ titleFolded: text("title_folded"),
275
278
  }, (table) => [
276
279
  unique().on(table.accountId, table.externalId),
277
280
  index("chats_by_recency").on(table.accountId, desc(table.lastMessageAt)),
281
+ index("chats_title_to_fold").on(table.id).where(sql `title IS NOT NULL AND title_folded IS NULL`),
278
282
  index("chats_by_parent_chat_id").on(table.parentChatId),
279
283
  ]);
280
284
  /** Who is in a chat, as the account last saw it: a list replaces the chat's membership whole. */
@@ -589,7 +593,11 @@ export const emailThreads = sqliteTable("email_threads", {
589
593
  createdAt: integer("created_at").notNull(),
590
594
  updatedAt: integer("updated_at").notNull(),
591
595
  deletedAt: integer("deleted_at"),
592
- }, (table) => [unique().on(table.accountId, table.externalId)]);
596
+ subjectFolded: text("subject_folded"),
597
+ }, (table) => [
598
+ unique().on(table.accountId, table.externalId),
599
+ index("email_threads_subject_to_fold").on(table.id).where(sql `subject IS NOT NULL AND subject_folded IS NULL`),
600
+ ]);
593
601
  export const emails = sqliteTable("emails", {
594
602
  id: integer("id").primaryKey(),
595
603
  accountId: integer("account_id")
@@ -669,7 +677,12 @@ export const mailboxes = sqliteTable("mailboxes", {
669
677
  kind: text("kind"),
670
678
  createdAt: integer("created_at").notNull(),
671
679
  updatedAt: integer("updated_at").notNull(),
672
- }, (table) => [unique().on(table.accountId, table.externalId)]);
680
+ nameFolded: text("name_folded"),
681
+ }, (table) => [
682
+ unique().on(table.accountId, table.externalId),
683
+ index("mailboxes_by_name_folded").on(table.nameFolded),
684
+ index("mailboxes_name_to_fold").on(table.id).where(sql `name_folded IS NULL`),
685
+ ]);
673
686
  export const emailMailboxes = sqliteTable("email_mailboxes", {
674
687
  emailId: integer("email_id")
675
688
  .notNull()
@@ -1186,7 +1199,10 @@ export const projects = sqliteTable("projects", {
1186
1199
  createdAt: integer("created_at").notNull(),
1187
1200
  updatedAt: integer("updated_at").notNull(),
1188
1201
  deletedAt: integer("deleted_at"),
1202
+ nameFolded: text("name_folded"),
1189
1203
  }, (table) => [
1204
+ index("projects_by_name_folded").on(table.nameFolded),
1205
+ index("projects_name_to_fold").on(table.id).where(sql `name_folded IS NULL`),
1190
1206
  index("projects_by_organization_id").on(table.organizationId),
1191
1207
  index("projects_by_account_id").on(table.accountId),
1192
1208
  index("projects_by_owner_type_owner_id").on(table.ownerType, table.ownerId),
@@ -205,7 +205,7 @@ export const tagsOf = ({ database }, key, filter) => {
205
205
  const parts = [
206
206
  {
207
207
  type: "chat",
208
- sql: "SELECT t.name AS tag, g.taggable_type AS type, g.created_at, c.external_id AS chat_id, c.title AS chat_title, " +
208
+ sql: "SELECT t.name AS tag, g.taggable_type AS type, g.created_at, c.external_id AS chat_id, c.title AS chat_title, c.title_folded AS name_folded, " +
209
209
  "NULL AS person_id, NULL AS name, NULL AS message_id, g.source, g.taggable_id AS target_id, t.canonical_id AS tag_id, NULL AS task_id " +
210
210
  "FROM taggings g JOIN resolved_tags t ON t.id=g.tag_id JOIN chats c ON c.id=g.taggable_id " +
211
211
  "JOIN accounts a ON a.id=c.account_id WHERE g.taggable_type='chat' AND a.provider=? AND a.external_id=?",
@@ -213,7 +213,7 @@ export const tagsOf = ({ database }, key, filter) => {
213
213
  },
214
214
  {
215
215
  type: "contact",
216
- sql: "SELECT t.name AS tag, g.taggable_type AS type, g.created_at, NULL AS chat_id, NULL AS chat_title, " +
216
+ sql: "SELECT t.name AS tag, g.taggable_type AS type, g.created_at, NULL AS chat_id, NULL AS chat_title, i.name_folded AS name_folded, " +
217
217
  "i.external_id AS person_id, i.name AS name, NULL AS message_id, g.source, g.taggable_id AS target_id, t.canonical_id AS tag_id, NULL AS task_id " +
218
218
  "FROM taggings g JOIN resolved_tags t ON t.id=g.tag_id JOIN identities i ON i.id=g.taggable_id " +
219
219
  "WHERE g.taggable_type='identity' AND i.provider=?",
@@ -221,7 +221,7 @@ export const tagsOf = ({ database }, key, filter) => {
221
221
  },
222
222
  {
223
223
  type: "message",
224
- sql: "SELECT t.name AS tag, g.taggable_type AS type, g.created_at, c.external_id AS chat_id, c.title AS chat_title, " +
224
+ sql: "SELECT t.name AS tag, g.taggable_type AS type, g.created_at, c.external_id AS chat_id, c.title AS chat_title, c.title_folded AS name_folded, " +
225
225
  "NULL AS person_id, NULL AS name, m.external_id AS message_id, g.source, g.taggable_id AS target_id, t.canonical_id AS tag_id, NULL AS task_id " +
226
226
  "FROM taggings g JOIN resolved_tags t ON t.id=g.tag_id JOIN messages m ON m.id=g.taggable_id JOIN chats c ON c.id=m.chat_id " +
227
227
  "JOIN accounts a ON a.id=c.account_id WHERE g.taggable_type='message' AND a.provider=? AND a.external_id=?",
@@ -229,7 +229,7 @@ export const tagsOf = ({ database }, key, filter) => {
229
229
  },
230
230
  {
231
231
  type: "chat",
232
- sql: "SELECT t.name AS tag, g.taggable_type AS type, g.created_at, th.external_id AS chat_id, th.subject AS chat_title, " +
232
+ sql: "SELECT t.name AS tag, g.taggable_type AS type, g.created_at, th.external_id AS chat_id, th.subject AS chat_title, th.subject_folded AS name_folded, " +
233
233
  "NULL AS person_id, NULL AS name, NULL AS message_id, g.source, g.taggable_id AS target_id, t.canonical_id AS tag_id, NULL AS task_id " +
234
234
  "FROM taggings g JOIN resolved_tags t ON t.id=g.tag_id JOIN email_threads th ON th.id=g.taggable_id " +
235
235
  "JOIN accounts a ON a.id=th.account_id WHERE g.taggable_type='email_thread' AND a.provider=? AND a.external_id=?",
@@ -237,7 +237,7 @@ export const tagsOf = ({ database }, key, filter) => {
237
237
  },
238
238
  {
239
239
  type: "message",
240
- sql: "SELECT t.name AS tag, g.taggable_type AS type, g.created_at, th.external_id AS chat_id, th.subject AS chat_title, " +
240
+ sql: "SELECT t.name AS tag, g.taggable_type AS type, g.created_at, th.external_id AS chat_id, th.subject AS chat_title, th.subject_folded AS name_folded, " +
241
241
  "NULL AS person_id, NULL AS name, e.external_id AS message_id, g.source, g.taggable_id AS target_id, t.canonical_id AS tag_id, NULL AS task_id " +
242
242
  "FROM taggings g JOIN resolved_tags t ON t.id=g.tag_id JOIN emails e ON e.id=g.taggable_id " +
243
243
  "JOIN email_threads th ON th.id=e.email_thread_id JOIN accounts a ON a.id=e.account_id " +
@@ -246,7 +246,7 @@ export const tagsOf = ({ database }, key, filter) => {
246
246
  },
247
247
  {
248
248
  type: "task",
249
- sql: "SELECT t.name AS tag, g.taggable_type AS type, g.created_at, NULL AS chat_id, k.title AS chat_title, " +
249
+ sql: "SELECT t.name AS tag, g.taggable_type AS type, g.created_at, NULL AS chat_id, k.title AS chat_title, k.title_folded AS name_folded, " +
250
250
  "NULL AS person_id, NULL AS name, NULL AS message_id, g.source, g.taggable_id AS target_id, t.canonical_id AS tag_id, " +
251
251
  "COALESCE(k.package_id, k.key) AS task_id " +
252
252
  "FROM taggings g JOIN resolved_tags t ON t.id=g.tag_id JOIN tasks k ON k.id=g.taggable_id JOIN projects p ON p.id=k.project_id " +
@@ -263,7 +263,7 @@ export const tagsOf = ({ database }, key, filter) => {
263
263
  : [{ sql: filter.source === "auto" ? "x.auto = 1" : "(x.auto = 0 OR x.source = 'owner')", params: [] }]),
264
264
  ];
265
265
  const rows = database
266
- .prepare("SELECT * FROM (SELECT tag,type,min(created_at) AS created_at,chat_id,chat_title,person_id,name,message_id,CASE WHEN max(source='owner') THEN 'owner' ELSE min(source) END AS source,target_id,tag_id,task_id,tag_type,max(auto) AS auto FROM (SELECT u.*, CASE u.type WHEN 'identity' THEN 'contact' WHEN 'email_thread' THEN 'chat' " +
266
+ .prepare("SELECT * FROM (SELECT tag,type,min(created_at) AS created_at,chat_id,chat_title,person_id,name,name_folded,message_id,CASE WHEN max(source='owner') THEN 'owner' ELSE min(source) END AS source,target_id,tag_id,task_id,tag_type,max(auto) AS auto FROM (SELECT u.*, CASE u.type WHEN 'identity' THEN 'contact' WHEN 'email_thread' THEN 'chat' " +
267
267
  "WHEN 'email' THEN 'message' ELSE u.type END AS tag_type, (u.type = 'chat' AND EXISTS (SELECT 1 FROM " +
268
268
  `canonical_auto_tag_claims ac JOIN resolved_tags rt ON rt.id=ac.tag_id WHERE ac.chat_id = u.target_id AND rt.canonical_id = u.tag_id)) AS auto FROM (${union}) u) GROUP BY tag,type,target_id) x ` +
269
269
  `WHERE ${where.length === 0 ? "1" : where.map(({ sql }) => `(${sql})`).join(" AND ")} ` +
@@ -40,9 +40,9 @@ export const taskStoreOver = (context) => {
40
40
  }
41
41
  const at = now();
42
42
  return Number(database
43
- .prepare("INSERT INTO projects (key, name, description, type, account_id, scope, tasks_count, status, created_at, updated_at) " +
44
- "VALUES (?, ?, ?, 'personal', ?, 'personal', 0, 'active', ?, ?) RETURNING id")
45
- .get(key, account, `What waits on the owner in ${account}`, accountId, at, at)?.id);
43
+ .prepare("INSERT INTO projects (key, name, name_folded, description, type, account_id, scope, tasks_count, status, created_at, updated_at) " +
44
+ "VALUES (?, ?, ?, ?, 'personal', ?, 'personal', 0, 'active', ?, ?) RETURNING id")
45
+ .get(key, account, folded(account), `What waits on the owner in ${account}`, accountId, at, at)?.id);
46
46
  };
47
47
  // A task made here has no package id and may be a type the package does not know; the package never sees it.
48
48
  const select = (where) => database.prepare(`SELECT ${COLUMNS} FROM tasks t JOIN projects p ON p.id = t.project_id WHERE t.package_id IS NOT NULL AND ${where}`);
@@ -1,6 +1,39 @@
1
1
  import { drainInvolvementQueue } from "./involvement-queue.js";
2
2
  import { drainMentions } from "./mention-drain.js";
3
3
  import { drainStems, stemmerCache } from "./stems.js";
4
+ const SQLITE_BUSY = 5;
5
+ const pause = new Int32Array(new SharedArrayBuffer(4));
6
+ const isBusy = (error) => {
7
+ const { errcode, errno } = (error ?? {});
8
+ const code = errcode ?? errno;
9
+ return typeof code === "number" && (code & 0xff) === SQLITE_BUSY;
10
+ };
11
+ /**
12
+ * Takes the write lock, waiting as long as the connection's `busy_timeout`. SQLite's own wait sleeps 1 ms, then up to
13
+ * 100 ms between tries, so a process that commits and begins again at once is never seen free and the waiter fails
14
+ * with "database is locked" after the full timeout. Trying every millisecond sees the gap between two writes.
15
+ */
16
+ const beginImmediate = (database) => {
17
+ const timeout = Number(database.prepare("PRAGMA busy_timeout").get()?.timeout ?? 0);
18
+ const deadline = Date.now() + timeout;
19
+ database.exec("PRAGMA busy_timeout = 0");
20
+ try {
21
+ for (;;) {
22
+ try {
23
+ database.exec("BEGIN IMMEDIATE");
24
+ return;
25
+ }
26
+ catch (error) {
27
+ if (!isBusy(error) || Date.now() >= deadline)
28
+ throw error;
29
+ Atomics.wait(pause, 0, 0, 1);
30
+ }
31
+ }
32
+ }
33
+ finally {
34
+ database.exec(`PRAGMA busy_timeout = ${timeout}`);
35
+ }
36
+ };
4
37
  const connections = new WeakMap();
5
38
  const connectionOf = (database) => {
6
39
  let connection = connections.get(database);
@@ -29,7 +62,10 @@ const transact = (database, body, drain, beforeCommit) => {
29
62
  throw new Error("a store write cannot run inside a read");
30
63
  const outer = connection.depth === 0;
31
64
  const name = `w${connection.depth + 1}`;
32
- database.exec(outer ? "BEGIN IMMEDIATE" : `SAVEPOINT ${name}`);
65
+ if (outer)
66
+ beginImmediate(database);
67
+ else
68
+ database.exec(`SAVEPOINT ${name}`);
33
69
  connection.depth += 1;
34
70
  try {
35
71
  const result = body();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wirecat/cli-core",
3
- "version": "0.483.0",
3
+ "version": "0.485.0",
4
4
  "description": "The messenger-neutral half of a messaging CLI: domain model, message locators, rendering, name resolution, the SQLite seam, the command skeleton and shared read commands, the send guard, run records and the message store",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Viacheslav Ptsarev",