@wirecat/cli-core 0.381.0 → 0.383.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 +50 -0
- package/dist/attachments/documents.d.ts +3 -0
- package/dist/attachments/documents.js +17 -0
- package/dist/cli/bot/watch.js +3 -0
- 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 +12 -5
- package/dist/cli/messenger/watch-command.js +2 -0
- package/dist/documents/blocks.d.ts +45 -0
- package/dist/documents/blocks.js +48 -0
- package/dist/documents/index.d.ts +5 -0
- package/dist/documents/index.js +4 -0
- package/dist/documents/markdown-blocks.d.ts +3 -0
- package/dist/documents/markdown-blocks.js +123 -0
- package/dist/documents/push-model.d.ts +89 -0
- package/dist/documents/push-model.js +1 -0
- package/dist/documents.d.ts +1 -0
- package/dist/documents.js +1 -0
- package/dist/domain/models.d.ts +31 -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 +3 -0
- package/dist/meetings/answers.d.ts +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/ports/traits.d.ts +6 -0
- 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/documents/block-diff.d.ts +24 -0
- package/dist/services/documents/block-diff.js +91 -0
- package/dist/services/documents/push.d.ts +60 -0
- package/dist/services/documents/push.js +218 -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 +7 -1
- package/dist/store/messages.js +16 -3
- package/dist/store/sqlite/chat-rekey.js +9 -1
- package/dist/store/sqlite/chats.d.ts +2 -2
- package/dist/store/sqlite/chats.js +37 -8
- package/dist/store/sqlite/manifest.js +2 -0
- package/dist/store/sqlite/meeting-hit-query.js +18 -3
- package/dist/store/sqlite/messages.d.ts +9 -2
- package/dist/store/sqlite/messages.js +29 -2
- package/dist/store/sqlite/migrations.generated.js +16 -0
- package/dist/store/sqlite/reads.d.ts +50 -0
- package/dist/store/sqlite/reads.js +3 -1
- package/dist/store/sqlite/schema.d.ts +96 -0
- package/dist/store/sqlite/schema.js +6 -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 +5 -1
package/dist/services/chats.js
CHANGED
|
@@ -9,7 +9,7 @@ import { codeOf, guardedWrite } from "../sends/guarded.js";
|
|
|
9
9
|
import { newOperationId } from "../sends/send-id.js";
|
|
10
10
|
import { CHAT_COLUMNS, queryFilter, } from "../store/store.js";
|
|
11
11
|
import { chatStats } from "./chat-stats.js";
|
|
12
|
-
import { accountStore, accountStoreIfOpen, fromStore, nothingStored } from "./deps.js";
|
|
12
|
+
import { accountStore, accountStoreIfOpen, fromStore, nothingStored, unlessBusy } from "./deps.js";
|
|
13
13
|
import { memberEvents } from "./member-history.js";
|
|
14
14
|
import { AUDIT_BUDGET, AUDIT_MIN_SCORE, AUDIT_PAGE, AUDIT_PAUSE_MS, auditMembers, } from "./members-audit.js";
|
|
15
15
|
import { chatIdIn } from "./message-resolution.js";
|
|
@@ -249,7 +249,7 @@ export const chatsService = (deps) => ({
|
|
|
249
249
|
const markTopicRead = capability(connection, "markTopicRead", "mark a forum topic read");
|
|
250
250
|
act = (chatId) => markTopicRead(chatId, threadId, until);
|
|
251
251
|
}
|
|
252
|
-
const { id: chatId } = await connection.resolve(chat);
|
|
252
|
+
const { id: chatId, kind } = await connection.resolve(chat);
|
|
253
253
|
const operationId = newOperationId();
|
|
254
254
|
await guardedWrite(deps.guard, {
|
|
255
255
|
operationId,
|
|
@@ -258,9 +258,23 @@ export const chatsService = (deps) => ({
|
|
|
258
258
|
...(until === undefined ? {} : { messageId: until }),
|
|
259
259
|
...(threadId === undefined ? {} : { threadId }),
|
|
260
260
|
}, () => act(chatId));
|
|
261
|
+
if (deps.reads === "store" && threadId === undefined)
|
|
262
|
+
await readInStore(deps, chatId, kind, until);
|
|
261
263
|
return { operationId, chatId, until: until ?? null, ...(threadId === undefined ? {} : { threadId }) };
|
|
262
264
|
},
|
|
263
265
|
});
|
|
266
|
+
/**
|
|
267
|
+
* Where `inbox` reads the store, the store learns of a read the owner made here at once: the messenger may never say.
|
|
268
|
+
* A read up to an older message from someone else leaves the count, which the store cannot split.
|
|
269
|
+
*/
|
|
270
|
+
const readInStore = async (deps, chatId, kind, until) => {
|
|
271
|
+
const store = await accountStoreIfOpen(deps);
|
|
272
|
+
if (!store)
|
|
273
|
+
return;
|
|
274
|
+
const [newest] = (await store.messages.messages(chatId, { limit: 1, outgoing: false })).items;
|
|
275
|
+
if (newest !== undefined && (until === undefined || newest.id === until))
|
|
276
|
+
await unlessBusy(() => store.chats.saveChats([{ id: chatId, kind, unreadCount: 0, markedUnread: false }]));
|
|
277
|
+
};
|
|
264
278
|
/** Each sender's first stored message in the chat; `undefined` when the store holds none of it. */
|
|
265
279
|
const storedFacts = async (store, chatId) => {
|
|
266
280
|
const chat = (await store.chats.chats({})).items.find((one) => one.id === chatId);
|
package/dist/services/deps.d.ts
CHANGED
|
@@ -52,6 +52,8 @@ export declare const accountStore: (deps: Pick<ServiceDeps, "store" | "account">
|
|
|
52
52
|
* `reason` says which, so every such read is found by searching for this name.
|
|
53
53
|
*/
|
|
54
54
|
export declare const crossAccount: (deps: Pick<ServiceDeps, "store">, _reason: string) => Promise<MessageStore>;
|
|
55
|
+
/** A store write after the messenger already did the work: a store busy past its timeout leaves it unwritten. */
|
|
56
|
+
export declare const unlessBusy: <T>(work: () => Promise<T>) => Promise<T | undefined>;
|
|
55
57
|
/** `accountStore`, or nothing when there is no store to read or the account is not known yet. */
|
|
56
58
|
export declare const accountStoreIfOpen: (deps: ServiceDeps) => Promise<AccountStore | undefined>;
|
|
57
59
|
/**
|
package/dist/services/deps.js
CHANGED
|
@@ -33,6 +33,8 @@ const unless = async (work, expected) => {
|
|
|
33
33
|
throw error;
|
|
34
34
|
}
|
|
35
35
|
};
|
|
36
|
+
/** A store write after the messenger already did the work: a store busy past its timeout leaves it unwritten. */
|
|
37
|
+
export const unlessBusy = (work) => unless(work, storeAbsent);
|
|
36
38
|
/** `accountStore`, or nothing when there is no store to read or the account is not known yet. */
|
|
37
39
|
export const accountStoreIfOpen = async (deps) => {
|
|
38
40
|
const held = await storeIfOpen(deps);
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { type Block } from "../../documents/blocks.js";
|
|
2
|
+
/** `base[from, to)` is replaced by `blocks`; an empty range is an insert, an empty `blocks` a delete. */
|
|
3
|
+
export interface BlockHunk {
|
|
4
|
+
from: number;
|
|
5
|
+
to: number;
|
|
6
|
+
blocks: Block[];
|
|
7
|
+
}
|
|
8
|
+
/** The edits that turn `base` into `next`, leaving every unchanged block in place. */
|
|
9
|
+
export declare const diffBlocks: (base: Block[], next: Block[]) => BlockHunk[];
|
|
10
|
+
export type Reconciled = {
|
|
11
|
+
status: "clean";
|
|
12
|
+
blocks: Block[];
|
|
13
|
+
ticksKept: number;
|
|
14
|
+
} | {
|
|
15
|
+
status: "conflict";
|
|
16
|
+
};
|
|
17
|
+
/**
|
|
18
|
+
* What a push may write. `recorded` is what the last push wrote and read back, `current` the remote
|
|
19
|
+
* now, `local` the file now and `lastLocal` the file as the last push read it. Any remote change other
|
|
20
|
+
* than a checklist tick is a conflict, so the push never overwrites someone's edit. A tick follows the
|
|
21
|
+
* side that changed it: the local file when its tick differs from `lastLocal`, the remote otherwise —
|
|
22
|
+
* judged on every push, so a remote tick is not undone once the remote and `recorded` agree again.
|
|
23
|
+
*/
|
|
24
|
+
export declare const reconcileBlocks: (recorded: Block[], current: Block[], local: Block[], lastLocal: Block[]) => Reconciled;
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { blockKey, blockShape } from "../../documents/blocks.js";
|
|
2
|
+
const MAX_CELLS = 4_000_000;
|
|
3
|
+
/** Longest common subsequence: index pairs [inA, inB] of blocks with equal keys, in order. */
|
|
4
|
+
const matches = (a, b, keyOf) => {
|
|
5
|
+
const ka = a.map(keyOf);
|
|
6
|
+
const kb = b.map(keyOf);
|
|
7
|
+
let start = 0;
|
|
8
|
+
while (start < ka.length && start < kb.length && ka[start] === kb[start])
|
|
9
|
+
start++;
|
|
10
|
+
let endA = ka.length;
|
|
11
|
+
let endB = kb.length;
|
|
12
|
+
while (endA > start && endB > start && ka[endA - 1] === kb[endB - 1]) {
|
|
13
|
+
endA--;
|
|
14
|
+
endB--;
|
|
15
|
+
}
|
|
16
|
+
const rows = endA - start;
|
|
17
|
+
const cols = endB - start;
|
|
18
|
+
const pairs = [];
|
|
19
|
+
for (let i = 0; i < start; i++)
|
|
20
|
+
pairs.push([i, i]);
|
|
21
|
+
if (rows * cols > MAX_CELLS) {
|
|
22
|
+
// Too large to align: the changed middle is rewritten as one block of edits.
|
|
23
|
+
}
|
|
24
|
+
else if (rows && cols) {
|
|
25
|
+
const table = new Uint32Array((rows + 1) * (cols + 1));
|
|
26
|
+
for (let i = rows - 1; i >= 0; i--)
|
|
27
|
+
for (let j = cols - 1; j >= 0; j--)
|
|
28
|
+
table[i * (cols + 1) + j] =
|
|
29
|
+
ka[start + i] === kb[start + j]
|
|
30
|
+
? table[(i + 1) * (cols + 1) + j + 1] + 1
|
|
31
|
+
: Math.max(table[(i + 1) * (cols + 1) + j], table[i * (cols + 1) + j + 1]);
|
|
32
|
+
let i = 0;
|
|
33
|
+
let j = 0;
|
|
34
|
+
while (i < rows && j < cols) {
|
|
35
|
+
if (ka[start + i] === kb[start + j]) {
|
|
36
|
+
pairs.push([start + i, start + j]);
|
|
37
|
+
i++;
|
|
38
|
+
j++;
|
|
39
|
+
}
|
|
40
|
+
else if (table[(i + 1) * (cols + 1) + j] >= table[i * (cols + 1) + j + 1])
|
|
41
|
+
i++;
|
|
42
|
+
else
|
|
43
|
+
j++;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
for (let offset = 0; offset < ka.length - endA; offset++)
|
|
47
|
+
pairs.push([endA + offset, endB + offset]);
|
|
48
|
+
return pairs;
|
|
49
|
+
};
|
|
50
|
+
/** The edits that turn `base` into `next`, leaving every unchanged block in place. */
|
|
51
|
+
export const diffBlocks = (base, next) => {
|
|
52
|
+
const hunks = [];
|
|
53
|
+
let a = 0;
|
|
54
|
+
let b = 0;
|
|
55
|
+
for (const [i, j] of [...matches(base, next, blockKey), [base.length, next.length]]) {
|
|
56
|
+
if (i > a || j > b)
|
|
57
|
+
hunks.push({ from: a, to: i, blocks: next.slice(b, j) });
|
|
58
|
+
a = i + 1;
|
|
59
|
+
b = j + 1;
|
|
60
|
+
}
|
|
61
|
+
return hunks;
|
|
62
|
+
};
|
|
63
|
+
const isCheck = (block) => block?.kind === "item" && block.list === "check";
|
|
64
|
+
/**
|
|
65
|
+
* What a push may write. `recorded` is what the last push wrote and read back, `current` the remote
|
|
66
|
+
* now, `local` the file now and `lastLocal` the file as the last push read it. Any remote change other
|
|
67
|
+
* than a checklist tick is a conflict, so the push never overwrites someone's edit. A tick follows the
|
|
68
|
+
* side that changed it: the local file when its tick differs from `lastLocal`, the remote otherwise —
|
|
69
|
+
* judged on every push, so a remote tick is not undone once the remote and `recorded` agree again.
|
|
70
|
+
*/
|
|
71
|
+
export const reconcileBlocks = (recorded, current, local, lastLocal) => {
|
|
72
|
+
const sameShape = recorded.length === current.length &&
|
|
73
|
+
recorded.every((block, index) => blockShape(block) === blockShape(current[index]));
|
|
74
|
+
if (!sameShape)
|
|
75
|
+
return { status: "conflict" };
|
|
76
|
+
const before = new Map(matches(local, lastLocal, blockShape));
|
|
77
|
+
const blocks = local.map((block) => ({ ...block }));
|
|
78
|
+
let ticksKept = 0;
|
|
79
|
+
for (const [i, j] of matches(current, local, blockShape)) {
|
|
80
|
+
const remote = current[i];
|
|
81
|
+
const mine = blocks[j];
|
|
82
|
+
const last = lastLocal[before.get(j) ?? -1];
|
|
83
|
+
if (!isCheck(remote) || !isCheck(mine) || !isCheck(last))
|
|
84
|
+
continue;
|
|
85
|
+
if (Boolean(mine.checked) === Boolean(last.checked) && Boolean(remote.checked) !== Boolean(mine.checked)) {
|
|
86
|
+
mine.checked = Boolean(remote.checked);
|
|
87
|
+
ticksKept++;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return { status: "clean", blocks, ticksKept };
|
|
91
|
+
};
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { CliError } from "@wirecat/cli-base";
|
|
2
|
+
import type { DocumentTarget, LocalDocument, PushPlan, PushState, PushStatePort } from "../../documents/push-model.js";
|
|
3
|
+
export interface PushOptions<S> {
|
|
4
|
+
/** Absolute path of the local folder being mirrored. */
|
|
5
|
+
root: string;
|
|
6
|
+
/** Remote folder id the mirror writes into. */
|
|
7
|
+
folder: string;
|
|
8
|
+
target: DocumentTarget<S>;
|
|
9
|
+
state: PushStatePort<S>;
|
|
10
|
+
principal: string;
|
|
11
|
+
force?: boolean;
|
|
12
|
+
dryRun?: boolean;
|
|
13
|
+
/** Runs one remote write behind the provider's permission checks and write journal. */
|
|
14
|
+
guard<T>(attempt: {
|
|
15
|
+
action: "create" | "update" | "folder";
|
|
16
|
+
path: string;
|
|
17
|
+
operationId: string;
|
|
18
|
+
}, act: () => Promise<T>): Promise<T>;
|
|
19
|
+
operationId(): string;
|
|
20
|
+
errorOf(error: unknown): CliError;
|
|
21
|
+
assertHeld(): void;
|
|
22
|
+
signal?: AbortSignal;
|
|
23
|
+
maxFiles?: number;
|
|
24
|
+
now?(): number;
|
|
25
|
+
}
|
|
26
|
+
export interface PushResult {
|
|
27
|
+
created: string[];
|
|
28
|
+
updated: string[];
|
|
29
|
+
unchanged: number;
|
|
30
|
+
conflicts: {
|
|
31
|
+
path: string;
|
|
32
|
+
url: string;
|
|
33
|
+
reason: string;
|
|
34
|
+
}[];
|
|
35
|
+
missing: string[];
|
|
36
|
+
orphans: {
|
|
37
|
+
path: string;
|
|
38
|
+
url: string;
|
|
39
|
+
}[];
|
|
40
|
+
errors: {
|
|
41
|
+
path: string;
|
|
42
|
+
code: string;
|
|
43
|
+
message: string;
|
|
44
|
+
}[];
|
|
45
|
+
unsupported: number;
|
|
46
|
+
plan?: {
|
|
47
|
+
create: string[];
|
|
48
|
+
update: string[];
|
|
49
|
+
orphans: string[];
|
|
50
|
+
};
|
|
51
|
+
stopped?: true;
|
|
52
|
+
}
|
|
53
|
+
/** Files under `root` the target pushes. Hidden files, editor leftovers and symbolic links are never followed. */
|
|
54
|
+
export declare const scanLocal: (root: string, kindOf: (path: string) => string | undefined, maxFiles?: number) => Promise<{
|
|
55
|
+
files: LocalDocument[];
|
|
56
|
+
unsupported: number;
|
|
57
|
+
}>;
|
|
58
|
+
/** What a push would do, from the local files and the last push alone: no remote read. */
|
|
59
|
+
export declare const planPush: <S>(files: LocalDocument[], state: PushState<S>) => PushPlan;
|
|
60
|
+
export declare const pushDocuments: <S>(options: PushOptions<S>) => Promise<PushResult>;
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { createReadStream } from "node:fs";
|
|
3
|
+
import { readdir } from "node:fs/promises";
|
|
4
|
+
import { join, posix } from "node:path";
|
|
5
|
+
import { CliError, isCliError } from "@wirecat/cli-base";
|
|
6
|
+
const SKIPPED = /^(\.|node_modules$|~\$)|(~|\.swp|\.tmp)$/;
|
|
7
|
+
const hashFile = (path) => new Promise((done, fail) => {
|
|
8
|
+
const hash = createHash("sha256");
|
|
9
|
+
createReadStream(path)
|
|
10
|
+
.on("data", (chunk) => hash.update(chunk))
|
|
11
|
+
.on("error", fail)
|
|
12
|
+
.on("end", () => done(hash.digest("hex")));
|
|
13
|
+
});
|
|
14
|
+
/** Files under `root` the target pushes. Hidden files, editor leftovers and symbolic links are never followed. */
|
|
15
|
+
export const scanLocal = async (root, kindOf, maxFiles = 10_000) => {
|
|
16
|
+
const files = [];
|
|
17
|
+
let unsupported = 0;
|
|
18
|
+
const walk = async (relative) => {
|
|
19
|
+
const entries = await readdir(join(root, relative), { withFileTypes: true });
|
|
20
|
+
for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) {
|
|
21
|
+
if (SKIPPED.test(entry.name) || entry.isSymbolicLink())
|
|
22
|
+
continue;
|
|
23
|
+
const path = relative ? posix.join(relative, entry.name) : entry.name;
|
|
24
|
+
if (entry.isDirectory())
|
|
25
|
+
await walk(path);
|
|
26
|
+
else if (entry.isFile()) {
|
|
27
|
+
const kind = kindOf(path);
|
|
28
|
+
if (!kind) {
|
|
29
|
+
unsupported++;
|
|
30
|
+
continue;
|
|
31
|
+
}
|
|
32
|
+
if (files.length >= maxFiles)
|
|
33
|
+
throw new CliError("validation_error", `the folder holds more than ${maxFiles} files to push`, {
|
|
34
|
+
reason: "push.too-many-files",
|
|
35
|
+
});
|
|
36
|
+
const absolute = join(root, path);
|
|
37
|
+
files.push({ path, absolute, kind, hash: await hashFile(absolute) });
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
};
|
|
41
|
+
try {
|
|
42
|
+
await walk("");
|
|
43
|
+
}
|
|
44
|
+
catch (error) {
|
|
45
|
+
if (isCliError(error))
|
|
46
|
+
throw error;
|
|
47
|
+
throw new CliError("validation_error", "the folder to push could not be read", { reason: "push.unreadable" });
|
|
48
|
+
}
|
|
49
|
+
return { files, unsupported };
|
|
50
|
+
};
|
|
51
|
+
/** What a push would do, from the local files and the last push alone: no remote read. */
|
|
52
|
+
export const planPush = (files, state) => {
|
|
53
|
+
const plan = { create: [], update: [], unchanged: [], orphans: [] };
|
|
54
|
+
const seen = new Set();
|
|
55
|
+
for (const file of files) {
|
|
56
|
+
seen.add(file.path);
|
|
57
|
+
const entry = state.entries[file.path];
|
|
58
|
+
if (!entry)
|
|
59
|
+
plan.create.push(file);
|
|
60
|
+
else if (entry.hash !== file.hash)
|
|
61
|
+
plan.update.push(file);
|
|
62
|
+
else
|
|
63
|
+
plan.unchanged.push(file);
|
|
64
|
+
}
|
|
65
|
+
plan.orphans = Object.keys(state.entries).filter((path) => !seen.has(path));
|
|
66
|
+
return plan;
|
|
67
|
+
};
|
|
68
|
+
const originOf = (principal, root, folder, path) => createHash("sha256")
|
|
69
|
+
.update(JSON.stringify([principal, root, folder, path]))
|
|
70
|
+
.digest("hex")
|
|
71
|
+
.slice(0, 32);
|
|
72
|
+
export const pushDocuments = async (options) => {
|
|
73
|
+
const { root, folder, target, principal, force = false, signal } = options;
|
|
74
|
+
const now = options.now ?? Date.now;
|
|
75
|
+
if (signal?.aborted)
|
|
76
|
+
throw new CliError("cancelled", "push was cancelled");
|
|
77
|
+
if ((await target.identity()) !== principal)
|
|
78
|
+
throw new CliError("authentication_error", "signed-in account does not match this profile", {
|
|
79
|
+
reason: "profile.wrong-account",
|
|
80
|
+
});
|
|
81
|
+
const state = options.state.read();
|
|
82
|
+
if (state.principal !== principal || state.root !== root || state.target !== folder)
|
|
83
|
+
throw new CliError("configuration_error", "push state belongs to another account or mirror");
|
|
84
|
+
const save = () => options.state.save(state);
|
|
85
|
+
if (!options.dryRun)
|
|
86
|
+
for (const [path, pending] of Object.entries(state.pending)) {
|
|
87
|
+
const found = await target.findByOrigin(pending.origin);
|
|
88
|
+
// An empty hash makes this pass update the found file from the local one.
|
|
89
|
+
if (found)
|
|
90
|
+
state.entries[path] = { ...found, kind: target.kindOf(path) ?? "", hash: "", pushedAt: "" };
|
|
91
|
+
delete state.pending[path];
|
|
92
|
+
save();
|
|
93
|
+
}
|
|
94
|
+
const { files, unsupported } = await scanLocal(root, (path) => target.kindOf(path), options.maxFiles);
|
|
95
|
+
const plan = planPush(files, state);
|
|
96
|
+
const result = {
|
|
97
|
+
created: [],
|
|
98
|
+
updated: [],
|
|
99
|
+
unchanged: plan.unchanged.length,
|
|
100
|
+
conflicts: [],
|
|
101
|
+
missing: [],
|
|
102
|
+
orphans: plan.orphans.map((path) => ({ path, url: state.entries[path]?.url ?? "" })),
|
|
103
|
+
errors: [],
|
|
104
|
+
unsupported,
|
|
105
|
+
};
|
|
106
|
+
if (options.dryRun) {
|
|
107
|
+
result.plan = {
|
|
108
|
+
create: plan.create.map((file) => file.path),
|
|
109
|
+
update: plan.update.map((file) => file.path),
|
|
110
|
+
orphans: plan.orphans,
|
|
111
|
+
};
|
|
112
|
+
return result;
|
|
113
|
+
}
|
|
114
|
+
const folderOf = async (path) => {
|
|
115
|
+
const directory = posix.dirname(path);
|
|
116
|
+
if (directory === ".")
|
|
117
|
+
return folder;
|
|
118
|
+
const known = state.folders[directory];
|
|
119
|
+
if (known)
|
|
120
|
+
return known;
|
|
121
|
+
const parent = await folderOf(directory);
|
|
122
|
+
const operationId = options.operationId();
|
|
123
|
+
options.assertHeld();
|
|
124
|
+
const id = await options.guard({ action: "folder", path: directory, operationId }, () => target.ensureFolder({
|
|
125
|
+
parent,
|
|
126
|
+
name: posix.basename(directory),
|
|
127
|
+
origin: originOf(principal, root, folder, `${directory}/`),
|
|
128
|
+
}));
|
|
129
|
+
state.folders[directory] = id;
|
|
130
|
+
save();
|
|
131
|
+
return id;
|
|
132
|
+
};
|
|
133
|
+
const create = async (file) => {
|
|
134
|
+
const parent = await folderOf(file.path);
|
|
135
|
+
const operationId = options.operationId();
|
|
136
|
+
const origin = originOf(principal, root, folder, file.path);
|
|
137
|
+
state.pending[file.path] = { origin, operationId };
|
|
138
|
+
save();
|
|
139
|
+
options.assertHeld();
|
|
140
|
+
const pushed = await options.guard({ action: "create", path: file.path, operationId }, () => target.create({
|
|
141
|
+
file,
|
|
142
|
+
name: posix.basename(file.path),
|
|
143
|
+
parent,
|
|
144
|
+
origin,
|
|
145
|
+
operationId,
|
|
146
|
+
...(signal ? { signal } : {}),
|
|
147
|
+
}));
|
|
148
|
+
state.entries[file.path] = { ...pushed, kind: file.kind, hash: file.hash, pushedAt: new Date(now()).toISOString() };
|
|
149
|
+
delete state.pending[file.path];
|
|
150
|
+
save();
|
|
151
|
+
result.created.push(file.path);
|
|
152
|
+
};
|
|
153
|
+
const work = [
|
|
154
|
+
...plan.create.map((file) => ({ file, create: true })),
|
|
155
|
+
...plan.update.map((file) => ({ file, create: false })),
|
|
156
|
+
];
|
|
157
|
+
for (const job of work.sort((a, b) => a.file.path.localeCompare(b.file.path))) {
|
|
158
|
+
if (signal?.aborted) {
|
|
159
|
+
result.stopped = true;
|
|
160
|
+
break;
|
|
161
|
+
}
|
|
162
|
+
const { file } = job;
|
|
163
|
+
try {
|
|
164
|
+
const entry = state.entries[file.path];
|
|
165
|
+
if (job.create || !entry) {
|
|
166
|
+
await create(file);
|
|
167
|
+
continue;
|
|
168
|
+
}
|
|
169
|
+
const prepared = await target.prepare({
|
|
170
|
+
id: entry.id,
|
|
171
|
+
file,
|
|
172
|
+
recorded: entry.snapshot,
|
|
173
|
+
force,
|
|
174
|
+
...(signal ? { signal } : {}),
|
|
175
|
+
});
|
|
176
|
+
if ("conflict" in prepared) {
|
|
177
|
+
result.conflicts.push({ path: file.path, url: entry.url, reason: prepared.conflict });
|
|
178
|
+
continue;
|
|
179
|
+
}
|
|
180
|
+
if ("missing" in prepared) {
|
|
181
|
+
if (!force) {
|
|
182
|
+
result.missing.push(file.path);
|
|
183
|
+
continue;
|
|
184
|
+
}
|
|
185
|
+
delete state.entries[file.path];
|
|
186
|
+
await create(file);
|
|
187
|
+
continue;
|
|
188
|
+
}
|
|
189
|
+
let pushed;
|
|
190
|
+
if ("unchanged" in prepared) {
|
|
191
|
+
pushed = prepared.unchanged;
|
|
192
|
+
result.unchanged++;
|
|
193
|
+
}
|
|
194
|
+
else {
|
|
195
|
+
const operationId = options.operationId();
|
|
196
|
+
options.assertHeld();
|
|
197
|
+
pushed = await options.guard({ action: "update", path: file.path, operationId }, () => prepared.write());
|
|
198
|
+
result.updated.push(file.path);
|
|
199
|
+
}
|
|
200
|
+
state.entries[file.path] = {
|
|
201
|
+
...pushed,
|
|
202
|
+
kind: file.kind,
|
|
203
|
+
hash: file.hash,
|
|
204
|
+
pushedAt: new Date(now()).toISOString(),
|
|
205
|
+
};
|
|
206
|
+
save();
|
|
207
|
+
}
|
|
208
|
+
catch (error) {
|
|
209
|
+
const failure = options.errorOf(error);
|
|
210
|
+
if (failure.code === "cancelled") {
|
|
211
|
+
result.stopped = true;
|
|
212
|
+
break;
|
|
213
|
+
}
|
|
214
|
+
result.errors.push({ path: file.path, code: failure.code, message: failure.message });
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
return result;
|
|
218
|
+
};
|
package/dist/services/inbox.d.ts
CHANGED
|
@@ -39,6 +39,7 @@ export declare const capped: (chats: Chat[], most: number) => {
|
|
|
39
39
|
* Other people's unread messages, as the messenger counts them: for each chat with a count, its
|
|
40
40
|
* newest that many. Reading marks nothing read, so the same messages come back until they are read
|
|
41
41
|
* somewhere else — right for a person, wrong for a scheduled run, which is what `since` is for.
|
|
42
|
+
* A chat the owner marked unread is there with no count too, with its newest message from someone else.
|
|
42
43
|
*
|
|
43
44
|
* Muted and archived chats are left out unless `all`, or they mention the owner: on a busy account
|
|
44
45
|
* they are most of what is unread, and they would take the history reads a person's chat needs.
|
package/dist/services/inbox.js
CHANGED
|
@@ -15,8 +15,8 @@ export const REVIEW_DAYS = 3;
|
|
|
15
15
|
export const UNANSWERED_HOURS = 24;
|
|
16
16
|
export const reviewStart = (now = Date.now()) => now - REVIEW_DAYS * 86_400_000;
|
|
17
17
|
export const byRecency = (chats) => chats.toSorted((a, b) => Date.parse(b.lastMessageAt ?? "") - Date.parse(a.lastMessageAt ?? ""));
|
|
18
|
-
/** Muted or archived, and nothing in it mentions the owner or replies to them. */
|
|
19
|
-
const isQuiet = (chat) => (chat.muted === true || chat.archived === true) && (chat.unreadMentions ?? 0) === 0;
|
|
18
|
+
/** Muted or archived, and nothing in it mentions the owner or replies to them; one the owner marked unread never is. */
|
|
19
|
+
const isQuiet = (chat) => (chat.muted === true || chat.archived === true) && (chat.unreadMentions ?? 0) === 0 && chat.markedUnread !== true;
|
|
20
20
|
export const heard = (chats, all) => all
|
|
21
21
|
? { heard: chats, quiet: 0 }
|
|
22
22
|
: { heard: chats.filter((chat) => !isQuiet(chat)), quiet: chats.filter(isQuiet).length };
|
|
@@ -75,17 +75,24 @@ export const capped = (chats, most) => ({
|
|
|
75
75
|
* Other people's unread messages, as the messenger counts them: for each chat with a count, its
|
|
76
76
|
* newest that many. Reading marks nothing read, so the same messages come back until they are read
|
|
77
77
|
* somewhere else — right for a person, wrong for a scheduled run, which is what `since` is for.
|
|
78
|
+
* A chat the owner marked unread is there with no count too, with its newest message from someone else.
|
|
78
79
|
*
|
|
79
80
|
* Muted and archived chats are left out unless `all`, or they mention the owner: on a busy account
|
|
80
81
|
* they are most of what is unread, and they would take the history reads a person's chat needs.
|
|
81
82
|
*/
|
|
82
83
|
export const unreadIn = async (adapter, { limit, all = false, kinds }) => {
|
|
83
84
|
const page = await everyChat(adapter);
|
|
84
|
-
const { heard: waiting, quiet } = heard(byRecency(ofKinds(page.items, kinds).filter((chat) => (chat.unreadCount ?? 0) > 0)), all);
|
|
85
|
+
const { heard: waiting, quiet } = heard(byRecency(ofKinds(page.items, kinds).filter((chat) => (chat.unreadCount ?? 0) > 0 || chat.markedUnread === true)), all);
|
|
85
86
|
const { read, skipped } = capped(waiting, INBOX_CHATS);
|
|
86
87
|
const chats = [];
|
|
87
88
|
for (const { id, title, kind, unreadCount } of read) {
|
|
88
89
|
const count = unreadCount ?? 0;
|
|
90
|
+
if (count === 0) {
|
|
91
|
+
const newest = (await adapter.history(id, { limit })).items.findLast((message) => !message.outgoing);
|
|
92
|
+
if (newest)
|
|
93
|
+
chats.push({ id, title, kind, unreadCount, messages: [newest], more: false });
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
89
96
|
const wanted = Math.min(count, limit);
|
|
90
97
|
const { items } = await adapter.history(id, { limit: wanted });
|
|
91
98
|
const theirs = items.slice(-wanted).filter((message) => !message.outgoing);
|
package/dist/store/keys.d.ts
CHANGED
|
@@ -10,7 +10,7 @@ export declare const keysOver: (context: StoreContext) => {
|
|
|
10
10
|
chatPkFor: (accountKey: number, chatId: Id) => number;
|
|
11
11
|
answeredChatPk: (accountKey: number, chatId: Id, typed: Id) => number;
|
|
12
12
|
identityPk: (accountKey: number, provider: Provider, nativeId: Id, name: string | null, facts?: Omit<PersonFacts, "id" | "name">) => number;
|
|
13
|
-
upsertMessage: (key: AccountKey, accountKey: number, chatKey: number, message: Message, via: string, seenAt?: number) =>
|
|
13
|
+
upsertMessage: (key: AccountKey, accountKey: number, chatKey: number, message: Message, via: string, seenAt?: number) => boolean;
|
|
14
14
|
writeMembers: (key: AccountKey, accountKey: number, chatId: Id, memberIds: Id[]) => void;
|
|
15
15
|
writeState: (accountKey: number, name: string, value: string) => void;
|
|
16
16
|
};
|
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, PersonReaction, 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";
|
|
@@ -15,6 +15,9 @@ export interface AccountMessages {
|
|
|
15
15
|
*
|
|
16
16
|
* `typedChatId` is the id the owner wrote to when the messenger answered `chatId`: a chat row made for `chatId`
|
|
17
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`).
|
|
18
21
|
*/
|
|
19
22
|
saveMessages(key: AccountKey, chatId: Id, messages: Message[], options: {
|
|
20
23
|
via: IngestedVia;
|
|
@@ -32,6 +35,7 @@ export interface AccountMessages {
|
|
|
32
35
|
starred: boolean;
|
|
33
36
|
}[];
|
|
34
37
|
typedChatId?: Id;
|
|
38
|
+
countUnread?: boolean;
|
|
35
39
|
}): Promise<void>;
|
|
36
40
|
/** Oldest to newest, like a provider's history page. */
|
|
37
41
|
/**
|
|
@@ -108,6 +112,8 @@ export interface AccountMessages {
|
|
|
108
112
|
saveReaction(key: AccountKey, chatId: Id, messageId: Id, reaction: PersonReaction): Promise<void>;
|
|
109
113
|
/** The owner's star on a message, or its removal; answers whether the message is held at all. */
|
|
110
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>;
|
|
111
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. */
|
|
112
118
|
saveVote(key: AccountKey, chatId: Id, messageId: Id, vote: PollVote): Promise<void>;
|
|
113
119
|
/** Each voter's newest vote in the poll a message carries, newest first. */
|
package/dist/store/messages.js
CHANGED
|
@@ -11,12 +11,14 @@ 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 = [], reactions = [], 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);
|
|
@@ -85,6 +87,17 @@ export const accountMessagesOver = (context) => {
|
|
|
85
87
|
});
|
|
86
88
|
return saved;
|
|
87
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
|
+
},
|
|
88
101
|
saveReaction: async (key, chatId, messageId, reaction) => write(database, () => {
|
|
89
102
|
const accountKey = accountPk(key);
|
|
90
103
|
messageWrites.saveReaction(context, accountKey, key.provider, chatPkFor(accountKey, chatId), messageId, reaction);
|
|
@@ -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);
|
|
@@ -160,6 +163,10 @@ const privacyOf = (row) => ({
|
|
|
160
163
|
const MESSENGER_FIELDS = [
|
|
161
164
|
"title",
|
|
162
165
|
"unread_count",
|
|
166
|
+
"unread_counted_at",
|
|
167
|
+
"muted",
|
|
168
|
+
"archived",
|
|
169
|
+
"marked_unread",
|
|
163
170
|
"participants_count",
|
|
164
171
|
"metadata",
|
|
165
172
|
"username",
|
|
@@ -206,8 +213,9 @@ const moveChat = (context, key, accountKey, old, twin, from, to) => {
|
|
|
206
213
|
// Left only when both were: a complete chat list that named the new id before the re-key marked the old one.
|
|
207
214
|
`UPDATE chats SET ${fields}, kind = CASE WHEN kind = 'unknown' THEN ? ELSE kind END,
|
|
208
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,
|
|
209
217
|
searchable = ?, scope = ?, last_message_at = ?, updated_at = ? WHERE id = ?`)
|
|
210
|
-
.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);
|
|
211
219
|
}
|
|
212
220
|
context.database.prepare("UPDATE chats SET external_id = ?, updated_at = ? WHERE id = ?").run(to, context.now(), kept);
|
|
213
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>;
|