@wirecat/cli-core 0.364.0 → 0.366.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,25 @@ shape of this file.
7
7
 
8
8
  ## Unreleased
9
9
 
10
+ ## 0.366.0 — 20.10.2026
11
+
12
+ ### Added
13
+
14
+ - **A chat's history in the store (store version 25).** `chat_revisions` keeps one row per change to a chat's id,
15
+ title, membership, scope, search visibility or parent, with the old and new value, the time and the store write
16
+ that made it (`chats`, `delta`, `left`, `rejoin`, `rekey`, `merge`, `send`, `details`; NULL for an older build or SQL
17
+ by hand). A trigger writes it in the changing transaction, only for a real change. A read by the id a chat was
18
+ re-keyed from, `messages list <old id> --offline` included, now finds the chat it became, per account
19
+ (`store.chats.renamedChatId`). The history is never indexed for search; a purged chat's history goes with it.
20
+
21
+ ## 0.365.0 — 19.10.2026
22
+
23
+ ### Fixed
24
+
25
+ - **A findings batch whose evidence changed can still be rejected.** Review rechecked the cited sources even when
26
+ it accepted nothing, so a stale meeting or evidence findings batch could never be closed and stayed open in
27
+ every list. Accepting still requires current evidence.
28
+
10
29
  ## 0.364.0 — 19.10.2026
11
30
 
12
31
  ### Fixed
@@ -23,35 +23,48 @@ export const readChatId = async (deps, reference, store, account) => {
23
23
  };
24
24
  const LEFT_EMPTY = "you left this chat, and the store holds none of its messages";
25
25
  /** A chat as typed, found among the stored chats the way an adapter finds it among its own. */
26
- export const storedChatId = async (messenger, reference, store, account) => chatIdAmong(messenger, reference, account, () => store.chats.chats(account, {}));
26
+ export const storedChatId = async (messenger, reference, store, account) => chatIdAmong(messenger, reference, account, {
27
+ held: () => store.chats.chats(account, {}),
28
+ renamed: (id) => store.chats.renamedChatId(account, id),
29
+ });
27
30
  /** `storedChatId`, then among the chats the account left: their history is kept, so a read of it still finds them. */
28
31
  const keptChatId = (messenger, reference, store, account) => storedChatId(messenger, reference, store, account).catch((error) => {
29
32
  if (codeOf(error) !== "not_found")
30
33
  throw error;
31
- return chatIdAmong(messenger, reference, account, () => store.chats.chats(account, { includeLeft: true }));
34
+ return chatIdAmong(messenger, reference, account, {
35
+ held: () => store.chats.chats(account, { includeLeft: true }),
36
+ renamed: (id) => store.chats.renamedChatId(account, id),
37
+ });
32
38
  });
33
39
  /** `storedChatId`, among the chats of the account the store is bound to; `includeLeft` tries the left ones last. */
34
40
  export const chatIdIn = (messenger, reference, store, { includeLeft = false } = {}) => {
35
- const among = chatIdAmong(messenger, reference, store.account, () => store.chats.chats({}));
41
+ const renamed = (id) => store.chats.renamedChatId(id);
42
+ const among = chatIdAmong(messenger, reference, store.account, { held: () => store.chats.chats({}), renamed });
36
43
  if (!includeLeft)
37
44
  return among;
38
45
  return among.catch((error) => {
39
46
  if (codeOf(error) !== "not_found")
40
47
  throw error;
41
- return chatIdAmong(messenger, reference, store.account, () => store.chats.chats({ includeLeft: true }));
48
+ return chatIdAmong(messenger, reference, store.account, {
49
+ held: () => store.chats.chats({ includeLeft: true }),
50
+ renamed,
51
+ });
42
52
  });
43
53
  };
