@wirecat/cli-core 0.419.0 → 0.420.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.
Files changed (100) hide show
  1. package/CHANGELOG.md +36 -0
  2. package/dist/domain/providers.d.ts +20 -2
  3. package/dist/domain/providers.js +23 -3
  4. package/dist/domain/tag-consolidation.d.ts +30 -1
  5. package/dist/entities/index.d.ts +4 -1
  6. package/dist/index.d.ts +1 -1
  7. package/dist/index.js +1 -1
  8. package/dist/meetings/calendar-text.d.ts +13 -0
  9. package/dist/meetings/calendar-text.js +57 -0
  10. package/dist/meetings/event-store.d.ts +202 -0
  11. package/dist/meetings/event-store.js +1 -0
  12. package/dist/meetings/events.js +5 -1
  13. package/dist/meetings/index.d.ts +3 -0
  14. package/dist/meetings/index.js +2 -0
  15. package/dist/meetings/join-key.d.ts +7 -0
  16. package/dist/meetings/join-key.js +38 -0
  17. package/dist/meetings/model.d.ts +8 -2
  18. package/dist/meetings/read-answers.d.ts +6 -2
  19. package/dist/meetings/read-answers.js +9 -2
  20. package/dist/meetings/store.d.ts +2 -0
  21. package/dist/meetings/testing/event-store-contract.d.ts +10 -0
  22. package/dist/meetings/testing/event-store-contract.js +161 -0
  23. package/dist/meetings/testing/index.d.ts +1 -0
  24. package/dist/meetings/testing/index.js +1 -0
  25. package/dist/search/account-list.js +39 -7
  26. package/dist/services/agenda.js +5 -5
  27. package/dist/services/document-source.d.ts +7 -3
  28. package/dist/services/document-source.js +24 -12
  29. package/dist/services/documents/ingestion.d.ts +6 -1
  30. package/dist/services/documents/ingestion.js +4 -0
  31. package/dist/services/notes-search.js +3 -2
  32. package/dist/services/search-all.js +1 -0
  33. package/dist/services/task-tag-filters.d.ts +3 -0
  34. package/dist/services/task-tag-filters.js +8 -0
  35. package/dist/services/tasks.js +5 -9
  36. package/dist/store/document-transfer/index.js +12 -2
  37. package/dist/store/index.d.ts +2 -1
  38. package/dist/store/sqlite/accounts.js +1 -0
  39. package/dist/store/sqlite/calendar.d.ts +3 -0
  40. package/dist/store/sqlite/calendar.js +593 -0
  41. package/dist/store/sqlite/catalog.js +16 -5
  42. package/dist/store/sqlite/chat-rekey.js +1 -1
  43. package/dist/store/sqlite/chats.js +2 -2
  44. package/dist/store/sqlite/context-document-source.js +8 -4
  45. package/dist/store/sqlite/document-transfer/apply.js +24 -2
  46. package/dist/store/sqlite/document-transfer/collection.d.ts +19 -0
  47. package/dist/store/sqlite/document-transfer/collection.js +66 -0
  48. package/dist/store/sqlite/document-transfer/plan.js +15 -8
  49. package/dist/store/sqlite/document-transfer/snapshot.js +12 -7
  50. package/dist/store/sqlite/entity-types.d.ts +5 -1
  51. package/dist/store/sqlite/entity-types.js +5 -1
  52. package/dist/store/sqlite/events.d.ts +8 -2
  53. package/dist/store/sqlite/events.js +36 -12
  54. package/dist/store/sqlite/files.d.ts +93 -0
  55. package/dist/store/sqlite/files.js +293 -0
  56. package/dist/store/sqlite/folded.d.ts +3 -0
  57. package/dist/store/sqlite/folded.js +1 -0
  58. package/dist/store/sqlite/imported-documents.js +1 -1
  59. package/dist/store/sqlite/join-keys.d.ts +8 -0
  60. package/dist/store/sqlite/join-keys.js +27 -0
  61. package/dist/store/sqlite/knowledge.js +6 -6
  62. package/dist/store/sqlite/link-reviews.d.ts +9 -0
  63. package/dist/store/sqlite/link-reviews.js +12 -0
  64. package/dist/store/sqlite/lucene-fields.js +8 -6
  65. package/dist/store/sqlite/manifest.js +11 -0
  66. package/dist/store/sqlite/meeting-selection.js +1 -1
  67. package/dist/store/sqlite/meetings.js +6 -1
  68. package/dist/store/sqlite/migrations.generated.js +143 -0
  69. package/dist/store/sqlite/note-index.d.ts +8 -1
  70. package/dist/store/sqlite/note-index.js +14 -0
  71. package/dist/store/sqlite/note-search.js +6 -5
  72. package/dist/store/sqlite/notes.d.ts +18 -1
  73. package/dist/store/sqlite/notes.js +237 -40
  74. package/dist/store/sqlite/open-store.js +4 -0
  75. package/dist/store/sqlite/person-links.js +7 -2
  76. package/dist/store/sqlite/schema/canonical-tags.d.ts +87 -0
  77. package/dist/store/sqlite/schema/canonical-tags.js +8 -0
  78. package/dist/store/sqlite/schema/tag-consolidations.d.ts +270 -0
  79. package/dist/store/sqlite/schema/tag-consolidations.js +34 -0
  80. package/dist/store/sqlite/schema.d.ts +3589 -1634
  81. package/dist/store/sqlite/schema.js +191 -13
  82. package/dist/store/sqlite/tag-consolidation-apply.d.ts +3 -0
  83. package/dist/store/sqlite/tag-consolidation-apply.js +88 -0
  84. package/dist/store/sqlite/tag-consolidation-input.d.ts +16 -0
  85. package/dist/store/sqlite/tag-consolidation-input.js +52 -0
  86. package/dist/store/sqlite/tag-consolidation.js +51 -66
  87. package/dist/store/sqlite/tag-creation.js +17 -8
  88. package/dist/store/sqlite/tag-resolution.d.ts +5 -0
  89. package/dist/store/sqlite/tag-resolution.js +33 -0
  90. package/dist/store/sqlite/tags.d.ts +3 -1
  91. package/dist/store/sqlite/tags.js +70 -50
  92. package/dist/store/sqlite/tasks.d.ts +2 -0
  93. package/dist/store/sqlite/tasks.js +6 -1
  94. package/dist/store/sqlite/things.js +2 -1
  95. package/dist/store/sqlite/vocabulary.d.ts +1 -0
  96. package/dist/store/sqlite/vocabulary.js +30 -8
  97. package/dist/store/store.d.ts +6 -0
  98. package/dist/store/store.js +10 -0
  99. package/dist/store/tags.js +2 -2
  100. package/package.json +2 -1
