@wirecat/cli-core 0.380.0 → 0.382.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 +63 -1
- package/dist/cli/bot/watch.js +4 -1
- package/dist/cli/index.d.ts +1 -1
- package/dist/cli/messenger/chat-answers.d.ts +2 -0
- package/dist/cli/messenger/chat-answers.js +2 -0
- package/dist/cli/messenger/chat-lists.d.ts +2 -0
- package/dist/cli/messenger/context.d.ts +2 -2
- package/dist/cli/messenger/context.js +2 -1
- package/dist/cli/messenger/conversation-answers.d.ts +1 -0
- package/dist/cli/messenger/message-answers.d.ts +1 -0
- package/dist/cli/messenger/message-items.js +1 -0
- package/dist/cli/messenger/message-writes.d.ts +1 -0
- package/dist/cli/messenger/serve-relay.d.ts +1 -0
- package/dist/cli/messenger/stored.d.ts +3 -1
- package/dist/cli/messenger/stored.js +24 -12
- package/dist/cli/messenger/watch-command.js +8 -0
- package/dist/domain/models.d.ts +52 -4
- package/dist/entities/chat.d.ts +4 -0
- package/dist/entities/chat.js +4 -0
- package/dist/entities/contact.d.ts +4 -0
- package/dist/entities/evidence.d.ts +8 -0
- package/dist/entities/index.d.ts +17 -0
- package/dist/entities/message.d.ts +2 -0
- package/dist/entities/message.js +2 -0
- package/dist/errors/reasons.js +1 -0
- package/dist/meetings/answers.d.ts +1 -0
- package/dist/meetings/import.js +1 -0
- package/dist/meetings/index.d.ts +1 -1
- package/dist/meetings/index.js +1 -1
- package/dist/meetings/item.d.ts +10 -0
- package/dist/meetings/item.js +34 -18
- package/dist/meetings/store.d.ts +6 -0
- package/dist/ports/messenger.d.ts +10 -3
- package/dist/ports/traits.d.ts +6 -0
- package/dist/resolve.d.ts +6 -0
- package/dist/resolve.js +13 -4
- package/dist/search/garbled.d.ts +2 -1
- package/dist/search/garbled.js +6 -1
- package/dist/services/chats.js +16 -2
- package/dist/services/deps.d.ts +2 -0
- package/dist/services/deps.js +2 -0
- package/dist/services/inbox.d.ts +1 -0
- package/dist/services/inbox.js +10 -3
- package/dist/store/keys.d.ts +1 -1
- package/dist/store/messages.d.ts +22 -3
- package/dist/store/messages.js +22 -3
- package/dist/store/sqlite/accounts.js +2 -1
- package/dist/store/sqlite/chat-rekey.js +18 -2
- package/dist/store/sqlite/chats.d.ts +2 -2
- package/dist/store/sqlite/chats.js +39 -9
- package/dist/store/sqlite/manifest.js +3 -0
- package/dist/store/sqlite/meeting-hit-query.js +18 -3
- package/dist/store/sqlite/meetings.js +56 -1
- package/dist/store/sqlite/messages.d.ts +19 -4
- package/dist/store/sqlite/messages.js +103 -5
- package/dist/store/sqlite/migrations.generated.js +23 -0
- package/dist/store/sqlite/open-store.js +16 -5
- package/dist/store/sqlite/reads.d.ts +50 -0
- package/dist/store/sqlite/reads.js +3 -1
- package/dist/store/sqlite/schema.d.ts +251 -0
- package/dist/store/sqlite/schema.js +28 -0
- package/dist/store/sqlite/search.d.ts +33 -0
- package/dist/store/sqlite/values.d.ts +4 -0
- package/dist/store/sqlite/values.js +3 -0
- package/package.json +1 -1
package/dist/store/messages.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { CounterObservations } from "../domain/counters.js";
|
|
2
|
-
import type { Id, Message, Page, PollVote, Reactions, WindowedMessage } from "../domain/models.js";
|
|
2
|
+
import type { Delivery, Id, Message, Page, PersonReaction, PollVote, Reactions, WindowedMessage } from "../domain/models.js";
|
|
3
3
|
import { type DocumentLinkMessage } from "./sqlite/document-links.js";
|
|
4
4
|
import type { StoreContext } from "./sqlite/open.js";
|
|
5
5
|
import type { AccountKey, DeletionScope, IngestedVia, MessageFilter, SenderChatStats, StoredHit } from "./types.js";
|
|
@@ -10,10 +10,14 @@ export interface AccountMessages {
|
|
|
10
10
|
* `seenAt` is when the messenger was asked: a message it still returned is not deleted, so a
|
|
11
11
|
* tombstone older than that is lifted. A tombstone set after it stays — the deletion is newer.
|
|
12
12
|
*
|
|
13
|
-
* `votes` and `stars` are saved after the messages in the same transaction, as `saveVote
|
|
13
|
+
* `votes`, `reactions` and `stars` are saved after the messages in the same transaction, as `saveVote`,
|
|
14
|
+
* `saveReaction` and `saveStar` save each.
|
|
14
15
|
*
|
|
15
16
|
* `typedChatId` is the id the owner wrote to when the messenger answered `chatId`: a chat row made for `chatId`
|
|
16
17
|
* takes the typed row's title, kind and privacy, and is never more visible than it.
|
|
18
|
+
*
|
|
19
|
+
* `countUnread` raises the chat's unread count by each message from someone else stored for the first time and sent
|
|
20
|
+
* after the messenger's count was taken — for a messenger whose store counts (`MessengerTraits.unreadCounts`).
|
|
17
21
|
*/
|
|
18
22
|
saveMessages(key: AccountKey, chatId: Id, messages: Message[], options: {
|
|
19
23
|
via: IngestedVia;
|
|
@@ -22,11 +26,16 @@ export interface AccountMessages {
|
|
|
22
26
|
messageId: Id;
|
|
23
27
|
vote: PollVote;
|
|
24
28
|
}[];
|
|
29
|
+
reactions?: {
|
|
30
|
+
messageId: Id;
|
|
31
|
+
reaction: PersonReaction;
|
|
32
|
+
}[];
|
|
25
33
|
stars?: {
|
|
26
34
|
messageId: Id;
|
|
27
35
|
starred: boolean;
|
|
28
36
|
}[];
|
|
29
37
|
typedChatId?: Id;
|
|
38
|
+
countUnread?: boolean;
|
|
30
39
|
}): Promise<void>;
|
|
31
40
|
/** Oldest to newest, like a provider's history page. */
|
|
32
41
|
/**
|
|
@@ -89,12 +98,22 @@ export interface AccountMessages {
|
|
|
89
98
|
chatId?: Id;
|
|
90
99
|
among?: DeletionScope;
|
|
91
100
|
}): Promise<number>;
|
|
92
|
-
/**
|
|
101
|
+
/**
|
|
102
|
+
* A message's reactions as they are now; answers whether the message is held at all. Ignored where the store holds
|
|
103
|
+
* people's own reactions to it (`saveReaction`): the totals come from those.
|
|
104
|
+
*/
|
|
93
105
|
saveReactions(key: AccountKey, chatId: Id, messageId: Id, reactions: Reactions, options?: {
|
|
94
106
|
observation?: CounterObservations["reactions"];
|
|
95
107
|
}): Promise<boolean>;
|
|
108
|
+
/**
|
|
109
|
+
* One person's reaction to a message, unless the store holds a newer one of theirs; a `null` reaction is a take-back.
|
|
110
|
+
* The message's `reactions` become the totals of every person's newest, whenever the message is held.
|
|
111
|
+
*/
|
|
112
|
+
saveReaction(key: AccountKey, chatId: Id, messageId: Id, reaction: PersonReaction): Promise<void>;
|
|
96
113
|
/** The owner's star on a message, or its removal; answers whether the message is held at all. */
|
|
97
114
|
saveStar(key: AccountKey, chatId: Id, messageId: Id, starred: boolean): Promise<boolean>;
|
|
115
|
+
/** A receipt for the owner's own message; the state never moves back. Answers whether such a message is held. */
|
|
116
|
+
saveDelivery(key: AccountKey, chatId: Id, messageId: Id, delivery: Delivery): Promise<boolean>;
|
|
98
117
|
/** One voter's vote in the poll a message carries, unless the store holds a newer one of theirs; no answers is a take-back. */
|
|
99
118
|
saveVote(key: AccountKey, chatId: Id, messageId: Id, vote: PollVote): Promise<void>;
|
|
100
119
|
/** Each voter's newest vote in the poll a message carries, newest first. */
|
package/dist/store/messages.js
CHANGED
|
@@ -11,15 +11,19 @@ export const accountMessagesOver = (context) => {
|
|
|
11
11
|
const { database } = context;
|
|
12
12
|
const { accountPk, findAccountPk, findChatPk, chatKeyOf, chatPkFor, answeredChatPk, upsertMessage } = keysOver(context);
|
|
13
13
|
return {
|
|
14
|
-
saveMessages: async (key, chatId, messages, { via, seenAt, votes = [], stars = [], typedChatId }) => write(database, () => {
|
|
14
|
+
saveMessages: async (key, chatId, messages, { via, seenAt, votes = [], reactions = [], stars = [], typedChatId, countUnread = false }) => write(database, () => {
|
|
15
15
|
const accountKey = accountPk(key);
|
|
16
16
|
const chatKey = typedChatId === undefined ? chatPkFor(accountKey, chatId) : answeredChatPk(accountKey, chatId, typedChatId);
|
|
17
17
|
for (const message of messages) {
|
|
18
|
-
if (message.scheduledFor
|
|
19
|
-
|
|
18
|
+
if (message.scheduledFor !== undefined)
|
|
19
|
+
continue;
|
|
20
|
+
if (upsertMessage(key, accountKey, chatKey, message, via, seenAt) && countUnread)
|
|
21
|
+
messageWrites.countUnread(context, chatKey, message);
|
|
20
22
|
}
|
|
21
23
|
for (const { messageId, vote } of votes)
|
|
22
24
|
messageWrites.saveVote(context, accountKey, key.provider, chatKey, messageId, vote);
|
|
25
|
+
for (const { messageId, reaction } of reactions)
|
|
26
|
+
messageWrites.saveReaction(context, accountKey, key.provider, chatKey, messageId, reaction);
|
|
23
27
|
for (const { messageId, starred } of stars)
|
|
24
28
|
messageWrites.saveStar(context, chatKey, messageId, starred);
|
|
25
29
|
}),
|
|
@@ -83,6 +87,21 @@ export const accountMessagesOver = (context) => {
|
|
|
83
87
|
});
|
|
84
88
|
return saved;
|
|
85
89
|
},
|
|
90
|
+
saveDelivery: async (key, chatId, messageId, delivery) => {
|
|
91
|
+
const accountKey = findAccountPk(key);
|
|
92
|
+
const chatKey = accountKey === undefined ? undefined : findChatPk(accountKey, chatId);
|
|
93
|
+
if (chatKey === undefined)
|
|
94
|
+
return false;
|
|
95
|
+
let saved = false;
|
|
96
|
+
write(database, () => {
|
|
97
|
+
saved = messageWrites.saveDelivery(context, chatKey, messageId, delivery);
|
|
98
|
+
});
|
|
99
|
+
return saved;
|
|
100
|
+
},
|
|
101
|
+
saveReaction: async (key, chatId, messageId, reaction) => write(database, () => {
|
|
102
|
+
const accountKey = accountPk(key);
|
|
103
|
+
messageWrites.saveReaction(context, accountKey, key.provider, chatPkFor(accountKey, chatId), messageId, reaction);
|
|
104
|
+
}),
|
|
86
105
|
saveVote: async (key, chatId, messageId, vote) => write(database, () => {
|
|
87
106
|
const accountKey = accountPk(key);
|
|
88
107
|
messageWrites.saveVote(context, accountKey, key.provider, chatPkFor(accountKey, chatId), messageId, vote);
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { CliError } from "@wirecat/cli-base";
|
|
2
2
|
import { and, asc, eq, inArray, sql } from "./drizzle/core.js";
|
|
3
3
|
import { pointerPairs } from "./entity-types.js";
|
|
4
|
-
import { accountIdentities, accounts, aliases, botUpdates, chatEventPeople, chatEvents, chatMembers, chats, chunks, fetchLeases, involvements, memberCounts, memberObservationMembers, memberObservations, memberStays, messageRevisions, messages, messageTranscripts, pollVotes, projects, syncCursors, syncRanges, syncs, } from "./schema.js";
|
|
4
|
+
import { accountIdentities, accounts, aliases, botUpdates, chatEventPeople, chatEvents, chatMembers, chats, chunks, fetchLeases, involvements, memberCounts, memberObservationMembers, memberObservations, memberStays, messageReactions, messageRevisions, messages, messageTranscripts, pollVotes, projects, syncCursors, syncRanges, syncs, } from "./schema.js";
|
|
5
5
|
/** An account's row, made on first sight; a name only replaces what was known when there is one. */
|
|
6
6
|
export const accountPk = ({ orm, now }, { provider, account, scope }, name = null) => Number(orm
|
|
7
7
|
.insert(accounts)
|
|
@@ -67,6 +67,7 @@ export const purgeAccount = ({ orm, database }, accountKey) => {
|
|
|
67
67
|
orm.delete(syncs).where(eq(syncs.accountId, accountKey)).run();
|
|
68
68
|
orm.delete(messageTranscripts).where(inArray(messageTranscripts.chatId, chatsOf)).run();
|
|
69
69
|
orm.delete(pollVotes).where(inArray(pollVotes.chatId, chatsOf)).run();
|
|
70
|
+
orm.delete(messageReactions).where(inArray(messageReactions.chatId, chatsOf)).run();
|
|
70
71
|
orm
|
|
71
72
|
.delete(chatEventPeople)
|
|
72
73
|
.where(inArray(chatEventPeople.chatEventId, orm.select({ id: chatEvents.id }).from(chatEvents).where(inArray(chatEvents.chatId, chatsOf))))
|
|
@@ -2,7 +2,7 @@ import { CliError } from "@wirecat/cli-base";
|
|
|
2
2
|
import { formatLocator } from "../../domain/locator.js";
|
|
3
3
|
import { purgeChats, revisedBy, stricterPrivacy } from "./chats.js";
|
|
4
4
|
import { pointerPairs } from "./entity-types.js";
|
|
5
|
-
import { tombstone } from "./messages.js";
|
|
5
|
+
import { tombstone, writeDerivedReactions } from "./messages.js";
|
|
6
6
|
import { targetKey } from "./notes.js";
|
|
7
7
|
import { write } from "./transaction.js";
|
|
8
8
|
const chatRow = (context, accountKey, chatId) => context.database.prepare("SELECT * FROM chats WHERE account_id = ? AND external_id = ?").get(accountKey, chatId);
|
|
@@ -86,10 +86,13 @@ const absorb = (context, kept, gone) => {
|
|
|
86
86
|
const threads = database.prepare("UPDATE messages SET thread_root_id = ? WHERE thread_root_id = ?");
|
|
87
87
|
// Without a time on either star, the kept copy's word stands and the absorbed one only fills a gap.
|
|
88
88
|
const star = database.prepare("UPDATE messages SET starred = (SELECT starred FROM messages WHERE id = ?) WHERE id = ? AND starred IS NULL");
|
|
89
|
+
// A receipt only moves a message forward, so the copy that got further wins.
|
|
90
|
+
const delivery = database.prepare("UPDATE messages SET delivery = nullif(max(coalesce(delivery, 0), coalesce((SELECT delivery FROM messages WHERE id = ?), 0)), 0) WHERE id = ? AND outgoing = 1");
|
|
89
91
|
const links = ["message_id", "parent_id"].map((column) => database.prepare(`UPDATE OR IGNORE message_links SET ${column} = ? WHERE ${column} = ?`));
|
|
90
92
|
for (const pair of pairs) {
|
|
91
93
|
threads.run(pair.kept, pair.gone);
|
|
92
94
|
star.run(pair.gone, pair.kept);
|
|
95
|
+
delivery.run(pair.gone, pair.kept);
|
|
93
96
|
// Both copies become tombstones before anything moves, so only what a deletion keeps (the owner's links) moves.
|
|
94
97
|
if (pair.deleted) {
|
|
95
98
|
tombstone(context, pair.gone);
|
|
@@ -106,6 +109,9 @@ const absorb = (context, kept, gone) => {
|
|
|
106
109
|
database
|
|
107
110
|
.prepare("DELETE FROM poll_votes WHERE chat_id = ? AND EXISTS (SELECT 1 FROM poll_votes g WHERE g.chat_id = ? AND g.message_external_id = poll_votes.message_external_id AND g.identity_id = poll_votes.identity_id AND g.voted_at > poll_votes.voted_at)")
|
|
108
111
|
.run(kept, gone);
|
|
112
|
+
database
|
|
113
|
+
.prepare("DELETE FROM message_reactions WHERE chat_id = ? AND EXISTS (SELECT 1 FROM message_reactions g WHERE g.chat_id = ? AND g.message_external_id = message_reactions.message_external_id AND g.identity_id = message_reactions.identity_id AND g.reacted_at > message_reactions.reacted_at)")
|
|
114
|
+
.run(kept, gone);
|
|
109
115
|
// The absorbed copy of an event both chats hold is purged, so the people only it named move first.
|
|
110
116
|
database
|
|
111
117
|
.prepare("INSERT OR IGNORE INTO chat_event_people (chat_event_id, identity_id) SELECT k.id, p.identity_id FROM chat_events g JOIN chat_events k ON k.chat_id = ? AND k.source_id = g.source_id JOIN chat_event_people p ON p.chat_event_id = g.id WHERE g.chat_id = ?")
|
|
@@ -113,6 +119,7 @@ const absorb = (context, kept, gone) => {
|
|
|
113
119
|
foldOpenStays(context, kept, gone);
|
|
114
120
|
for (const table of [
|
|
115
121
|
"poll_votes",
|
|
122
|
+
"message_reactions",
|
|
116
123
|
"chat_revisions",
|
|
117
124
|
"chat_events",
|
|
118
125
|
"message_transcripts",
|
|
@@ -131,6 +138,10 @@ const absorb = (context, kept, gone) => {
|
|
|
131
138
|
database
|
|
132
139
|
.prepare("DELETE FROM message_transcripts WHERE chat_id = ? AND message_id IN (SELECT id FROM messages WHERE chat_id = ? AND deleted_at IS NOT NULL)")
|
|
133
140
|
.run(kept, kept);
|
|
141
|
+
for (const { id } of database
|
|
142
|
+
.prepare("SELECT DISTINCT message_external_id AS id FROM message_reactions WHERE chat_id = ?")
|
|
143
|
+
.all(kept))
|
|
144
|
+
writeDerivedReactions(context, kept, id);
|
|
134
145
|
database.prepare("UPDATE chats SET parent_chat_id = ? WHERE parent_chat_id = ?").run(kept, gone);
|
|
135
146
|
pointerMover(context, "chat")(gone, kept);
|
|
136
147
|
purgeChats(context, [gone]);
|
|
@@ -152,6 +163,10 @@ const privacyOf = (row) => ({
|
|
|
152
163
|
const MESSENGER_FIELDS = [
|
|
153
164
|
"title",
|
|
154
165
|
"unread_count",
|
|
166
|
+
"unread_counted_at",
|
|
167
|
+
"muted",
|
|
168
|
+
"archived",
|
|
169
|
+
"marked_unread",
|
|
155
170
|
"participants_count",
|
|
156
171
|
"metadata",
|
|
157
172
|
"username",
|
|
@@ -198,8 +213,9 @@ const moveChat = (context, key, accountKey, old, twin, from, to) => {
|
|
|
198
213
|
// Left only when both were: a complete chat list that named the new id before the re-key marked the old one.
|
|
199
214
|
`UPDATE chats SET ${fields}, kind = CASE WHEN kind = 'unknown' THEN ? ELSE kind END,
|
|
200
215
|
membership_state = CASE WHEN ? IS NULL THEN nullif(membership_state, 'left') ELSE coalesce(nullif(?, 'left'), membership_state) END,
|
|
216
|
+
muted_until = CASE WHEN ? IS NULL THEN muted_until ELSE ? END,
|
|
201
217
|
searchable = ?, scope = ?, last_message_at = ?, updated_at = ? WHERE id = ?`)
|
|
202
|
-
.run(...MESSENGER_FIELDS.map((field) => scalar(twin[field])), scalar(twin.kind), scalar(twin.membership_state), scalar(twin.membership_state), strict.searchable, strict.scope, latest(old.last_message_at, twin.last_message_at), context.now(), kept);
|
|
218
|
+
.run(...MESSENGER_FIELDS.map((field) => scalar(twin[field])), scalar(twin.kind), scalar(twin.membership_state), scalar(twin.membership_state), scalar(twin.muted), scalar(twin.muted_until), strict.searchable, strict.scope, latest(old.last_message_at, twin.last_message_at), context.now(), kept);
|
|
203
219
|
}
|
|
204
220
|
context.database.prepare("UPDATE chats SET external_id = ?, updated_at = ? WHERE id = ?").run(to, context.now(), kept);
|
|
205
221
|
renameText(context, key, accountKey, from, to);
|
|
@@ -61,7 +61,7 @@ export declare const leftChats: ({ orm }: StoreContext, accountKey: number) => {
|
|
|
61
61
|
};
|
|
62
62
|
/** These chats and everything under them, children before parents: the foreign keys are enforced. */
|
|
63
63
|
export declare const purgeChats: ({ orm }: StoreContext, pks: number[]) => void;
|
|
64
|
-
export declare const chatsWith: ({ orm }: StoreContext, accountKey: number, key: AccountKey, memberId: Id) => Chat[];
|
|
64
|
+
export declare const chatsWith: ({ orm, now }: StoreContext, accountKey: number, key: AccountKey, memberId: Id) => Chat[];
|
|
65
65
|
/**
|
|
66
66
|
* How the store answers a chat list's query. `title:` goes through the trigram index, so it matches part of a
|
|
67
67
|
* title, three characters or more, whatever the case.
|
|
@@ -96,7 +96,7 @@ export declare const CHAT_COLUMNS: {
|
|
|
96
96
|
};
|
|
97
97
|
/** `title:*book*` and `title:book` ask the same: the title holds "book". */
|
|
98
98
|
export declare const titlePart: (value: string) => string;
|
|
99
|
-
export declare const listChats: ({ orm }: StoreContext, accountKey: number, { limit, offset, ...filter }: {
|
|
99
|
+
export declare const listChats: ({ orm, now }: StoreContext, accountKey: number, { limit, offset, ...filter }: {
|
|
100
100
|
limit?: number;
|
|
101
101
|
offset?: number;
|
|
102
102
|
} & StoredChatFilter) => Page<Chat>;
|
|
@@ -2,7 +2,7 @@ import { CliError } from "@wirecat/cli-base";
|
|
|
2
2
|
import { and, eq, inArray, or, sql } from "./drizzle/core.js";
|
|
3
3
|
import { identityPk } from "./identities.js";
|
|
4
4
|
import { bound } from "./query-filter.js";
|
|
5
|
-
import { chatEventPeople, chatEvents, chatMembers, chats, chunks, fetchLeases, identities, involvements, memberCounts, memberObservationMembers, memberObservations, memberStays, messageRevisions, messages, messageTranscripts, pollVotes, syncRanges, } from "./schema.js";
|
|
5
|
+
import { chatEventPeople, chatEvents, chatMembers, chats, chunks, fetchLeases, identities, involvements, memberCounts, memberObservationMembers, memberObservations, memberStays, messageReactions, messageRevisions, messages, messageTranscripts, pollVotes, syncRanges, } from "./schema.js";
|
|
6
6
|
import { ensureTag } from "./tags.js";
|
|
7
7
|
import { parsed, present, toIso, toMs } from "./values.js";
|
|
8
8
|
/** The chat's own id, when the store key is a chat of this account. */
|
|
@@ -137,6 +137,7 @@ export const upsertChat = (context, accountKey, chat) => {
|
|
|
137
137
|
participantsCount: chat.participantsCount ?? null,
|
|
138
138
|
metadata: mergedMetadata(stored?.metadata ?? null, chat.providerMetadata),
|
|
139
139
|
membershipState: chat.membershipState === "member" ? null : (chat.membershipState ?? null),
|
|
140
|
+
...stateOf(chat, now()),
|
|
140
141
|
createdAt: now(),
|
|
141
142
|
updatedAt: now(),
|
|
142
143
|
})
|
|
@@ -147,16 +148,33 @@ export const upsertChat = (context, accountKey, chat) => {
|
|
|
147
148
|
...(chat.parentChatId === undefined ? {} : { parentChatId: sql `excluded.parent_chat_id` }),
|
|
148
149
|
scope: sql `coalesce(excluded.scope, ${chats.scope})`,
|
|
149
150
|
...(chat.title === undefined ? {} : { title: sql `excluded.title` }),
|
|
150
|
-
...(chat.unreadCount === undefined
|
|
151
|
+
...(chat.unreadCount === undefined
|
|
152
|
+
? {}
|
|
153
|
+
: { unreadCount: sql `excluded.unread_count`, unreadCountedAt: sql `excluded.unread_counted_at` }),
|
|
151
154
|
...(chat.lastMessageAt === undefined ? {} : { lastMessageAt: sql `excluded.last_message_at` }),
|
|
152
155
|
...(chat.participantsCount === undefined ? {} : { participantsCount: sql `excluded.participants_count` }),
|
|
153
156
|
metadata: sql `excluded.metadata`,
|
|
154
157
|
...membershipSet(chat.membershipState),
|
|
158
|
+
...(chat.muted === undefined ? {} : { muted: sql `excluded.muted`, mutedUntil: sql `excluded.muted_until` }),
|
|
159
|
+
...(chat.archived === undefined ? {} : { archived: sql `excluded.archived` }),
|
|
160
|
+
...(chat.markedUnread === undefined ? {} : { markedUnread: sql `excluded.marked_unread` }),
|
|
155
161
|
updatedAt: sql `excluded.updated_at`,
|
|
156
162
|
},
|
|
157
163
|
})
|
|
158
164
|
.run();
|
|
159
165
|
};
|
|
166
|
+
const bit = (value) => (value === undefined ? null : value ? 1 : 0);
|
|
167
|
+
/**
|
|
168
|
+
* The count is taken as of the chat's newest message where the update names it, else as of now: a message the store
|
|
169
|
+
* saves later and sent after that point is one the count does not hold yet.
|
|
170
|
+
*/
|
|
171
|
+
const stateOf = (chat, at) => ({
|
|
172
|
+
unreadCountedAt: chat.unreadCount === undefined ? null : (toMs(chat.lastMessageAt) ?? at),
|
|
173
|
+
muted: bit(chat.muted),
|
|
174
|
+
mutedUntil: chat.muted === true ? toMs(chat.mutedUntil) : null,
|
|
175
|
+
archived: bit(chat.archived),
|
|
176
|
+
markedUnread: bit(chat.markedUnread),
|
|
177
|
+
});
|
|
160
178
|
const membershipSet = (state) => {
|
|
161
179
|
if (state === undefined)
|
|
162
180
|
return {};
|
|
@@ -262,6 +280,7 @@ export const purgeChats = ({ orm }, pks) => {
|
|
|
262
280
|
.run();
|
|
263
281
|
orm.delete(messageTranscripts).where(inArray(messageTranscripts.chatId, pks)).run();
|
|
264
282
|
orm.delete(pollVotes).where(inArray(pollVotes.chatId, pks)).run();
|
|
283
|
+
orm.delete(messageReactions).where(inArray(messageReactions.chatId, pks)).run();
|
|
265
284
|
orm
|
|
266
285
|
.delete(chatEventPeople)
|
|
267
286
|
.where(inArray(chatEventPeople.chatEventId, orm.select({ id: chatEvents.id }).from(chatEvents).where(inArray(chatEvents.chatId, pks))))
|
|
@@ -285,7 +304,7 @@ export const purgeChats = ({ orm }, pks) => {
|
|
|
285
304
|
orm.delete(messages).where(inArray(messages.chatId, pks)).run();
|
|
286
305
|
orm.delete(chats).where(inArray(chats.id, pks)).run();
|
|
287
306
|
};
|
|
288
|
-
export const chatsWith = ({ orm }, accountKey, key, memberId) => {
|
|
307
|
+
export const chatsWith = ({ orm, now }, accountKey, key, memberId) => {
|
|
289
308
|
const rows = orm
|
|
290
309
|
.select({ chat: chats })
|
|
291
310
|
.from(chats)
|
|
@@ -299,7 +318,7 @@ export const chatsWith = ({ orm }, accountKey, key, memberId) => {
|
|
|
299
318
|
.orderBy(...byRecency)
|
|
300
319
|
.all()
|
|
301
320
|
.map(({ chat }) => chat);
|
|
302
|
-
return toChats(orm, rows);
|
|
321
|
+
return toChats(orm, rows, now());
|
|
303
322
|
};
|
|
304
323
|
const chatsWhere = (accountKey, { where, includeLeft }) => and(eq(chats.accountId, accountKey), includeLeft ? undefined : notLeft, where === undefined ? undefined : bound(where));
|
|
305
324
|
/**
|
|
@@ -323,7 +342,7 @@ export const CHAT_COLUMNS = {
|
|
|
323
342
|
};
|
|
324
343
|
/** `title:*book*` and `title:book` ask the same: the title holds "book". */
|
|
325
344
|
export const titlePart = (value) => value.replace(/^\*+|\*+$/g, "").trim();
|
|
326
|
-
export const listChats = ({ orm }, accountKey, { limit, offset = 0, ...filter }) => {
|
|
345
|
+
export const listChats = ({ orm, now }, accountKey, { limit, offset = 0, ...filter }) => {
|
|
327
346
|
const rows = orm
|
|
328
347
|
.select()
|
|
329
348
|
.from(chats)
|
|
@@ -332,10 +351,10 @@ export const listChats = ({ orm }, accountKey, { limit, offset = 0, ...filter })
|
|
|
332
351
|
.limit(limit === undefined ? -1 : limit + 1)
|
|
333
352
|
.offset(offset)
|
|
334
353
|
.all();
|
|
335
|
-
return { items: toChats(orm, rows.slice(0, limit)), hasMore: limit !== undefined && rows.length > limit };
|
|
354
|
+
return { items: toChats(orm, rows.slice(0, limit), now()), hasMore: limit !== undefined && rows.length > limit };
|
|
336
355
|
};
|
|
337
356
|
export const countChats = ({ orm }, accountKey, filter) => Number(orm.select({ n: sql `count(*)` }).from(chats).where(chatsWhere(accountKey, filter)).get()?.n);
|
|
338
|
-
const toChats = (orm, rows) => {
|
|
357
|
+
const toChats = (orm, rows, at) => {
|
|
339
358
|
const ids = [...new Set(rows.flatMap((row) => (row.parentChatId === null ? [] : [row.parentChatId])))];
|
|
340
359
|
const parents = new Map(ids.length === 0
|
|
341
360
|
? []
|
|
@@ -345,9 +364,10 @@ const toChats = (orm, rows) => {
|
|
|
345
364
|
.where(inArray(chats.id, ids))
|
|
346
365
|
.all()
|
|
347
366
|
.map((row) => [row.pk, row.externalId]));
|
|
348
|
-
return rows.map((row) => toChat(row, row.parentChatId === null ? undefined : parents.get(row.parentChatId)));
|
|
367
|
+
return rows.map((row) => toChat(row, at, row.parentChatId === null ? undefined : parents.get(row.parentChatId)));
|
|
349
368
|
};
|
|
350
|
-
const
|
|
369
|
+
const flag = (stored) => (stored === null ? undefined : stored === 1);
|
|
370
|
+
const toChat = (row, at, parentId) => ({
|
|
351
371
|
id: row.externalId,
|
|
352
372
|
title: row.title,
|
|
353
373
|
kind: row.kind,
|
|
@@ -360,7 +380,17 @@ const toChat = (row, parentId) => ({
|
|
|
360
380
|
membershipState: row.membershipState,
|
|
361
381
|
providerMetadata: parsed(row.metadata),
|
|
362
382
|
}),
|
|
383
|
+
...muteOf(row, at),
|
|
384
|
+
...present({ archived: flag(row.archived), markedUnread: flag(row.markedUnread) }),
|
|
363
385
|
});
|
|
386
|
+
// A mute is read against the clock, so one that ran out needs no write to end.
|
|
387
|
+
const muteOf = ({ muted, mutedUntil }, at) => {
|
|
388
|
+
if (muted === null)
|
|
389
|
+
return {};
|
|
390
|
+
if (muted !== 1 || (mutedUntil !== null && mutedUntil <= at))
|
|
391
|
+
return { muted: false };
|
|
392
|
+
return mutedUntil === null ? { muted: true } : { muted: true, mutedUntil: toIso(mutedUntil) };
|
|
393
|
+
};
|
|
364
394
|
const metadataChatPk = ({ database }, key, chatId) => {
|
|
365
395
|
const row = database
|
|
366
396
|
.prepare("SELECT c.id FROM chats c JOIN accounts a ON a.id=c.account_id WHERE a.provider=? AND a.external_id=? AND c.external_id=?")
|
|
@@ -31,6 +31,9 @@ export const MANIFEST = [
|
|
|
31
31
|
{ name: "20261018063952_version-24-message-stars-index", version: 24, minCompatible: 1 },
|
|
32
32
|
{ name: "20261019115025_version-25-chat-revisions", version: 25, minCompatible: 1 },
|
|
33
33
|
{ name: "20261019115027_version-25-chat-revisions-triggers", version: 25, minCompatible: 1 },
|
|
34
|
+
{ name: "20261021214318_version-26-message-reactions", version: 26, minCompatible: 1 },
|
|
35
|
+
{ name: "20261021221808_version-27-message-delivery", version: 27, minCompatible: 1 },
|
|
36
|
+
{ name: "20261021224625_version-28-chat-state", version: 28, minCompatible: 1 },
|
|
34
37
|
];
|
|
35
38
|
export const generatedMigrations = (manifest = MANIFEST, generated = GENERATED) => {
|
|
36
39
|
const migrations = [];
|
|
@@ -21,7 +21,7 @@ const speakers = (database, accountId) => {
|
|
|
21
21
|
found.set(key, speaker);
|
|
22
22
|
};
|
|
23
23
|
const participants = database
|
|
24
|
-
.prepare(`SELECT p.id,p.identity_id,p.display_name,i.name AS identity_name,l.person_id,person.name AS person_name
|
|
24
|
+
.prepare(`SELECT p.id,p.identity_id,p.display_name,p.email,i.external_id AS identity_key,i.name AS identity_name,l.person_id,person.name AS person_name
|
|
25
25
|
FROM meeting_participants p JOIN meetings m ON m.id=p.meeting_id
|
|
26
26
|
LEFT JOIN identities i ON i.id=p.identity_id LEFT JOIN identity_links l ON l.identity_id=p.identity_id
|
|
27
27
|
LEFT JOIN persons person ON person.id=l.person_id WHERE m.account_id=? AND m.deleted_at IS NULL`)
|
|
@@ -34,11 +34,26 @@ const speakers = (database, accountId) => {
|
|
|
34
34
|
const others = personNames.all(Number(row.person_id)).map((one) => (one.name === null ? null : String(one.name)));
|
|
35
35
|
add(`person:${row.person_id}`, String(row.person_name ?? label), [...names, ...others], Number(row.id));
|
|
36
36
|
}
|
|
37
|
-
else if (row.identity_id !== null &&
|
|
38
|
-
|
|
37
|
+
else if (row.identity_id !== null &&
|
|
38
|
+
row.identity_id !== undefined &&
|
|
39
|
+
!String(row.identity_key).startsWith("name:"))
|
|
40
|
+
add(`identity:${row.identity_id}`, row.email ? `${label} <${row.email}>` : label, names, Number(row.id));
|
|
41
|
+
// A participant known only by name is keyed per meeting (`name:…@account/occurrence:n`); across meetings the name is all there is.
|
|
39
42
|
else
|
|
40
43
|
add(`name:${fold(label)}`, label, names, Number(row.id));
|
|
41
44
|
}
|
|
45
|
+
// A name heard in one meeting is the identified speaker of that name, when exactly one has it.
|
|
46
|
+
for (const [key, speaker] of found) {
|
|
47
|
+
if (!key.startsWith("name:"))
|
|
48
|
+
continue;
|
|
49
|
+
const named = [...found].filter(([other, one]) => !other.startsWith("name:") && one.names.has(key.slice(5)));
|
|
50
|
+
const [only] = named;
|
|
51
|
+
if (named.length !== 1 || !only)
|
|
52
|
+
continue;
|
|
53
|
+
for (const participant of speaker.participants)
|
|
54
|
+
only[1].participants.add(participant);
|
|
55
|
+
found.delete(key);
|
|
56
|
+
}
|
|
42
57
|
const unlinked = database
|
|
43
58
|
.prepare(`SELECT DISTINCT r.speaker_name AS name FROM meeting_transcript_rows r
|
|
44
59
|
JOIN meeting_transcripts t ON t.id=r.meeting_transcript_id JOIN meetings m ON m.id=t.meeting_id
|
|
@@ -166,14 +166,65 @@ export const saveAttachment = (database, type, parentId, a, now) => upsert(datab
|
|
|
166
166
|
content_sha256: a.contentSha256,
|
|
167
167
|
extracted_at: a.extractedAt,
|
|
168
168
|
}, now);
|
|
169
|
+
const FILE_SOURCE = "file";
|
|
170
|
+
const SAME_OCCURRENCE_MS = 15 * 60_000;
|
|
171
|
+
/** The account's record of this occurrence under another key: same series, the nearest start within 15 minutes. */
|
|
172
|
+
const sameOccurrence = (database, meeting, seriesKey) => {
|
|
173
|
+
if (!seriesKey || meeting.startedAt === null)
|
|
174
|
+
return undefined;
|
|
175
|
+
const row = database
|
|
176
|
+
.prepare(`SELECT m.id,m.external_id,m.meeting_series_id,
|
|
177
|
+
EXISTS (SELECT 1 FROM meeting_transcripts t WHERE t.meeting_id=m.id AND t.source=?) AND
|
|
178
|
+
NOT EXISTS (SELECT 1 FROM meeting_transcripts t WHERE t.meeting_id=m.id AND t.source!=?) AS from_file
|
|
179
|
+
FROM meetings m JOIN meeting_series s ON s.id=m.meeting_series_id
|
|
180
|
+
WHERE m.account_id=? AND s.account_id=? AND s.external_id=? AND m.deleted_at IS NULL
|
|
181
|
+
AND m.started_at BETWEEN ? AND ? ORDER BY abs(m.started_at-?),m.id LIMIT 1`)
|
|
182
|
+
.get(FILE_SOURCE, FILE_SOURCE, meeting.accountId, meeting.accountId, seriesKey, meeting.startedAt - SAME_OCCURRENCE_MS, meeting.startedAt + SAME_OCCURRENCE_MS, meeting.startedAt);
|
|
183
|
+
return row
|
|
184
|
+
? {
|
|
185
|
+
id: row.id,
|
|
186
|
+
externalId: row.external_id,
|
|
187
|
+
seriesId: row.meeting_series_id,
|
|
188
|
+
fromFile: Number(row.from_file) === 1,
|
|
189
|
+
}
|
|
190
|
+
: undefined;
|
|
191
|
+
};
|
|
192
|
+
/** A file's copy of an occurrence the provider already gave: its transcript only, and only where there is none. */
|
|
193
|
+
const fillGap = (context, input, meetingId) => {
|
|
194
|
+
const { database } = context;
|
|
195
|
+
const held = database
|
|
196
|
+
.prepare("SELECT 1 FROM meeting_transcripts WHERE meeting_id = ? AND superseded_at IS NULL AND deleted_at IS NULL")
|
|
197
|
+
.get(meetingId);
|
|
198
|
+
if (held || !input.transcripts?.length)
|
|
199
|
+
return meetingId;
|
|
200
|
+
const participantIds = (input.participants ?? []).map(({ identity, ...p }) => upsert(database, "meeting_participants", {
|
|
201
|
+
meeting_id: meetingId,
|
|
202
|
+
identity_id: meetingIdentityPk({ ...context, now: () => input.now }, input.meeting.accountId, identity),
|
|
203
|
+
}, { display_name: p.displayName, email: p.email, role: p.role, external_id: p.externalId }, input.now));
|
|
204
|
+
saveTranscripts(database, meetingId, input.transcripts, (position) => (position === null ? null : (participantIds[position] ?? null)), input.now);
|
|
205
|
+
return meetingId;
|
|
206
|
+
};
|
|
169
207
|
const save = (context, input) => {
|
|
170
208
|
const { database } = context;
|
|
171
209
|
const { meeting: { series: seriesInput, ...meeting }, now, } = input;
|
|
172
210
|
if (!Number.isSafeInteger(meeting.accountId) || meeting.accountId <= 0 || !meeting.externalId)
|
|
173
211
|
throw new Error("Invalid meeting key");
|
|
174
|
-
|
|
212
|
+
let existing = database
|
|
175
213
|
.prepare("SELECT id, meeting_series_id FROM meetings WHERE account_id = ? AND external_id = ?")
|
|
176
214
|
.get(meeting.accountId, meeting.externalId);
|
|
215
|
+
const same = existing ? undefined : sameOccurrence(database, meeting, seriesInput?.externalId);
|
|
216
|
+
if (same?.fromFile === false && input.fromFile)
|
|
217
|
+
return fillGap(context, input, Number(same.id));
|
|
218
|
+
if (same && (same.fromFile || input.fromFile)) {
|
|
219
|
+
// A provider record takes over a record made from files; two files of one occurrence share the first one's key.
|
|
220
|
+
if (input.fromFile)
|
|
221
|
+
meeting.externalId = String(same.externalId);
|
|
222
|
+
else
|
|
223
|
+
database
|
|
224
|
+
.prepare("UPDATE meetings SET external_id = ?, updated_at = ? WHERE id = ?")
|
|
225
|
+
.run(meeting.externalId, now, Number(same.id));
|
|
226
|
+
existing = { id: same.id, meeting_series_id: same.seriesId };
|
|
227
|
+
}
|
|
177
228
|
let seriesId = existing ? int(existing.meeting_series_id) : null;
|
|
178
229
|
if (seriesInput) {
|
|
179
230
|
if (!seriesInput.externalId)
|
|
@@ -228,6 +279,10 @@ const save = (context, input) => {
|
|
|
228
279
|
return id;
|
|
229
280
|
};
|
|
230
281
|
saveTranscripts(database, meetingId, input.transcripts ?? [], participantId, now);
|
|
282
|
+
if ((input.transcripts ?? []).some((transcript) => transcript.source !== FILE_SOURCE))
|
|
283
|
+
database
|
|
284
|
+
.prepare("UPDATE meeting_transcripts SET superseded_at = ?, updated_at = ? WHERE meeting_id = ? AND source = ? AND superseded_at IS NULL")
|
|
285
|
+
.run(now, now, meetingId, FILE_SOURCE);
|
|
231
286
|
for (const { senderParticipantPosition, ...line } of input.chat ?? []) {
|
|
232
287
|
const values = {
|
|
233
288
|
sent_at: line.sentAt,
|
|
@@ -1,17 +1,32 @@
|
|
|
1
|
-
import type { Id, Message, PollVote, Provider, Reactions } from "../../domain/models.js";
|
|
1
|
+
import type { Delivery, Id, Message, PersonReaction, PollVote, Provider, Reactions } from "../../domain/models.js";
|
|
2
2
|
import type { AccountKey, DeletionScope } from "../types.js";
|
|
3
3
|
import type { StoreContext } from "./open.js";
|
|
4
|
-
export declare const upsertMessage: (context: StoreContext, key: AccountKey, accountKey: number, chatKey: number, message: Message, via: string, seenAt?: number) =>
|
|
4
|
+
export declare const upsertMessage: (context: StoreContext, key: AccountKey, accountKey: number, chatKey: number, message: Message, via: string, seenAt?: number) => boolean;
|
|
5
|
+
/**
|
|
6
|
+
* One more unread for a message from someone else that the store first saw now, sent after the messenger's count was
|
|
7
|
+
* taken. A chat the messenger never gave a count for is left alone: the store would count its whole history.
|
|
8
|
+
*/
|
|
9
|
+
export declare const countUnread: ({ database }: StoreContext, chatKey: number, message: Message) => void;
|
|
5
10
|
/**
|
|
6
11
|
* The owner's ruling (NEED-393 A): a deleted message keeps its row, so a later sync cannot bring it
|
|
7
12
|
* back, and leaves no text — not in the row, the search copy, the edit history, a transcript or a vector.
|
|
8
13
|
*/
|
|
9
14
|
export declare const tombstone: (context: StoreContext, pk: number) => number;
|
|
10
|
-
/** Whether the message is held at all. */
|
|
11
|
-
export declare const saveReactions: (
|
|
15
|
+
/** Whether the message is held at all. Where people's own reactions are held, the totals come from them instead. */
|
|
16
|
+
export declare const saveReactions: (context: StoreContext, chatKey: number, messageId: Id, reactions: Reactions) => boolean;
|
|
12
17
|
/** Answers whether the message is held; a deleted one keeps no star. */
|
|
13
18
|
export declare const saveStar: ({ orm }: StoreContext, chatKey: number, messageId: Id, starred: boolean) => boolean;
|
|
19
|
+
/** Answers whether the owner's own message is held. A receipt is for a message the owner sent, so another's keeps none. */
|
|
20
|
+
export declare const saveDelivery: ({ orm }: StoreContext, chatKey: number, messageId: Id, delivery: Delivery) => boolean;
|
|
14
21
|
export declare const markDeleted: (context: StoreContext, accountKey: number, messageIds: Id[], chatId: Id | undefined, among?: DeletionScope) => number;
|
|
15
22
|
/** A vote older than the one held for that voter is dropped, and so is any vote in a deleted poll. */
|
|
16
23
|
export declare const saveVote: (context: StoreContext, accountKey: number, provider: Provider, chatKey: number, messageId: Id, { person, answers, votedAt }: PollVote) => void;
|
|
17
24
|
export declare const pollVotes: ({ database }: StoreContext, chatKey: number, messageId: Id) => PollVote[];
|
|
25
|
+
/**
|
|
26
|
+
* The totals of the people's reactions the store holds for a message, or `undefined` when it holds none: then the
|
|
27
|
+
* messenger's own totals stand. A taken-back reaction still counts as held, so all of them taken back is zero.
|
|
28
|
+
*/
|
|
29
|
+
export declare const derivedReactions: ({ database }: StoreContext, chatKey: number, messageId: Id) => Reactions | undefined;
|
|
30
|
+
/** A reaction older than the one held for that person is dropped, and so is any reaction to a deleted message. */
|
|
31
|
+
export declare const saveReaction: (context: StoreContext, accountKey: number, provider: Provider, chatKey: number, messageId: Id, { person, own, reaction, reactedAt }: PersonReaction) => void;
|
|
32
|
+
export declare const writeDerivedReactions: (context: StoreContext, chatKey: number, messageId: Id) => void;
|