44
- const chatIdAmong = async (messenger, reference, account, held) => {
54
+ const chatIdAmong = async (messenger, reference, account, { held, renamed }) => {
45
55
  const trimmed = reference.trim();
46
56
  if (messenger.savedChatId && ["me", "self", "saved"].includes(trimmed.toLowerCase())) {
47
57
  return messenger.savedChatId(account);
48
58
  }
49
59
  if (/^-?\d+$/.test(trimmed))
50
- return trimmed;
60
+ return (await renamed(trimmed)) ?? trimmed;
51
61
  const chats = (await held()).items;
52
62
  const exact = chats.find((one) => one.id === trimmed);
53
63
  if (exact)
54
64
  return exact.id;
65
+ const moved = await renamed(trimmed);
66
+ if (moved !== undefined && chats.some((one) => one.id === moved))
67
+ return moved;
55
68
  if (trimmed.startsWith("@")) {
56
69
  const username = trimmed.slice(1).toLowerCase();
57
70
  const found = chats.find((one) => String(one.providerMetadata?.username ?? "").toLowerCase() === username);
@@ -36,7 +36,8 @@ export interface ChatsStore {
36
36
  rejoinChats(key: AccountKey, present: Id[]): Promise<number>;
37
37
  /**
38
38
  * The chat the messenger now names `to` instead of `from`: renamed, or merged into the chat already stored as
39
- * `to`. Replaying it changes nothing.
39
+ * `to`, with `from` recorded in `chat_revisions` (`renamedChatId`), also when only `to` is stored. Replaying it
40
+ * changes nothing.
40
41
  */
41
42
  rekeyChat(key: AccountKey, from: Id, to: Id): Promise<ChatRekey>;
42
43
  /**
@@ -58,6 +59,11 @@ export interface ChatsStore {
58
59
  chatCompleteness(key: AccountKey, chatIds: Id[]): Promise<ChatCompleteness[]>;
59
60
  /** Per chat that has messages: how many, the oldest and newest, and when the last one was stored. */
60
61
  chatStats(key: AccountKey, chatId?: Id): Promise<ChatStats[]>;
62
+ /**
63
+ * The id a chat re-keyed from `chatId` has now, from `chat_revisions`; `undefined` while a chat is stored under
64
+ * `chatId` itself. Only for finding the chat: the old id and its history are never shown or searched.
65
+ */
66
+ renamedChatId(key: AccountKey, chatId: Id): Promise<Id | undefined>;
61
67
  /** A stored chat's kind, with or without messages; `undefined` when the store does not know the chat. */
62
68
  chatKind(key: AccountKey, chatId: Id): Promise<ChatKind | undefined>;
63
69
  /** Per chat that spoke since `since` (ms): its newest message and who sent it when stored, never the text. */
@@ -11,8 +11,10 @@ export const chatsStoreOver = (context) => {
11
11
  return {
12
12
  saveChats: async (key, list) => write(database, () => {
13
13
  const accountKey = accountPk(key);
14
- for (const chat of list)
15
- chatQueries.upsertChat(context, accountKey, chat);
14
+ chatQueries.revisedBy(context, "chats", () => {
15
+ for (const chat of list)
16
+ chatQueries.upsertChat(context, accountKey, chat);
17
+ });
16
18
  }),
17
19
  chats: async (key, window) => {
18
20
  const accountKey = findAccountPk(key);
@@ -34,14 +36,16 @@ export const chatsStoreOver = (context) => {
34
36
  return 0;
35
37
  let marked = 0;
36
38
  write(database, () => {
37
- marked = chatQueries.markLeft(context, accountKey, present);
39
+ marked = chatQueries.revisedBy(context, "left", () => chatQueries.markLeft(context, accountKey, present));
38
40
  writeState(accountKey, completeness.CHAT_LIST_KEY, String(present.length));
39
41
  });
40
42
  return marked;
41
43
  },
42
44
  rejoinChats: async (key, present) => {
43
45
  const accountKey = findAccountPk(key);
44
- return accountKey === undefined ? 0 : write(database, () => chatQueries.rejoin(context, accountKey, present));
46
+ return accountKey === undefined
47
+ ? 0
48
+ : write(database, () => chatQueries.revisedBy(context, "rejoin", () => chatQueries.rejoin(context, accountKey, present)));
45
49
  },
46
50
  rekeyChat: async (key, from, to) => {
47
51
  const accountKey = findAccountPk(key);
@@ -77,6 +81,10 @@ export const chatsStoreOver = (context) => {
77
81
  const accountKey = findAccountPk(key);
78
82
  return accountKey === undefined ? [] : reads.chatStats(context, accountKey, chatId);
79
83
  },
84
+ renamedChatId: async (key, chatId) => {
85
+ const accountKey = findAccountPk(key);
86
+ return accountKey === undefined ? undefined : chatQueries.renamedChatId(context, accountKey, chatId);
87
+ },
80
88
  chatKind: async (key, chatId) => {
81
89
  const accountKey = findAccountPk(key);
82
90
  if (accountKey === undefined)
@@ -93,7 +101,7 @@ export const chatsStoreOver = (context) => {
93
101
  : reads.chatActivity(context, accountKey, since, where);
94
102
  },
95
103
  chatMetadata: async (key, chatId) => chatQueries.metadata(context, key, chatId),
96
- saveChatMetadata: async (key, entry) => chatQueries.saveMetadata(context, key, entry),
104
+ saveChatMetadata: async (key, entry) => write(database, () => chatQueries.saveMetadata(context, key, entry)),
97
105
  replaceAutoTags: async (key, chatId, algorithm, matches) => write(database, () => chatQueries.replaceAutoTags(context, key, chatId, algorithm, matches)),
98
106
  rankingDiscussionChats: async (account, chatId) => {
99
107
  const rows = database
@@ -24,7 +24,8 @@ export interface ChatRekeys {
24
24
  }
25
25
  /**
26
26
  * A chat the messenger now names by another id: renamed in place, or merged into the row already stored under the
27
- * new id's twin. Replaying it, or naming a chat the store does not hold, changes nothing. Runs inside `write`.
27
+ * new id's twin, each change recorded in `chat_revisions`. Replaying it changes nothing; naming a chat the store does
28
+ * not hold records only the old id, when the new one is stored. Runs inside `write`.
28
29
  */
29
30
  export declare const rekeyChat: (context: StoreContext, key: AccountKey, accountKey: number, from: Id, to: Id) => ChatRekey;
30
31
  /**
@@ -1,6 +1,6 @@
1
1
  import { CliError } from "@wirecat/cli-base";
2
2
  import { formatLocator } from "../../domain/locator.js";
3
- import { purgeChats, stricterPrivacy } from "./chats.js";
3
+ import { purgeChats, revisedBy, stricterPrivacy } from "./chats.js";
4
4
  import { pointerPairs } from "./entity-types.js";
5
5
  import { tombstone } from "./messages.js";
6
6
  import { targetKey } from "./notes.js";
@@ -113,6 +113,7 @@ const absorb = (context, kept, gone) => {
113
113
  foldOpenStays(context, kept, gone);
114
114
  for (const table of [
115
115
  "poll_votes",
116
+ "chat_revisions",
116
117
  "chat_events",
117
118
  "message_transcripts",
118
119
  "chat_members",
@@ -157,15 +158,33 @@ const MESSENGER_FIELDS = [
157
158
  "description",
158
159
  "details_fetched_at",
159
160
  ];
161
+ /**
162
+ * The re-key of a chat stored only under its new id changes no row, so the trigger records nothing; the old id is
163
+ * written by hand, once, so a read by it still finds the chat.
164
+ */
165
+ const recordOldId = ({ database, now }, accountKey, chat, from, to) => {
166
+ database
167
+ .prepare(`INSERT INTO chat_revisions (chat_id, account_id, field, old_value, new_value, source, created_at)
168
+ SELECT ?, ?, 'external_id', ?, ?, 'rekey', ? WHERE NOT EXISTS (SELECT 1 FROM chat_revisions
169
+ WHERE chat_id = ? AND field = 'external_id' AND old_value = ?)`)
170
+ .run(chat, accountKey, from, to, now(), chat, from);
171
+ };
160
172
  /**
161
173
  * A chat the messenger now names by another id: renamed in place, or merged into the row already stored under the
162
- * new id's twin. Replaying it, or naming a chat the store does not hold, changes nothing. Runs inside `write`.
174
+ * new id's twin, each change recorded in `chat_revisions`. Replaying it changes nothing; naming a chat the store does
175
+ * not hold records only the old id, when the new one is stored. Runs inside `write`.
163
176
  */
164
177
  export const rekeyChat = (context, key, accountKey, from, to) => {
165
178
  const old = from === to ? undefined : chatRow(context, accountKey, from);
166
- if (!old)
179
+ const twin = from === to ? undefined : chatRow(context, accountKey, to);
180
+ if (!old) {
181
+ if (twin)
182
+ recordOldId(context, accountKey, Number(twin.id), from, to);
167
183
  return { outcome: "unchanged", messages: 0, duplicates: 0 };
168
- const twin = chatRow(context, accountKey, to);
184
+ }
185
+ return revisedBy(context, twin ? "merge" : "rekey", () => moveChat(context, key, accountKey, old, twin, from, to));
186
+ };
187
+ const moveChat = (context, key, accountKey, old, twin, from, to) => {
169
188
  const kept = Number(old.id);
170
189
  let moved = { messages: 0, duplicates: 0 };
171
190
  if (twin) {
@@ -7,6 +7,19 @@ export declare const chatOf: ({ orm }: StoreContext, accountKey: number, chatKey
7
7
  export declare const findChatPk: ({ orm }: StoreContext, accountKey: number, chatId: Id) => number | undefined;
8
8
  /** A chat known only from its messages, until the chat list names it. */
9
9
  export declare const chatPkFor: (context: StoreContext, accountKey: number, chatId: Id) => number;
10
+ /** The store write a `chat_revisions` row names; a writer that sets none is recorded as NULL. */
11
+ export type ChatRevisionSource = "chats" | "delta" | "left" | "rejoin" | "rekey" | "merge" | "send" | "details";
12
+ /**
13
+ * Runs `body` with `source` as what the `chat_revisions` trigger records, inside the caller's transaction. The row is
14
+ * gone again before the commit, so an older build's write in a later transaction records no source rather than this
15
+ * one; a nested call puts the outer source back.
16
+ */
17
+ export declare const revisedBy: <T>({ database, now }: StoreContext, source: ChatRevisionSource, body: () => T) => T;
18
+ /**
19
+ * The id the chat once stored as `chatId` has now, from its newest re-key in this account; `undefined` while a chat is
20
+ * stored under `chatId` itself, or none ever was.
21
+ */
22
+ export declare const renamedChatId: ({ database }: StoreContext, accountKey: number, chatId: Id) => Id | undefined;
10
23
  interface ChatPrivacy {
11
24
  searchable: number;
12
25
  scope: string | null;
@@ -29,6 +29,38 @@ export const chatPkFor = (context, accountKey, chatId) => findChatPk(context, ac
29
29
  })
30
30
  .returning({ pk: chats.id })
31
31
  .get()?.pk);
32
+ /**
33
+ * Runs `body` with `source` as what the `chat_revisions` trigger records, inside the caller's transaction. The row is
34
+ * gone again before the commit, so an older build's write in a later transaction records no source rather than this
35
+ * one; a nested call puts the outer source back.
36
+ */
37
+ export const revisedBy = ({ database, now }, source, body) => {
38
+ const outer = database.prepare("SELECT source, at FROM chat_revision_source WHERE id = 1").get();
39
+ const set = database.prepare("INSERT OR REPLACE INTO chat_revision_source (id, source, at) VALUES (1, ?, ?)");
40
+ set.run(source, now());
41
+ try {
42
+ return body();
43
+ }
44
+ finally {
45
+ if (outer)
46
+ set.run(String(outer.source), Number(outer.at));
47
+ else
48
+ database.prepare("DELETE FROM chat_revision_source").run();
49
+ }
50
+ };
51
+ /**
52
+ * The id the chat once stored as `chatId` has now, from its newest re-key in this account; `undefined` while a chat is
53
+ * stored under `chatId` itself, or none ever was.
54
+ */
55
+ export const renamedChatId = ({ database }, accountKey, chatId) => {
56
+ const row = database
57
+ .prepare(`SELECT c.external_id AS id FROM chat_revisions r JOIN chats c ON c.id = r.chat_id
58
+ WHERE r.account_id = ? AND r.field = 'external_id' AND r.old_value = ? AND c.account_id = r.account_id
59
+ AND NOT EXISTS (SELECT 1 FROM chats WHERE account_id = ? AND external_id = ?)
60
+ ORDER BY r.created_at DESC, r.id DESC LIMIT 1`)
61
+ .get(accountKey, chatId, accountKey, chatId);
62
+ return row ? String(row.id) : undefined;
63
+ };
32
64
  /**
33
65
  * The owner's word on a chat must not loosen because another row joins it: hidden wins, and when the two chats sit
34
66
  * on different sides of the owner's life, the personal side wins, since a work chat reaches work views and meeting
@@ -70,11 +102,11 @@ export const answeredChatPk = (context, accountKey, chatId, typed) => {
70
102
  if (found !== undefined) {
71
103
  const strict = stricterPrivacy(context, accountKey, found, like);
72
104
  if (strict.searchable !== found.searchable || strict.scope !== found.scope)
73
- orm
105
+ revisedBy(context, "send", () => orm
74
106
  .update(chats)
75
107
  .set({ ...strict, updatedAt: now() })
76
108
  .where(eq(chats.id, found.pk))
77
- .run();
109
+ .run());
78
110
  return found.pk;
79
111
  }
80
112
  return Number(orm
@@ -185,7 +217,7 @@ export const rejoin = ({ orm }, accountKey, present) => orm
185
217
  * list. Answers how many it marked.
186
218
  */
187
219
  export const markLeft = (context, accountKey, present) => {
188
- rejoin(context, accountKey, present);
220
+ revisedBy(context, "rejoin", () => rejoin(context, accountKey, present));
189
221
  return context.orm
190
222
  .update(chats)
191
223
  .set({ membershipState: "left" })
@@ -352,9 +384,9 @@ export const metadata = (context, key, chatId) => {
352
384
  : undefined;
353
385
  };
354
386
  export const saveMetadata = (context, key, entry) => {
355
- context.database
387
+ revisedBy(context, "details", () => context.database
356
388
  .prepare("UPDATE chats SET title=?, username=?, description=?, details_fetched_at=?, updated_at=? WHERE id=?")
357
- .run(entry.title, entry.username, entry.description, context.now(), context.now(), metadataChatPk(context, key, entry.chatId));
389
+ .run(entry.title, entry.username, entry.description, context.now(), context.now(), metadataChatPk(context, key, entry.chatId)));
358
390
  return metadata(context, key, entry.chatId);
359
391
  };
360
392
  /** Called inside the store's transaction: claims and effective search tags change together. */
@@ -152,7 +152,8 @@ export const evidenceFindingsStoreOver = (context) => {
152
152
  return batch;
153
153
  }
154
154
  const input = proposal.payload;
155
- if (snapshot(batch.input) !== input.snapshotHash)
155
+ // Rejecting creates nothing, so a batch whose evidence changed can still be closed.
156
+ if (accepted.length && snapshot(batch.input) !== input.snapshotHash)
156
157
  return stale();
157
158
  if (proposal.proposedBy.type !== "bot" || !proposal.proposedBy.name)
158
159
  return invalid("a findings author must be an agent");
@@ -29,6 +29,8 @@ export const MANIFEST = [
29
29
  { name: "20261018024850_version-23-chat-events", version: 23, minCompatible: 1 },
30
30
  { name: "20261018061512_version-24-message-stars", version: 24, minCompatible: 1 },
31
31
  { name: "20261018063952_version-24-message-stars-index", version: 24, minCompatible: 1 },
32
+ { name: "20261019115025_version-25-chat-revisions", version: 25, minCompatible: 1 },
33
+ { name: "20261019115027_version-25-chat-revisions-triggers", version: 25, minCompatible: 1 },
32
34
  ];
33
35
  export const generatedMigrations = (manifest = MANIFEST, generated = GENERATED) => {
34
36
  const migrations = [];
@@ -182,7 +182,8 @@ export const meetingFindingsStoreOver = (context) => {
182
182
  return batch;
183
183
  }
184
184
  const input = proposal.payload;
185
- if (snapshot(account, batch.input) !== input.snapshotHash)
185
+ // Rejecting creates nothing, so a batch whose evidence changed can still be closed.
186
+ if (accepted.length && snapshot(account, batch.input) !== input.snapshotHash)
186
187
  return stale();
187
188
  if (proposal.proposedBy.type !== "bot" || !proposal.proposedBy.name)
188
189
  return invalid("a findings author must be an agent");
@@ -538,5 +538,21 @@ export const GENERATED = [
538
538
  "statements": [
539
539
  "CREATE INDEX `messages_starred_by_time` ON `messages` (`chat_id`,\"sent_at\" desc) WHERE starred = 1;"
540
540
  ]
541
+ },
542
+ {
543
+ "name": "20261019115025_version-25-chat-revisions",
544
+ "statements": [
545
+ "CREATE TABLE `chat_revision_source` (\n\t`id` integer PRIMARY KEY,\n\t`source` text NOT NULL,\n\t`at` integer NOT NULL\n);",
546
+ "CREATE TABLE `chat_revisions` (\n\t`id` integer PRIMARY KEY,\n\t`chat_id` integer NOT NULL,\n\t`account_id` integer NOT NULL,\n\t`field` text NOT NULL,\n\t`old_value` text,\n\t`new_value` text,\n\t`source` text,\n\t`created_at` integer NOT NULL\n);",
547
+ "CREATE INDEX `chat_revisions_by_chat_id_created_at` ON `chat_revisions` (`chat_id`,`created_at`);",
548
+ "CREATE INDEX `chat_revisions_by_account_id_old_value` ON `chat_revisions` (`account_id`,`old_value`) WHERE field = 'external_id';"
549
+ ]
550
+ },
551
+ {
552
+ "name": "20261019115027_version-25-chat-revisions-triggers",
553
+ "statements": [
554
+ "CREATE TRIGGER chat_revisions_au AFTER UPDATE OF external_id, title, membership_state, scope, searchable, parent_chat_id ON chats BEGIN\n INSERT INTO chat_revisions (chat_id, account_id, field, old_value, new_value, source, created_at)\n SELECT new.id, new.account_id, f.field, f.old_value, f.new_value, s.source,\n coalesce(s.at, CAST((julianday('now') - 2440587.5) * 86400000 AS INTEGER))\n FROM (\n SELECT 'external_id' AS field, old.external_id AS old_value, new.external_id AS new_value\n UNION ALL SELECT 'title', old.title, new.title\n UNION ALL SELECT 'membership_state', old.membership_state, new.membership_state\n UNION ALL SELECT 'scope', old.scope, new.scope\n UNION ALL SELECT 'searchable', old.searchable, new.searchable\n UNION ALL SELECT 'parent_chat_id', old.parent_chat_id, new.parent_chat_id\n ) f\n LEFT JOIN chat_revision_source s ON s.id = 1\n WHERE f.old_value IS NOT f.new_value;\nEND;",
555
+ "CREATE TRIGGER chat_revisions_ad AFTER DELETE ON chats BEGIN\n DELETE FROM chat_revisions WHERE chat_id = old.id;\nEND;"
556
+ ]
541
557
  }
542
558
  ];
@@ -4092,6 +4092,201 @@ export declare const pollVotes: import("drizzle-orm/sqlite-core").SQLiteTableWit
4092
4092
  };
4093
4093
  dialect: 'sqlite';
4094
4094
  }>;
4095
+ /**
4096
+ * One row per field a chat changed, written by a trigger on `chats` in the changing statement's transaction. No foreign
4097
+ * keys: an older build purges chats without knowing this table, and a trigger drops a purged chat's rows instead.
4098
+ */
4099
+ export declare const chatRevisions: import("drizzle-orm/sqlite-core").SQLiteTableWithColumns<{
4100
+ name: "chat_revisions";
4101
+ schema: undefined;
4102
+ columns: {
4103
+ id: import("drizzle-orm/sqlite-core").SQLiteColumn<{
4104
+ name: string;
4105
+ tableName: "chat_revisions";
4106
+ dataType: "number int53";
4107
+ data: number;
4108
+ driverParam: number;
4109
+ notNull: true;
4110
+ hasDefault: true;
4111
+ isPrimaryKey: true;
4112
+ isAutoincrement: false;
4113
+ hasRuntimeDefault: false;
4114
+ enumValues: undefined;
4115
+ baseColumn: never;
4116
+ identity: undefined;
4117
+ generated: undefined;
4118
+ }, {}>;
4119
+ chatId: import("drizzle-orm/sqlite-core").SQLiteColumn<{
4120
+ name: string;
4121
+ tableName: "chat_revisions";
4122
+ dataType: "number int53";
4123
+ data: number;
4124
+ driverParam: number;
4125
+ notNull: true;
4126
+ hasDefault: false;
4127
+ isPrimaryKey: false;
4128
+ isAutoincrement: false;
4129
+ hasRuntimeDefault: false;
4130
+ enumValues: undefined;
4131
+ baseColumn: never;
4132
+ identity: undefined;
4133
+ generated: undefined;
4134
+ }, {}>;
4135
+ accountId: import("drizzle-orm/sqlite-core").SQLiteColumn<{
4136
+ name: string;
4137
+ tableName: "chat_revisions";
4138
+ dataType: "number int53";
4139
+ data: number;
4140
+ driverParam: number;
4141
+ notNull: true;
4142
+ hasDefault: false;
4143
+ isPrimaryKey: false;
4144
+ isAutoincrement: false;
4145
+ hasRuntimeDefault: false;
4146
+ enumValues: undefined;
4147
+ baseColumn: never;
4148
+ identity: undefined;
4149
+ generated: undefined;
4150
+ }, {}>;
4151
+ field: import("drizzle-orm/sqlite-core").SQLiteColumn<{
4152
+ name: string;
4153
+ tableName: "chat_revisions";
4154
+ dataType: "string";
4155
+ data: string;
4156
+ driverParam: string;
4157
+ notNull: true;
4158
+ hasDefault: false;
4159
+ isPrimaryKey: false;
4160
+ isAutoincrement: false;
4161
+ hasRuntimeDefault: false;
4162
+ enumValues: [string, ...string[]];
4163
+ baseColumn: never;
4164
+ identity: undefined;
4165
+ generated: undefined;
4166
+ }, {}>;
4167
+ oldValue: import("drizzle-orm/sqlite-core").SQLiteColumn<{
4168
+ name: string;
4169
+ tableName: "chat_revisions";
4170
+ dataType: "string";
4171
+ data: string;
4172
+ driverParam: string;
4173
+ notNull: false;
4174
+ hasDefault: false;
4175
+ isPrimaryKey: false;
4176
+ isAutoincrement: false;
4177
+ hasRuntimeDefault: false;
4178
+ enumValues: [string, ...string[]];
4179
+ baseColumn: never;
4180
+ identity: undefined;
4181
+ generated: undefined;
4182
+ }, {}>;
4183
+ newValue: import("drizzle-orm/sqlite-core").SQLiteColumn<{
4184
+ name: string;
4185
+ tableName: "chat_revisions";
4186
+ dataType: "string";
4187
+ data: string;
4188
+ driverParam: string;
4189
+ notNull: false;
4190
+ hasDefault: false;
4191
+ isPrimaryKey: false;
4192
+ isAutoincrement: false;
4193
+ hasRuntimeDefault: false;
4194
+ enumValues: [string, ...string[]];
4195
+ baseColumn: never;
4196
+ identity: undefined;
4197
+ generated: undefined;
4198
+ }, {}>;
4199
+ source: import("drizzle-orm/sqlite-core").SQLiteColumn<{
4200
+ name: string;
4201
+ tableName: "chat_revisions";
4202
+ dataType: "string";
4203
+ data: string;
4204
+ driverParam: string;
4205
+ notNull: false;
4206
+ hasDefault: false;
4207
+ isPrimaryKey: false;
4208
+ isAutoincrement: false;
4209
+ hasRuntimeDefault: false;
4210
+ enumValues: [string, ...string[]];
4211
+ baseColumn: never;
4212
+ identity: undefined;
4213
+ generated: undefined;
4214
+ }, {}>;
4215
+ createdAt: import("drizzle-orm/sqlite-core").SQLiteColumn<{
4216
+ name: string;
4217
+ tableName: "chat_revisions";
4218
+ dataType: "number int53";
4219
+ data: number;
4220
+ driverParam: number;
4221
+ notNull: true;
4222
+ hasDefault: false;
4223
+ isPrimaryKey: false;
4224
+ isAutoincrement: false;
4225
+ hasRuntimeDefault: false;
4226
+ enumValues: undefined;
4227
+ baseColumn: never;
4228
+ identity: undefined;
4229
+ generated: undefined;
4230
+ }, {}>;
4231
+ };
4232
+ dialect: 'sqlite';
4233
+ }>;
4234
+ /** The source and time the `chat_revisions` trigger records, set by the store inside its write and gone by commit. */
4235
+ export declare const chatRevisionSource: import("drizzle-orm/sqlite-core").SQLiteTableWithColumns<{
4236
+ name: "chat_revision_source";
4237
+ schema: undefined;
4238
+ columns: {
4239
+ id: import("drizzle-orm/sqlite-core").SQLiteColumn<{
4240
+ name: string;
4241
+ tableName: "chat_revision_source";
4242
+ dataType: "number int53";
4243
+ data: number;
4244
+ driverParam: number;
4245
+ notNull: true;
4246
+ hasDefault: true;
4247
+ isPrimaryKey: true;
4248
+ isAutoincrement: false;
4249
+ hasRuntimeDefault: false;
4250
+ enumValues: undefined;
4251
+ baseColumn: never;
4252
+ identity: undefined;
4253
+ generated: undefined;
4254
+ }, {}>;
4255
+ source: import("drizzle-orm/sqlite-core").SQLiteColumn<{
4256
+ name: string;
4257
+ tableName: "chat_revision_source";
4258
+ dataType: "string";
4259
+ data: string;
4260
+ driverParam: string;
4261
+ notNull: true;
4262
+ hasDefault: false;
4263
+ isPrimaryKey: false;
4264
+ isAutoincrement: false;
4265
+ hasRuntimeDefault: false;
4266
+ enumValues: [string, ...string[]];
4267
+ baseColumn: never;
4268
+ identity: undefined;
4269
+ generated: undefined;
4270
+ }, {}>;
4271
+ at: import("drizzle-orm/sqlite-core").SQLiteColumn<{
4272
+ name: string;
4273
+ tableName: "chat_revision_source";
4274
+ dataType: "number int53";
4275
+ data: number;
4276
+ driverParam: number;
4277
+ notNull: true;
4278
+ hasDefault: false;
4279
+ isPrimaryKey: false;
4280
+ isAutoincrement: false;
4281
+ hasRuntimeDefault: false;
4282
+ enumValues: undefined;
4283
+ baseColumn: never;
4284
+ identity: undefined;
4285
+ generated: undefined;
4286
+ }, {}>;
4287
+ };
4288
+ dialect: 'sqlite';
4289
+ }>;
4095
4290
  /** Changes to who is in a chat, and to its title, for a messenger whose history is pushed to the store. */
4096
4291
  export declare const chatEvents: import("drizzle-orm/sqlite-core").SQLiteTableWithColumns<{
4097
4292
  name: "chat_events";
@@ -457,6 +457,31 @@ export const pollVotes = sqliteTable("poll_votes", {
457
457
  unique().on(table.chatId, table.messageExternalId, table.identityId),
458
458
  index("poll_votes_by_identity_id").on(table.identityId),
459
459
  ]);
460
+ /**
461
+ * One row per field a chat changed, written by a trigger on `chats` in the changing statement's transaction. No foreign
462
+ * keys: an older build purges chats without knowing this table, and a trigger drops a purged chat's rows instead.
463
+ */
464
+ export const chatRevisions = sqliteTable("chat_revisions", {
465
+ id: integer("id").primaryKey(),
466
+ chatId: integer("chat_id").notNull(),
467
+ accountId: integer("account_id").notNull(),
468
+ field: text("field").notNull(),
469
+ oldValue: text("old_value"),
470
+ newValue: text("new_value"),
471
+ source: text("source"),
472
+ createdAt: integer("created_at").notNull(),
473
+ }, (table) => [
474
+ index("chat_revisions_by_chat_id_created_at").on(table.chatId, table.createdAt),
475
+ index("chat_revisions_by_account_id_old_value")
476
+ .on(table.accountId, table.oldValue)
477
+ .where(sql `field = 'external_id'`),
478
+ ]);
479
+ /** The source and time the `chat_revisions` trigger records, set by the store inside its write and gone by commit. */
480
+ export const chatRevisionSource = sqliteTable("chat_revision_source", {
481
+ id: integer("id").primaryKey(),
482
+ source: text("source").notNull(),
483
+ at: integer("at").notNull(),
484
+ });
460
485
  /** Changes to who is in a chat, and to its title, for a messenger whose history is pushed to the store. */
461
486
  export const chatEvents = sqliteTable("chat_events", {
462
487
  id: integer("id").primaryKey(),
@@ -20,8 +20,10 @@ export const syncStoreOver = (context) => {
20
20
  },
21
21
  applyDelta: async (key, { chats = [], people = [], members = new Map(), state = {} }) => write(database, () => {
22
22
  const accountKey = accountPk(key);
23
- for (const chat of chats)
24
- chatQueries.upsertChat(context, accountKey, chat);
23
+ chatQueries.revisedBy(context, "delta", () => {
24
+ for (const chat of chats)
25
+ chatQueries.upsertChat(context, accountKey, chat);
26
+ });
25
27
  for (const person of people)
26
28
  identityPk(accountKey, key.provider, person.id, person.name, person);
27
29
  for (const [chatId, ids] of members)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wirecat/cli-core",
3
- "version": "0.364.0",
3
+ "version": "0.366.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",