@@ -0,0 +1,293 @@
1
+ import { CliError } from "@wirecat/cli-base";
2
+ import { fold } from "../normalize.js";
3
+ import { write, writeBatch } from "./transaction.js";
4
+ const isObject = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
5
+ /** A JSON column's object, or an object already parsed out of one. */
6
+ const objectOf = (value) => {
7
+ if (isObject(value))
8
+ return value;
9
+ if (typeof value !== "string")
10
+ return undefined;
11
+ try {
12
+ const parsed = JSON.parse(value);
13
+ return isObject(parsed) ? parsed : undefined;
14
+ }
15
+ catch {
16
+ return undefined;
17
+ }
18
+ };
19
+ const stringOf = (value) => (typeof value === "string" && value ? value : undefined);
20
+ const timeOf = (value) => {
21
+ const text = stringOf(value);
22
+ const at = text === undefined ? Number.NaN : Date.parse(text);
23
+ return Number.isFinite(at) ? at : null;
24
+ };
25
+ const LOCAL = new Set(["local", "obsidian"]);
26
+ /** Nested Drive folders deeper than this are cut at the root: a parent cycle must not recurse forever. */
27
+ const MAX_DEPTH = 64;
28
+ const nameOf = (path) => path.slice(path.lastIndexOf("/") + 1);
29
+ /** A local folder's own row, and its parents', made when a file under it is first written. */
30
+ const localFolder = (database, now, accountId, path) => {
31
+ if (!path)
32
+ return null;
33
+ const found = database
34
+ .prepare("SELECT id, path FROM files WHERE account_id = ? AND external_id = ? AND kind = 'folder'")
35
+ .get(accountId, path);
36
+ if (found)
37
+ return { id: Number(found.id), path: String(found.path) };
38
+ const slash = path.lastIndexOf("/");
39
+ const parent = localFolder(database, now, accountId, slash < 0 ? "" : path.slice(0, slash));
40
+ const row = database
41
+ .prepare("INSERT INTO files (account_id, external_id, kind, name, path, path_folded, parent_id, created_at, updated_at) " +
42
+ "VALUES (?, ?, 'folder', ?, ?, ?, ?, ?, ?) RETURNING id")
43
+ .get(accountId, path, nameOf(path), path, fold(path), parent?.id ?? null, now, now);
44
+ return { id: Number(row?.id), path };
45
+ };
46
+ /** A file's document is the one its writer saved it as (`import`); a change of it is logged as every link is. */
47
+ export const logFileLink = (database, now, fileId, from, to, method = "import", author = "rules") => database
48
+ .prepare("INSERT INTO entity_link_events (linkable_type, linkable_id, entity_type, from_entity_id, to_entity_id, method, " +
49
+ "confidence, author, created_at) VALUES ('file', ?, 'document', ?, ?, ?, 1, ?, ?)")
50
+ .run(fileId, from == null ? null : Number(from), to, method, author, now);
51
+ /** Moves the rows under a folder whose path changed, in one statement over the old prefix. */
52
+ const movePaths = (database, accountId, from, to) => {
53
+ database
54
+ .prepare("UPDATE files SET path = ? || substr(path, ?), path_folded = NULL WHERE account_id = ? AND substr(path, 1, ?) = ?")
55
+ .run(to, from.length + 1, accountId, from.length + 1, `${from}/`);
56
+ };
57
+ /**
58
+ * Writes the `files` row of one document from the document as it is now: the document's source record. A Drive
59
+ * document (catalog metadata or Drive front matter) is keyed by its Drive file id and placed under its first parent;
60
+ * a local one by its path, under folder rows made from that path. `seen` marks a sync or import that returned it.
61
+ * `place` names a local file of the document other than the one its own columns name: a copy in another folder.
62
+ */
63
+ export const syncFile = (database, now, documentId, { seen = true, depth = 0, place, method = "import", } = {}) => {
64
+ const doc = database.prepare("SELECT * FROM documents WHERE id = ?").get(documentId);
65
+ if (!doc)
66
+ return null;
67
+ const accountId = place?.accountId ?? Number(doc.account_id);
68
+ const front = place ? objectOf(place.frontMatter) : objectOf(doc.front_matter);
69
+ const catalog = place ? undefined : objectOf(objectOf(doc.metadata)?.catalog);
70
+ const provider = database.prepare("SELECT provider FROM accounts WHERE id = ?").get(accountId)?.provider;
71
+ // A vault file may carry an exported Drive file's front matter and still be a file on disk.
72
+ const remote = catalog !== undefined || (front?.source === "google-drive" && !LOCAL.has(String(provider)));
73
+ const externalId = (remote && stringOf(front?.fileId)) || (place?.path ?? String(doc.external_id));
74
+ const kind = doc.kind === "folder" ? "folder" : doc.kind === "page" ? "page" : "file";
75
+ let name;
76
+ let parent = null;
77
+ let metadata;
78
+ if (remote) {
79
+ name = stringOf(catalog?.name) ?? stringOf(doc.title) ?? externalId;
80
+ const parents = Array.isArray(catalog?.parents) ? catalog.parents.filter((one) => typeof one === "string") : [];
81
+ const first = parents[0];
82
+ if (first !== undefined && depth < MAX_DEPTH) {
83
+ const above = database
84
+ .prepare("SELECT id FROM documents WHERE account_id = ? AND external_id = ?")
85
+ .get(accountId, first);
86
+ if (above && Number(above.id) !== documentId)
87
+ parent = syncFile(database, now, Number(above.id), { seen: false, depth: depth + 1 });
88
+ }
89
+ const version = front?.sourceVersion;
90
+ metadata = {
91
+ ...(version === undefined || version === null ? {} : { version }),
92
+ ...(parents.length > 1 ? { parents } : {}),
93
+ };
94
+ }
95
+ else {
96
+ const path = place?.path ?? String(doc.external_id);
97
+ name = nameOf(path);
98
+ const slash = path.lastIndexOf("/");
99
+ parent = localFolder(database, now, accountId, slash < 0 ? "" : path.slice(0, slash));
100
+ metadata = front === undefined ? {} : { front_matter: front };
101
+ }
102
+ const path = remote ? (parent ? `${parent.path}/${name}` : name) : (place?.path ?? String(doc.external_id));
103
+ // The place first; then, for a Drive file first saved under its document's own key, that row (`<id>.md`).
104
+ const existing = (database
105
+ .prepare("SELECT * FROM files WHERE account_id = ? AND external_id = ?")
106
+ .get(accountId, place?.path ?? externalId) ??
107
+ (place
108
+ ? undefined
109
+ : database
110
+ .prepare("SELECT * FROM files WHERE document_id = ? AND account_id = ? AND external_id = ?")
111
+ .get(documentId, accountId, String(doc.external_id))));
112
+ const gone = place ? null : doc.deleted_at != null ? Number(doc.deleted_at) : catalog?.active === false ? now : null;
113
+ const values = {
114
+ account_id: accountId,
115
+ external_id: externalId,
116
+ kind,
117
+ name,
118
+ path,
119
+ path_folded: fold(path),
120
+ parent_id: parent?.id ?? null,
121
+ document_id: documentId,
122
+ mime: doc.mime ?? (remote ? (stringOf(front?.mime) ?? null) : null),
123
+ size: doc.size ?? (remote && Number.isSafeInteger(front?.size) ? front?.size : null),
124
+ content_hash: place ? place.contentHash : (doc.content_hash ?? null),
125
+ url: doc.url ?? (remote ? (stringOf(front?.sourceUrl) ?? null) : null),
126
+ external_created_at: doc.external_created_at ?? null,
127
+ external_updated_at: doc.external_updated_at ?? (remote ? timeOf(front?.modifiedAt) : null),
128
+ metadata: Object.keys(metadata).length ? JSON.stringify(metadata) : null,
129
+ seen_at: seen ? now : (existing?.seen_at ?? timeOf(catalog?.observedAt)),
130
+ deleted_at: gone === null ? null : existing?.deleted_at != null ? Number(existing.deleted_at) : gone,
131
+ };
132
+ if (!existing) {
133
+ const columns = Object.keys(values);
134
+ const row = database
135
+ .prepare(`INSERT INTO files (${columns.join(", ")}, created_at, updated_at) VALUES (${columns.map(() => "?").join(", ")}, ?, ?) RETURNING id`)
136
+ .get(...Object.values(values), Number(doc.created_at), now);
137
+ const placed = { id: Number(row?.id), path };
138
+ logFileLink(database, now, placed.id, null, documentId, method);
139
+ if (remote && kind === "folder")
140
+ placeChildren(database, now, accountId, String(doc.external_id), depth);
141
+ return placed;
142
+ }
143
+ const id = Number(existing.id);
144
+ if (existing.document_id !== documentId)
145
+ logFileLink(database, now, id, existing.document_id, documentId, method);
146
+ const changed = Object.entries(values).some(([key, value]) => key !== "seen_at" && existing[key] !== value);
147
+ if (changed || values.seen_at !== existing.seen_at) {
148
+ database
149
+ .prepare(`UPDATE files SET ${Object.keys(values)
150
+ .map((key) => `${key} = ?`)
151
+ .join(", ")}, updated_at = ? WHERE id = ?`)
152
+ .run(...Object.values(values), changed ? now : Number(existing.updated_at), id);
153
+ if (kind === "folder" && existing.path !== path)
154
+ movePaths(database, accountId, String(existing.path), path);
155
+ }
156
+ return { id, path };
157
+ };
158
+ /** A Drive item written before its folder sat at the root; the folder's first write puts it under it. */
159
+ const placeChildren = (database, now, accountId, folder, depth) => {
160
+ const orphans = database
161
+ .prepare("SELECT d.id FROM documents d JOIN files f ON f.document_id = d.id WHERE d.account_id = ? AND f.parent_id IS NULL " +
162
+ "AND json_valid(d.metadata) AND json_extract(d.metadata, '$.catalog.parents[0]') = ?")
163
+ .all(accountId, folder);
164
+ for (const row of orphans)
165
+ syncFile(database, now, Number(row.id), { seen: false, depth: depth + 1 });
166
+ };
167
+ export const FILES_BACKFILL = "files_backfill_after";
168
+ /**
169
+ * Writes the `files` row of every document stored before version 32, from the document after the cursor the
170
+ * migration left, in short writes: another process waits for one batch at most, and a stop resumes where it was.
171
+ */
172
+ export const fillFiles = (database, now, { batch = 500 } = {}) => {
173
+ const cursor = database.prepare("SELECT value FROM store_settings WHERE key = ?").get(FILES_BACKFILL);
174
+ if (!cursor)
175
+ return 0;
176
+ let after = Number(cursor.value);
177
+ let filled = 0;
178
+ const next = database.prepare("SELECT id FROM documents WHERE id > ? ORDER BY id LIMIT ?");
179
+ for (;;) {
180
+ const ids = writeBatch(database, () => {
181
+ const page = next.all(after, batch).map((row) => Number(row.id));
182
+ const at = now();
183
+ for (const id of page)
184
+ syncFile(database, at, id, { seen: false });
185
+ if (page.length < batch)
186
+ database.prepare("DELETE FROM store_settings WHERE key = ?").run(FILES_BACKFILL);
187
+ else
188
+ database
189
+ .prepare("UPDATE store_settings SET value = ?, at = ? WHERE key = ?")
190
+ .run(String(page.at(-1)), at, FILES_BACKFILL);
191
+ return page;
192
+ });
193
+ filled += ids.length;
194
+ if (ids.length < batch)
195
+ return filled;
196
+ after = ids.at(-1);
197
+ }
198
+ };
199
+ const documentIdOf = (reference) => {
200
+ const id = /^(?:document:)?(\d+)$/u.exec(reference)?.[1];
201
+ if (id === undefined)
202
+ throw new CliError("validation_error", `${reference} is not a document reference`);
203
+ return Number(id);
204
+ };
205
+ const isoOrNull = (value) => (value == null ? null : new Date(Number(value)).toISOString());
206
+ const idOrNull = (value) => (value == null ? null : String(value));
207
+ export const fileOf = (row) => ({
208
+ id: String(row.id),
209
+ accountId: String(row.account_id),
210
+ externalId: String(row.external_id),
211
+ kind: row.kind,
212
+ name: String(row.name),
213
+ path: String(row.path),
214
+ parentId: idOrNull(row.parent_id),
215
+ document: row.document_id == null ? null : `document:${row.document_id}`,
216
+ mime: idOrNull(row.mime),
217
+ size: row.size == null ? null : Number(row.size),
218
+ contentHash: idOrNull(row.content_hash),
219
+ url: idOrNull(row.url),
220
+ externalCreatedAt: isoOrNull(row.external_created_at),
221
+ externalUpdatedAt: isoOrNull(row.external_updated_at),
222
+ metadata: objectOf(row.metadata) ?? null,
223
+ seenAt: isoOrNull(row.seen_at),
224
+ deletedAt: isoOrNull(row.deleted_at),
225
+ });
226
+ /**
227
+ * The file a document shows (requirements 3.3): live ones first, then the owner's pin, then the most recently changed.
228
+ * Read with the document's row joined as `d`.
229
+ */
230
+ export const PREFERRED_FILE = "f.deleted_at IS NOT NULL, f.id IS NOT d.pinned_file_id, coalesce(f.external_updated_at, f.updated_at) DESC, f.id DESC";
231
+ export const filesStoreOver = ({ database, now }) => {
232
+ const account = (id) => {
233
+ if (!/^\d+$/.test(id) || !database.prepare("SELECT 1 FROM accounts WHERE id = ?").get(Number(id)))
234
+ throw new CliError("not_found", `no account ${id} in the local store`);
235
+ return Number(id);
236
+ };
237
+ return {
238
+ list: async (accountId, query = {}) => {
239
+ const { limit = 100, offset = 0 } = query;
240
+ if (!Number.isInteger(limit) || limit < 1 || limit > 1000 || !Number.isInteger(offset) || offset < 0)
241
+ throw new CliError("validation_error", "limit takes 1–1000 and offset 0 or more");
242
+ const clauses = ["f.account_id = ?"];
243
+ const params = [account(accountId)];
244
+ if (!query.includeDeleted)
245
+ clauses.push("f.deleted_at IS NULL");
246
+ if (query.kind !== undefined) {
247
+ clauses.push("f.kind = ?");
248
+ params.push(query.kind);
249
+ }
250
+ if (query.under !== undefined) {
251
+ const prefix = fold(query.under.replace(/\/+$/u, ""));
252
+ // `/` sorts just before `0`, so the subtree is one range over the index.
253
+ clauses.push("(f.path_folded = ? OR (f.path_folded >= ? AND f.path_folded < ?))");
254
+ params.push(prefix, `${prefix}/`, `${prefix}0`);
255
+ }
256
+ if (query.pathContains !== undefined) {
257
+ clauses.push("instr(f.path_folded, ?) > 0");
258
+ params.push(fold(query.pathContains));
259
+ }
260
+ const rows = database
261
+ .prepare(`SELECT f.* FROM files f WHERE ${clauses.join(" AND ")} ORDER BY f.path_folded, f.id LIMIT ? OFFSET ?`)
262
+ .all(...params, limit + 1, offset);
263
+ return { items: rows.slice(0, limit).map(fileOf), hasMore: rows.length > limit };
264
+ },
265
+ byPath: async (accountId, path) => {
266
+ const row = database
267
+ .prepare("SELECT * FROM files WHERE account_id = ? AND path = ? ORDER BY deleted_at IS NOT NULL, id LIMIT 1")
268
+ .get(account(accountId), path);
269
+ return row ? fileOf(row) : undefined;
270
+ },
271
+ ofDocument: async (reference) => database
272
+ .prepare(`SELECT f.* FROM files f JOIN documents d ON d.id = f.document_id WHERE f.document_id = ? ORDER BY ${PREFERRED_FILE}`)
273
+ .all(documentIdOf(reference))
274
+ .map(fileOf),
275
+ pin: async (document, fileId) => write(database, () => {
276
+ const id = documentIdOf(document);
277
+ const file = /^\d+$/u.test(fileId)
278
+ ? database.prepare("SELECT id FROM files WHERE id = ? AND document_id = ?").get(Number(fileId), id)
279
+ : undefined;
280
+ if (!file)
281
+ throw new CliError("not_found", `file ${fileId} is not a file of ${document}`);
282
+ database
283
+ .prepare("UPDATE documents SET pinned_file_id = ?, updated_at = ? WHERE id = ?")
284
+ .run(Number(fileId), now(), id);
285
+ }),
286
+ unpin: async (document) => write(database, () => {
287
+ const id = documentIdOf(document);
288
+ if (!database.prepare("SELECT 1 FROM documents WHERE id = ?").get(id))
289
+ throw new CliError("not_found", `no ${document} in the local store`);
290
+ database.prepare("UPDATE documents SET pinned_file_id = NULL, updated_at = ? WHERE id = ?").run(now(), id);
291
+ }),
292
+ };
293
+ };
@@ -15,6 +15,9 @@ export declare const FOLDED: readonly [{
15
15
  }, {
16
16
  readonly table: "meetings";
17
17
  readonly column: "title";
18
+ }, {
19
+ readonly table: "files";
20
+ readonly column: "path";
18
21
  }];
19
22
  export declare const folded: (value: string | null | undefined) => string | null;
20
23
  /**
@@ -7,6 +7,7 @@ export const FOLDED = [
7
7
  { table: "attachments", column: "name" },
8
8
  { table: "attachments", column: "local_path" },
9
9
  { table: "meetings", column: "title" },
10
+ { table: "files", column: "path" },
10
11
  ];
11
12
  export const folded = (value) => value === null || value === undefined ? null : fold(value);
12
13
  /**
@@ -32,7 +32,7 @@ export const importedDocumentWhere = (database, query) => {
32
32
  const columns = {
33
33
  tag: {
34
34
  where: (node) => ({
35
- sql: "EXISTS (SELECT 1 FROM taggings g JOIN tags t ON t.id = g.tag_id " +
35
+ sql: "EXISTS (SELECT 1 FROM taggings g JOIN resolved_tags t ON t.id=g.tag_id " +
36
36
  "WHERE g.taggable_type = 'document' AND g.taggable_id = d.id AND t.name = ?)",
37
37
  params: [tag(node.value)],
38
38
  }),
@@ -0,0 +1,8 @@
1
+ import type { CacheDatabase } from "../driver.js";
2
+ /**
3
+ * Fills `meetings.join_key` where it is empty: meetings stored before version 33, and those a build of version 32
4
+ * writes, which does not know the column. On every open; a store with nothing to fill costs one index probe.
5
+ */
6
+ export declare const fillJoinKeys: (database: CacheDatabase, { batch }?: {
7
+ batch?: number;
8
+ }) => number;
@@ -0,0 +1,27 @@
1
+ import { joinKeyOf } from "../../meetings/index.js";
2
+ import { writeBatch } from "./transaction.js";
3
+ /**
4
+ * Fills `meetings.join_key` where it is empty: meetings stored before version 33, and those a build of version 32
5
+ * writes, which does not know the column. On every open; a store with nothing to fill costs one index probe.
6
+ */
7
+ export const fillJoinKeys = (database, { batch = 2_000 } = {}) => {
8
+ const pending = database.prepare("SELECT id, join_url FROM meetings WHERE join_url IS NOT NULL AND join_key IS NULL AND id > ? ORDER BY id LIMIT ?");
9
+ if (pending.get(0, 1) === undefined)
10
+ return 0;
11
+ // A link that names no room keeps an empty key, so it is not read again on every open.
12
+ const fill = database.prepare("UPDATE meetings SET join_key = ? WHERE id = ?");
13
+ let filled = 0;
14
+ let after = 0;
15
+ for (;;) {
16
+ const rows = writeBatch(database, () => {
17
+ const page = pending.all(after, batch);
18
+ for (const row of page)
19
+ fill.run(joinKeyOf(String(row.join_url)) ?? "", Number(row.id));
20
+ return page;
21
+ });
22
+ filled += rows.length;
23
+ if (rows.length < batch)
24
+ return filled;
25
+ after = Number(rows.at(-1)?.id);
26
+ }
27
+ };
@@ -10,7 +10,7 @@ import { linkKind, RELATION_KINDS } from "./link-kinds.js";
10
10
  import { meetingProjectsStoreOver } from "./meeting-projects.js";
11
11
  import { insertLink, linkOf } from "./notes.js";
12
12
  import { queryFilter } from "./query-filter.js";
13
- import { addTags as addThingTags, canonicalTagNamed, createTag, labelsOf, removeTags as removeThingTags, setMainTopic, } from "./tags.js";
13
+ import { addTags as addThingTags, canonicalTagNamed, createTag, labelsOf, liveTagging, removeTags as removeThingTags, setMainTopic, } from "./tags.js";
14
14
  import { TASK_KEY_PREFIXES } from "./task-keys.js";
15
15
  import { inboxKey, taskAccountOf, taskIdOf, taskRowOf } from "./tasks.js";
16
16
  import { referenceOfThing, stateOfThing, storedThing, thingOf } from "./things.js";
@@ -155,7 +155,7 @@ export const knowledgeStoreOver = (context) => {
155
155
  : null;
156
156
  /** A subfolder's labels: `labelled` links from the folder, anchored at the path, to each tag. */
157
157
  const subfolderLabels = (folder, path, tag) => database
158
- .prepare("SELECT t.name FROM links l JOIN tags t ON t.id = l.to_id WHERE l.from_type = 'account' AND l.from_id = ? " +
158
+ .prepare("SELECT DISTINCT t.name FROM links l JOIN resolved_tags t ON t.id=l.to_id WHERE l.from_type = 'account' AND l.from_id = ? " +
159
159
  `AND l.kind = ${linkKind("labelled")} AND l.to_type = 'tag' AND l.anchor = ?${tag === undefined ? "" : " AND t.name = ?"} ORDER BY t.name`)
160
160
  .all(folder, path, ...(tag === undefined ? [] : [tag]))
161
161
  .map((row) => String(row.name));
@@ -373,7 +373,7 @@ export const knowledgeStoreOver = (context) => {
373
373
  return write(database, () => removeThingTags(context, thing, tagsOf(tags)));
374
374
  return tagsOf(tags).filter((name) => database
375
375
  .prepare(`DELETE FROM links WHERE from_type = 'account' AND from_id = ? AND kind = ${linkKind("labelled")} AND to_type = 'tag' AND anchor = ? ` +
376
- "AND to_id IN (SELECT id FROM tags WHERE name = ?)")
376
+ "AND to_id IN (SELECT id FROM resolved_tags WHERE name = ?)")
377
377
  .run(thing.id, path, name).changes > 0);
378
378
  },
379
379
  createTag: async (name, { kind = "tag", by = "owner", ...options } = {}) => write(database, () => createTag(context, canonicalTagName(name), kind, by, options)),
@@ -391,9 +391,9 @@ export const knowledgeStoreOver = (context) => {
391
391
  const subfolders = options.type === undefined || options.type === "folder";
392
392
  const rows = database
393
393
  .prepare("SELECT * FROM (" +
394
- "SELECT DISTINCT g.taggable_type AS type, g.taggable_id AS id, NULL AS path FROM taggings g JOIN tags t ON t.id = g.tag_id " +
395
- `WHERE g.taggable_type IN (${types.map(() => "?").join(",") || "NULL"}) AND (? IS NULL OR t.name = ?) ` +
396
- "UNION SELECT DISTINCT 'account' AS type, l.from_id AS id, l.anchor AS path FROM links l JOIN tags t ON t.id = l.to_id " +
394
+ "SELECT DISTINCT g.taggable_type AS type, g.taggable_id AS id, NULL AS path FROM taggings g JOIN resolved_tags t ON t.id=g.tag_id " +
395
+ `WHERE g.taggable_type IN (${types.map(() => "?").join(",") || "NULL"}) AND (? IS NULL OR t.name = ?) AND ${liveTagging("g")} ` +
396
+ "UNION SELECT DISTINCT 'account' AS type, l.from_id AS id, l.anchor AS path FROM links l JOIN resolved_tags t ON t.id=l.to_id " +
397
397
  `WHERE ? = 1 AND l.from_type = 'account' AND l.kind = ${linkKind("labelled")} AND l.to_type = 'tag' AND (? IS NULL OR t.name = ?)` +
398
398
  ") ORDER BY type = 'account', type, id, path LIMIT ? OFFSET ?")
399
399
  .all(...types, tag, tag, subfolders ? 1 : 0, tag, tag, limit + 1, offset);
@@ -0,0 +1,9 @@
1
+ import type { CacheDatabase } from "../driver.js";
2
+ /**
3
+ * Two or more equally good entities for one source record: one row on the owner's review list (requirements 3.6). A
4
+ * later sync updates the candidates of an open row and leaves a decided one as the owner left it.
5
+ */
6
+ export declare const reviewLink: (database: CacheDatabase, linkable: {
7
+ type: string;
8
+ id: number;
9
+ }, entity: string, candidates: number[], reason: string, now: number) => void;
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Two or more equally good entities for one source record: one row on the owner's review list (requirements 3.6). A
3
+ * later sync updates the candidates of an open row and leaves a decided one as the owner left it.
4
+ */
5
+ export const reviewLink = (database, linkable, entity, candidates, reason, now) => {
6
+ database
7
+ .prepare(`INSERT INTO link_reviews (linkable_type, linkable_id, entity_type, candidates, reason, status, created_at, updated_at)
8
+ VALUES (?, ?, ?, ?, ?, 'open', ?, ?)
9
+ ON CONFLICT (linkable_type, linkable_id, entity_type) DO UPDATE SET candidates = excluded.candidates,
10
+ reason = excluded.reason, updated_at = excluded.updated_at WHERE link_reviews.status = 'open'`)
11
+ .run(linkable.type, linkable.id, entity, JSON.stringify([...candidates].sort((a, b) => a - b)), reason, now, now);
12
+ };
@@ -12,6 +12,7 @@ import { STEM_SEPARATOR } from "../../search/stem.js";
12
12
  import { normalize } from "../normalize.js";
13
13
  import { linkMatch } from "./mention-links.js";
14
14
  import { valueMatch } from "./phone-countries.js";
15
+ import { canonicalTagNamed } from "./tags.js";
15
16
  /**
16
17
  * A voice note's transcript is its attachment's text, whoever wrote it: speech recognition (`speech:<model>`) or an
17
18
  * agent, which writes the text of a voice attachment as `agent`. The voice attachment is the one
@@ -318,13 +319,14 @@ const found = ({ field, value }, scope) => {
318
319
  ? mentioned(scope, "mn.domain = ? AND mn.status <> 'dismissed'", [siteOf(value.toLowerCase())])
319
320
  : mentioned(scope, `mn.kind = ? AND ${match.sql} AND mn.status <> 'dismissed'`, [field, ...match.params]));
320
321
  };
321
- const tag = (node, { corpus }) => {
322
- const name = tagOf(node.value);
323
- if (name === undefined)
322
+ const tag = (node, { corpus, database }) => {
323
+ const selected = tagOf(node.value);
324
+ if (selected === undefined)
324
325
  searchError("query.invalid-tag", node.span);
325
- return always(bound(`m.id IN (SELECT tg.taggable_id FROM taggings tg JOIN tags t ON t.id=tg.tag_id WHERE t.name=? AND tg.taggable_type='${corpus.item}') OR ` +
326
- `m.chat_id IN (SELECT tg.taggable_id FROM taggings tg JOIN tags t ON t.id=tg.tag_id WHERE t.name=? AND tg.taggable_type='${corpus.thread}') OR ` +
327
- "m.sender_identity_id IN (SELECT tg.taggable_id FROM taggings tg JOIN tags t ON t.id=tg.tag_id WHERE t.name=? AND tg.taggable_type='identity')", name, name, name));
326
+ const name = canonicalTagNamed(database, selected)?.name ?? selected;
327
+ return always(bound(`m.id IN (SELECT tg.taggable_id FROM taggings tg JOIN resolved_tags t ON t.id=tg.tag_id WHERE t.name=? AND tg.taggable_type='${corpus.item}') OR ` +
328
+ `m.chat_id IN (SELECT tg.taggable_id FROM taggings tg JOIN resolved_tags t ON t.id=tg.tag_id WHERE t.name=? AND tg.taggable_type='${corpus.thread}') OR ` +
329
+ "m.sender_identity_id IN (SELECT tg.taggable_id FROM taggings tg JOIN resolved_tags t ON t.id=tg.tag_id WHERE t.name=? AND tg.taggable_type='identity')", name, name, name));
328
330
  };
329
331
  const messengerOnly = (compile) => (node, scope) => {
330
332
  if (scope.mail)
@@ -36,14 +36,25 @@ export const MANIFEST = [
36
36
  { name: "20261021224625_version-28-chat-state", version: 28, minCompatible: 1 },
37
37
  { name: "20261023105057_version-29-transcript-words", version: 29, minCompatible: 1 },
38
38
  { name: "20261023105101_version-29-transcript-words-triggers", version: 29, minCompatible: 1 },
39
+ { name: "20261025064253_version-30-reviewed-tag-consolidation", version: 30, minCompatible: 30 },
40
+ { name: "20261025064302_version-30-resolved-tag-view", version: 30, minCompatible: 30 },
41
+ { name: "20261025070402_version-31-drive-account", version: 31, minCompatible: 31 },
42
+ { name: "20261025071848_version-32-files", version: 32, minCompatible: 32 },
43
+ { name: "20261025071850_version-32-files-data", version: 32, minCompatible: 32 },
44
+ { name: "20261025072643_version-33-calendars", version: 33, minCompatible: 32 },
45
+ { name: "20261025072644_version-33-calendars-triggers", version: 33, minCompatible: 32 },
39
46
  ];
40
47
  export const generatedMigrations = (manifest = MANIFEST, generated = GENERATED) => {
41
48
  const migrations = [];
42
49
  for (const entry of manifest) {
50
+ if (!Number.isSafeInteger(entry.minCompatible) || entry.minCompatible < 1 || entry.minCompatible > entry.version)
51
+ throw new Error("each migration requires an explicit valid minCompatible");
43
52
  const found = generated.find(({ name }) => name === entry.name);
44
53
  if (!found)
45
54
  throw new Error(`migration ${entry.name} is in the manifest and not in the bundle — run pnpm db:bundle`);
46
55
  const last = migrations.at(-1);
56
+ if (last && last.version !== entry.version && entry.minCompatible < last.minCompatible)
57
+ throw new Error("migration minCompatible cannot decrease");
47
58
  if (last?.version !== entry.version) {
48
59
  migrations.push({ version: entry.version, minCompatible: entry.minCompatible, statements: [...found.statements] });
49
60
  }
@@ -35,7 +35,7 @@ export const meetingSelectionSql = (input) => {
35
35
  params.push(input.projectId);
36
36
  }
37
37
  if (input.tagId !== undefined) {
38
- clauses.push("EXISTS (SELECT 1 FROM taggings g JOIN tags t ON t.id=g.tag_id WHERE g.taggable_type='meeting' AND g.taggable_id=m.id AND t.id=?)");
38
+ clauses.push("EXISTS (SELECT 1 FROM taggings g JOIN resolved_tags t ON t.id=g.tag_id WHERE g.taggable_type='meeting' AND g.taggable_id=m.id AND t.canonical_id=(SELECT canonical_id FROM resolved_tags WHERE id=?))");
39
39
  params.push(input.tagId);
40
40
  }
41
41
  return { clauses, params };
@@ -1,10 +1,11 @@
1
1
  import { CliError } from "@wirecat/cli-base";
2
- import { MeetingError } from "../../meetings/index.js";
2
+ import { joinKeyOf, MeetingError } from "../../meetings/index.js";
3
3
  import { normalize } from "../normalize.js";
4
4
  import * as eventQueries from "./events.js";
5
5
  import { fromJson, int, page, str, toJson } from "./events.js";
6
6
  import { folded } from "./folded.js";
7
7
  import { meetingIdentityPk } from "./identities.js";
8
+ import { reviewLink } from "./link-reviews.js";
8
9
  import { meetingChatLinesOver } from "./meeting-chat-lines.js";
9
10
  import { meetingContextLinksOver } from "./meeting-context-links.js";
10
11
  import { meetingHitIndexOver } from "./meeting-hit-index.js";
@@ -250,6 +251,7 @@ const save = (context, input) => {
250
251
  description: meeting.description,
251
252
  location: meeting.location,
252
253
  join_url: meeting.joinUrl,
254
+ join_key: meeting.joinUrl ? (joinKeyOf(meeting.joinUrl) ?? "") : null,
253
255
  started_at: meeting.startedAt,
254
256
  ended_at: meeting.endedAt,
255
257
  duration_ms: meeting.durationMs,
@@ -638,6 +640,9 @@ export const meetingStoreOver = (context) => {
638
640
  async linkMeeting(id, eventId, now, mode) {
639
641
  eventQueries.linkMeeting(database, id, eventId, now, mode);
640
642
  },
643
+ async reviewMeetingLink(meetingId, eventIds, now) {
644
+ write(database, () => reviewLink(database, { type: "meeting", id: meetingId }, "event", eventIds, "join-key", now));
645
+ },
641
646
  async cursor(accountId) {
642
647
  return str(database.prepare("SELECT value FROM sync_cursors WHERE account_id = ? AND key = ?").get(accountId, CURSOR_KEY)
643
648
  ?.value);