@wirecat/cli-core 0.488.0 → 0.490.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 (43) hide show
  1. package/CHANGELOG.md +112 -0
  2. package/dist/calendar/cli/index.js +48 -13
  3. package/dist/calendar/index.d.ts +1 -1
  4. package/dist/calendar/mcp/index.d.ts +390 -0
  5. package/dist/calendar/mcp/index.js +50 -12
  6. package/dist/calendar/reads.d.ts +9 -2
  7. package/dist/calendar/reads.js +60 -5
  8. package/dist/calendar/recurrence.d.ts +20 -0
  9. package/dist/calendar/recurrence.js +74 -0
  10. package/dist/calendar/sync.d.ts +6 -0
  11. package/dist/calendar/sync.js +123 -41
  12. package/dist/calendar/testing/memory.js +90 -7
  13. package/dist/calendar/testing/sync-contract.js +90 -0
  14. package/dist/calendar/testing/writer-contract.d.ts +2 -6
  15. package/dist/calendar/testing/writer-contract.js +157 -29
  16. package/dist/calendar/testing/writer.d.ts +3 -1
  17. package/dist/calendar/testing/writer.js +11 -0
  18. package/dist/calendar/writer.d.ts +20 -3
  19. package/dist/calendar/writer.js +8 -4
  20. package/dist/calendar/writes.d.ts +21 -12
  21. package/dist/calendar/writes.js +259 -20
  22. package/dist/errors/reasons.d.ts +1 -1
  23. package/dist/errors/reasons.js +5 -0
  24. package/dist/meetings/event-store.d.ts +80 -2
  25. package/dist/meetings/event-store.js +65 -1
  26. package/dist/meetings/index.d.ts +2 -1
  27. package/dist/meetings/index.js +1 -0
  28. package/dist/meetings/testing/event-store-contract.d.ts +1 -1
  29. package/dist/meetings/testing/event-store-contract.js +114 -0
  30. package/dist/store/drivers/node-sqlite.d.ts +9 -1
  31. package/dist/store/drivers/node-sqlite.js +30 -8
  32. package/dist/store/sqlite/calendar-events.d.ts +4 -0
  33. package/dist/store/sqlite/calendar-events.js +89 -0
  34. package/dist/store/sqlite/calendar.js +126 -94
  35. package/dist/store/sqlite/identities.d.ts +1 -1
  36. package/dist/store/sqlite/identities.js +4 -1
  37. package/dist/store/sqlite/knowledge.js +4 -2
  38. package/dist/store/sqlite/manifest.js +1 -0
  39. package/dist/store/sqlite/migrations.generated.js +10 -0
  40. package/dist/store/sqlite/note-index.d.ts +16 -1
  41. package/dist/store/sqlite/note-index.js +68 -3
  42. package/dist/store/sqlite/notes.js +7 -5
  43. package/package.json +1 -1
@@ -1,4 +1,5 @@
1
1
  import assert from "node:assert/strict";
2
+ import { eventChange } from "../event-store.js";
2
3
  const HOUR = 3_600_000;
3
4
  /** "Now" in every case: entries before it have happened. */
4
5
  export const CONTRACT_NOW = 1_000 * HOUR;
@@ -218,6 +219,119 @@ export const eventStoreContract = (make) => {
218
219
  assert.equal((await store.event(kept?.eventId))?.entries[0]?.deletedAt, null);
219
220
  assert.equal((await store.event(far?.eventId))?.entries[0]?.deletedAt, null);
220
221
  }),
222
+ test("a full list marks nothing outside its window: a single, a moved slot, a series that ended", async ({ store, calendar, }) => {
223
+ const month = 30 * 24 * HOUR;
224
+ const until = new Date(earlier - month).toISOString().replace(/[-:]|\.\d{3}/gu, "");
225
+ await store.saveEntries(calendar, [
226
+ single("old", earlier - month),
227
+ single("next", later + month),
228
+ single("inside", later),
229
+ single("ended", earlier - 2 * month, { kind: "series", recurrence: [`RRULE:FREQ=WEEKLY;UNTIL=${until}`] }),
230
+ single("endless", earlier - 2 * month, { kind: "series", recurrence: ["RRULE:FREQ=WEEKLY"] }),
231
+ single("moved", earlier - month, {
232
+ kind: "occurrence",
233
+ exception: true,
234
+ seriesExternalId: "endless",
235
+ originalStartsAt: earlier - month,
236
+ }),
237
+ ], 2);
238
+ assert.equal(await store.markUnseen(calendar, 10, { from: earlier, to: later + 24 * HOUR }, 11), 2);
239
+ const deleted = async (id) => (await store.entry(calendar, id))?.deletedAt;
240
+ assert.deepEqual(await Promise.all(["old", "next", "ended", "moved", "inside", "endless"].map(deleted)), [
241
+ null,
242
+ null,
243
+ null,
244
+ null,
245
+ 11,
246
+ 11,
247
+ ]);
248
+ }),
249
+ test("entries marked outside the window of the list that marked them come back, with their events", async ({ store, calendar, }) => {
250
+ const month = 30 * 24 * HOUR;
251
+ const [old, next, inside] = await store.saveEntries(calendar, [single("old", earlier - month), single("next", later + month), single("inside", later)], 2);
252
+ assert.equal(await store.markUnseen(calendar, 10, { from: 0, to: later + 2 * month }, 11), 3);
253
+ assert.equal((await store.event(next?.eventId))?.event.status, "cancelled");
254
+ assert.equal(await store.restoreUnseen(calendar, () => ({ from: earlier, to: later + 24 * HOUR }), 12), 2);
255
+ const oldEvent = (await store.event(old?.eventId))?.event;
256
+ const nextEvent = (await store.event(next?.eventId))?.event;
257
+ assert.equal(oldEvent?.sourceDeletedAt, null);
258
+ assert.equal(nextEvent?.status, "confirmed");
259
+ assert.equal((await store.entry(calendar, "old"))?.deletedAt, null);
260
+ assert.equal((await store.entry(calendar, "inside"))?.deletedAt, 11);
261
+ assert.equal(await store.restoreUnseen(calendar, () => ({ from: earlier, to: later + 24 * HOUR }), 13), 0);
262
+ assert.equal((await store.event(inside?.eventId))?.event.status, "cancelled");
263
+ }),
264
+ test("a list by time finds a long event that started well before it, timed or all-day", async ({ store, calendar, }) => {
265
+ const day = 24 * HOUR;
266
+ await store.saveEntries(calendar, [
267
+ single("trip", later - 30 * day, { endsAt: later + 30 * day }),
268
+ single("leave", later - 30 * day, {
269
+ allDay: true,
270
+ startDate: new Date(later - 30 * day).toISOString().slice(0, 10),
271
+ endDate: new Date(later + 30 * day).toISOString().slice(0, 10),
272
+ endsAt: later + 30 * day,
273
+ }),
274
+ single("today", later),
275
+ ], 2);
276
+ const day0 = new Date(later).toISOString().slice(0, 10);
277
+ const day1 = new Date(later + day).toISOString().slice(0, 10);
278
+ for (const filter of [
279
+ { from: later, to: later + day },
280
+ { from: later, to: later + day, fromDate: day0, toDate: day1 },
281
+ ])
282
+ assert.deepEqual((await store.events(filter)).map(({ title }) => title).length, 3, JSON.stringify(filter));
283
+ }),
284
+ test("a list of changes since an instant finds events new, moved or cancelled since, and says which", async ({ store, calendar, }) => {
285
+ await store.saveEntries(calendar, ["same", "moved", "back", "cancelled"].map((id) => single(id, later)), 2);
286
+ await store.saveEntries(calendar, [
287
+ single("moved", later + HOUR),
288
+ single("back", later + HOUR),
289
+ single("cancelled", later, { status: "cancelled" }),
290
+ single("new", later),
291
+ ], 10);
292
+ await store.saveEntries(calendar, [single("back", later)], 11);
293
+ const changed = async (since) => {
294
+ const events = await store.events({ changedSince: since });
295
+ const { entries, revisions } = await store.historyOf(events.map(({ id }) => id), [calendar], since);
296
+ return Object.fromEntries(events.map((event) => {
297
+ const [copy] = entries.filter(({ eventId }) => eventId === event.id);
298
+ return [
299
+ copy?.externalId,
300
+ eventChange(event, entries.filter(({ eventId }) => eventId === event.id), revisions, since),
301
+ ];
302
+ }));
303
+ };
304
+ assert.deepEqual(await changed(5), {
305
+ moved: { change: "moved", at: 10, was: { startsAt: later, endsAt: later + HOUR } },
306
+ cancelled: { change: "cancelled", at: 10 },
307
+ new: { change: "new", at: 10 },
308
+ });
309
+ assert.deepEqual(Object.keys(await changed(12)), []);
310
+ assert.equal((await changed(1)).same?.change, "new");
311
+ }),
312
+ test("a list of overlapping events holds timed events that clash and take the owner's time", async ({ store, calendar, }) => {
313
+ const at = later;
314
+ await store.saveEntries(calendar, [
315
+ single("first", at),
316
+ single("second", at + HOUR / 2),
317
+ single("alone", at + 3 * HOUR),
318
+ single("declined", at + 3 * HOUR + HOUR / 2, { ownResponse: "declined" }),
319
+ single("busy", at + 5 * HOUR),
320
+ single("free", at + 5 * HOUR, { transparency: "transparent" }),
321
+ single("cancelled", at + 3 * HOUR, { status: "cancelled" }),
322
+ single("all-day", at, {
323
+ allDay: true,
324
+ startDate: new Date(at).toISOString().slice(0, 10),
325
+ endDate: new Date(at + 24 * HOUR).toISOString().slice(0, 10),
326
+ endsAt: at + 24 * HOUR,
327
+ }),
328
+ ], 2);
329
+ const found = await store.events({ overlapping: true, from: at - HOUR, to: at + 8 * HOUR });
330
+ assert.deepEqual(found.map(({ title }) => title).length, 2);
331
+ const ids = new Set(found.map(({ id }) => id));
332
+ for (const externalId of ["first", "second"])
333
+ assert.ok(ids.has((await store.entry(calendar, externalId))?.eventId), externalId);
334
+ }),
221
335
  test("participants are kept by address; a room is listed and is no contact", async ({ store, calendar }) => {
222
336
  const [entry] = await store.saveEntries(calendar, [
223
337
  single("a", later, {
@@ -9,4 +9,12 @@ export declare const openWithNodeSqlite: (path: string) => Promise<CacheDatabase
9
9
  export declare const nodeClient: (path: string, { readOnly }?: {
10
10
  readOnly?: boolean;
11
11
  }) => Promise<DatabaseSync>;
12
- export declare const cacheOverNodeSqlite: (database: DatabaseSync) => CacheDatabase;
12
+ /**
13
+ * Preparing is the expensive part of a call: the sync write path builds the same SQL text thousands of times, so each
14
+ * connection keeps its statements, least recently used out first. Node's own `createTagStore` would do it, but it needs
15
+ * Node 24.9 and template tags, not SQL text. An evicted statement is not finalized, because a caller may still hold
16
+ * it; `close()` finalizes everything left. The seam must never offer `iterate`: callers share a cached statement, and
17
+ * reusing a statement invalidates its open iterator.
18
+ */
19
+ export declare const STATEMENT_CACHE_SIZE = 1000;
20
+ export declare const cacheOverNodeSqlite: (database: DatabaseSync, cacheSize?: number) => CacheDatabase;
@@ -19,17 +19,39 @@ export const nodeClient = async (path, { readOnly = false } = {}) => {
19
19
  throw error;
20
20
  }
21
21
  };
22
- export const cacheOverNodeSqlite = (database) => {
22
+ /**
23
+ * Preparing is the expensive part of a call: the sync write path builds the same SQL text thousands of times, so each
24
+ * connection keeps its statements, least recently used out first. Node's own `createTagStore` would do it, but it needs
25
+ * Node 24.9 and template tags, not SQL text. An evicted statement is not finalized, because a caller may still hold
26
+ * it; `close()` finalizes everything left. The seam must never offer `iterate`: callers share a cached statement, and
27
+ * reusing a statement invalidates its open iterator.
28
+ */
29
+ export const STATEMENT_CACHE_SIZE = 1000;
30
+ export const cacheOverNodeSqlite = (database, cacheSize = STATEMENT_CACHE_SIZE) => {
31
+ const statements = new Map();
32
+ const prepare = (sql) => {
33
+ const statement = database.prepare(sql);
34
+ return {
35
+ run: (...parameters) => ({ changes: Number(statement.run(...parameters).changes) }),
36
+ get: (...parameters) => statement.get(...parameters),
37
+ all: (...parameters) => statement.all(...parameters),
38
+ };
39
+ };
23
40
  return {
24
41
  exec: (sql) => database.exec(sql),
25
42
  prepare: (sql) => {
26
- const statement = database.prepare(sql);
27
- return {
28
- run: (...parameters) => ({ changes: Number(statement.run(...parameters).changes) }),
29
- get: (...parameters) => statement.get(...parameters),
30
- all: (...parameters) => statement.all(...parameters),
31
- };
43
+ const cached = statements.get(sql);
44
+ if (cached)
45
+ statements.delete(sql);
46
+ const statement = cached ?? prepare(sql);
47
+ statements.set(sql, statement);
48
+ if (statements.size > cacheSize)
49
+ statements.delete(statements.keys().next().value);
50
+ return statement;
51
+ },
52
+ close: () => {
53
+ statements.clear();
54
+ database.close();
32
55
  },
33
- close: () => database.close(),
34
56
  };
35
57
  };
@@ -0,0 +1,4 @@
1
+ import type { Event, EventFilter } from "../../meetings/index.js";
2
+ import type { CacheDatabase } from "../driver.js";
3
+ /** The events of a filter, earliest first (`EventStore.events`). */
4
+ export declare const listEvents: (database: CacheDatabase, filter: EventFilter) => Event[];
@@ -0,0 +1,89 @@
1
+ import { CliError } from "@wirecat/cli-base";
2
+ import { eventOf } from "./events.js";
3
+ const DAY = 86_400_000;
4
+ /** The events of a filter, earliest first (`EventStore.events`). */
5
+ export const listEvents = (database, filter) => {
6
+ const clauses = ["e.deleted_at IS NULL"];
7
+ const params = [];
8
+ const entries = (extra) => `EXISTS (SELECT 1 FROM calendar_entries ce JOIN calendars c ON c.id = ce.calendar_id WHERE ce.event_id = e.id AND ${extra})`;
9
+ const dated = (column, date, instant, at) => {
10
+ if (at === undefined)
11
+ return;
12
+ if (date === undefined) {
13
+ clauses.push(instant);
14
+ params.push(at);
15
+ return;
16
+ }
17
+ clauses.push(`(CASE WHEN e.all_day = 1 THEN EXISTS (SELECT 1 FROM calendar_entries ce WHERE ce.event_id = e.id AND ce.${column}) ELSE ${instant} END)`);
18
+ params.push(date, at);
19
+ };
20
+ dated("end_date > ?", filter.fromDate, "coalesce(e.ends_at, e.starts_at + 1) > ?", filter.from);
21
+ dated("start_date < ?", filter.toDate, "e.starts_at < ?", filter.to);
22
+ // Bounds on starts_at alone let SQLite seek its index; the CASE above cannot. They are wider than the CASE: by the
23
+ // longest stored event back, and by a day each way for an all-day event's zone.
24
+ const longest = () => Number(database.prepare("SELECT coalesce(max(ends_at - starts_at), 0) AS ms FROM events WHERE deleted_at IS NULL").get()
25
+ ?.ms ?? 0);
26
+ if (filter.from !== undefined) {
27
+ clauses.push("e.starts_at >= ?");
28
+ params.push(filter.from - longest() - DAY);
29
+ }
30
+ if (filter.to !== undefined && filter.toDate !== undefined) {
31
+ clauses.push("e.starts_at < ?");
32
+ params.push(filter.to + DAY);
33
+ }
34
+ if (filter.calendarId !== undefined) {
35
+ clauses.push(entries("ce.calendar_id = ?"));
36
+ params.push(filter.calendarId);
37
+ }
38
+ if (filter.accountId !== undefined) {
39
+ clauses.push(entries("c.account_id = ?"));
40
+ params.push(filter.accountId);
41
+ }
42
+ if (filter.personId !== undefined) {
43
+ clauses.push(entries("EXISTS (SELECT 1 FROM calendar_entry_participants p JOIN identity_links il ON il.identity_id = p.identity_id " +
44
+ "WHERE p.entry_id = ce.id AND il.person_id = ?)"));
45
+ params.push(filter.personId);
46
+ }
47
+ const account = (alias) => {
48
+ if (filter.accountId === undefined)
49
+ return "";
50
+ params.push(filter.accountId);
51
+ return ` AND ${alias}.account_id = ?`;
52
+ };
53
+ if (filter.changedSince !== undefined) {
54
+ // The SQL form of eventChange: first stored since, cancelled since, or a live copy's time changed since.
55
+ const since = filter.changedSince;
56
+ const own = account("c");
57
+ params.push(since);
58
+ const older = account("oc");
59
+ params.push(since, since, since, since);
60
+ clauses.push(`EXISTS (SELECT 1 FROM calendar_entries ce JOIN calendars c ON c.id = ce.calendar_id WHERE ce.event_id = e.id${own}
61
+ AND ((ce.created_at >= ? AND NOT EXISTS (SELECT 1 FROM calendar_entries o JOIN calendars oc
62
+ ON oc.id = o.calendar_id WHERE o.event_id = e.id${older} AND o.created_at < ?))
63
+ OR ((e.status = 'cancelled' OR e.source_deleted_at IS NOT NULL) AND (ce.deleted_at >= ?
64
+ OR (ce.status = 'cancelled' AND EXISTS (SELECT 1 FROM calendar_entry_revisions r WHERE r.entry_id = ce.id
65
+ AND r.created_at >= ? AND r.status IS NOT 'cancelled'))))
66
+ OR (ce.deleted_at IS NULL AND ce.status <> 'cancelled' AND (SELECT r.starts_at IS NOT ce.starts_at
67
+ OR r.ends_at IS NOT ce.ends_at FROM calendar_entry_revisions r WHERE r.entry_id = ce.id AND r.created_at >= ?
68
+ ORDER BY r.created_at, r.id LIMIT 1) = 1)))`);
69
+ }
70
+ if (filter.overlapping) {
71
+ const busy = (event) => `${event}.deleted_at IS NULL AND ${event}.status <> 'cancelled' AND ${event}.all_day = 0
72
+ AND ${event}.starts_at IS NOT NULL AND ${event}.ends_at IS NOT NULL AND EXISTS (SELECT 1 FROM calendar_entries
73
+ bce JOIN calendars bc ON bc.id = bce.calendar_id WHERE bce.event_id = ${event}.id${account("bc")}
74
+ AND bce.deleted_at IS NULL AND bce.status <> 'cancelled' AND coalesce(bce.own_response, '') <> 'declined'
75
+ AND coalesce(bce.transparency, '') <> 'transparent')`;
76
+ const own = busy("e");
77
+ params.push(longest());
78
+ clauses.push(`${own} AND EXISTS (SELECT 1 FROM events e2 WHERE e2.id <> e.id AND e2.starts_at < e.ends_at
79
+ AND e2.ends_at > e.starts_at AND e2.starts_at >= e.starts_at - ? AND ${busy("e2")})`);
80
+ }
81
+ const limit = filter.limit ?? 100;
82
+ const offset = filter.offset ?? 0;
83
+ if (!Number.isInteger(limit) || limit < 1 || limit > 1000 || !Number.isInteger(offset) || offset < 0)
84
+ throw new CliError("validation_error", "limit takes 1–1000 and offset 0 or more");
85
+ return database
86
+ .prepare(`SELECT e.* FROM events e WHERE ${clauses.join(" AND ")} ORDER BY ${filter.newest ? "e.starts_at DESC, e.id DESC" : "e.starts_at, e.id"} LIMIT ? OFFSET ?`)
87
+ .all(...params, limit, offset)
88
+ .map(eventOf);
89
+ };
@@ -1,5 +1,6 @@
1
1
  import { CliError } from "@wirecat/cli-base";
2
- import { calendarText, entryMetadata, joinKeyOf, joinUrlWithoutQuery, } from "../../meetings/index.js";
2
+ import { calendarText, entryInWindow, entryMetadata, joinKeyOf, joinUrlWithoutQuery, } from "../../meetings/index.js";
3
+ import { listEvents } from "./calendar-events.js";
3
4
  import { bool, eventOf, fromJson, int, str, toJson } from "./events.js";
4
5
  import { driveDocumentOf, driveFileTarget, logFileLink } from "./files.js";
5
6
  import { identityOf } from "./identities.js";
@@ -51,6 +52,8 @@ const entryOf = (row) => ({
51
52
  etag: str(row.etag),
52
53
  seenAt: int(row.seen_at),
53
54
  deletedAt: int(row.deleted_at),
55
+ createdAt: Number(row.created_at),
56
+ transparency: str(row.transparency),
54
57
  });
55
58
  const participantOf = (row) => ({
56
59
  entryId: Number(row.entry_id),
@@ -79,6 +82,7 @@ const reviewOf = (row) => ({
79
82
  id: Number(row.id),
80
83
  linkableType: String(row.linkable_type),
81
84
  linkableId: Number(row.linkable_id),
85
+ calendarId: int(row.calendar_id),
82
86
  entityType: String(row.entity_type),
83
87
  candidates: fromJson(row.candidates),
84
88
  reason: String(row.reason),
@@ -94,6 +98,8 @@ const attachmentOf = (row) => ({
94
98
  title: str(fromJson(row.metadata)?.title),
95
99
  documentId: int(row.to_id),
96
100
  });
101
+ const REVIEWS = "SELECT r.*, CASE WHEN r.linkable_type = 'calendar_entry' THEN " +
102
+ "(SELECT calendar_id FROM calendar_entries WHERE id = r.linkable_id) END AS calendar_id FROM link_reviews r";
97
103
  const TRACKED = ["title", "starts_at", "ends_at", "location", "status", "recurrence", "recurrence_ends_at"];
98
104
  const flag = (value) => (value ? 1 : 0);
99
105
  const cursorKey = (calendarId) => `calendar:${calendarId}`;
@@ -448,6 +454,56 @@ export const eventStoreOver = (context) => {
448
454
  refreshEvent(Number(row.event_id), now);
449
455
  }
450
456
  };
457
+ const saveAll = (calendarId, entries, now) => {
458
+ calendarRow(calendarId);
459
+ // A series first, so an occurrence in the same page finds it.
460
+ const ordered = [...entries].sort((a, b) => Number(b.kind === "series") - Number(a.kind === "series"));
461
+ const ids = new Map();
462
+ const saved = ordered.map((input) => {
463
+ const entry = saveEntry(calendarId, input, now);
464
+ ids.set(input, entry.id);
465
+ return entry;
466
+ });
467
+ for (const entry of saved)
468
+ if (entry.kind === "series" && entry.status === "cancelled")
469
+ cancelOccurrences(entry.id, now);
470
+ const current = saved.map(({ id }) => entryOf(entryRow(id)));
471
+ for (const entry of current)
472
+ link(entry, now);
473
+ refreshAround(current.map(({ id }) => id), now);
474
+ return entries.map((input) => entryOf(entryRow(ids.get(input))));
475
+ };
476
+ const removeAll = (calendarId, externalIds, now) => {
477
+ calendarRow(calendarId);
478
+ const removed = database
479
+ .prepare(`SELECT * FROM calendar_entries WHERE calendar_id = ? AND external_id IN (SELECT value FROM json_each(?))
480
+ AND status <> 'cancelled' AND deleted_at IS NULL`)
481
+ .all(calendarId, JSON.stringify(externalIds));
482
+ for (const row of removed) {
483
+ cancel(row, now);
484
+ database.prepare("UPDATE calendar_entries SET seen_at = ? WHERE id = ?").run(now, Number(row.id));
485
+ if (row.kind === "series")
486
+ cancelOccurrences(Number(row.id), now);
487
+ }
488
+ refreshAround(removed.map((row) => Number(row.id)), now);
489
+ return removed.length;
490
+ };
491
+ const putCursor = (calendarId, value, now) => {
492
+ const accountId = Number(calendarRow(calendarId).account_id);
493
+ if (value === null)
494
+ database
495
+ .prepare("DELETE FROM sync_cursors WHERE account_id = ? AND key = ?")
496
+ .run(accountId, cursorKey(calendarId));
497
+ else
498
+ database
499
+ .prepare(`INSERT INTO sync_cursors (account_id, key, value, created_at, updated_at) VALUES (?, ?, ?, ?, ?)
500
+ ON CONFLICT (account_id, key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at`)
501
+ .run(accountId, cursorKey(calendarId), value, now, now);
502
+ };
503
+ const slotRows = (calendarId, series, window) => database
504
+ .prepare(`SELECT * FROM calendar_entries WHERE calendar_id = ? AND kind = 'occurrence' AND series_external_id = ?
505
+ AND original_starts_at >= ? AND original_starts_at < ? AND deleted_at IS NULL ORDER BY original_starts_at`)
506
+ .all(calendarId, series, window.from, window.to);
451
507
  return {
452
508
  saveCalendars: async (accountId, calendars, now) => write(database, () => {
453
509
  const upsert = database.prepare(`INSERT INTO calendars (account_id, external_id, title, timezone, access_role, primary_calendar, synced, seen_at,
@@ -468,115 +524,72 @@ export const eventStoreOver = (context) => {
468
524
  .prepare("UPDATE calendars SET synced = ?, updated_at = ? WHERE id = ?")
469
525
  .run(flag(synced), now, calendarId);
470
526
  }),
471
- saveEntries: async (calendarId, entries, now) => write(database, () => {
472
- calendarRow(calendarId);
473
- // A series first, so an occurrence in the same page finds it.
474
- const ordered = [...entries].sort((a, b) => Number(b.kind === "series") - Number(a.kind === "series"));
475
- const ids = new Map();
476
- const saved = ordered.map((input) => {
477
- const entry = saveEntry(calendarId, input, now);
478
- ids.set(input, entry.id);
479
- return entry;
480
- });
481
- for (const entry of saved)
482
- if (entry.kind === "series" && entry.status === "cancelled")
483
- cancelOccurrences(entry.id, now);
484
- const current = saved.map(({ id }) => entryOf(entryRow(id)));
485
- for (const entry of current)
486
- link(entry, now);
487
- refreshAround(current.map(({ id }) => id), now);
488
- return entries.map((input) => entryOf(entryRow(ids.get(input))));
489
- }),
490
- removeEntries: async (calendarId, externalIds, now) => write(database, () => {
491
- calendarRow(calendarId);
492
- const removed = database
493
- .prepare(`SELECT * FROM calendar_entries WHERE calendar_id = ? AND external_id IN (SELECT value FROM json_each(?))
494
- AND status <> 'cancelled' AND deleted_at IS NULL`)
495
- .all(calendarId, JSON.stringify(externalIds));
496
- for (const row of removed) {
497
- cancel(row, now);
498
- database.prepare("UPDATE calendar_entries SET seen_at = ? WHERE id = ?").run(now, Number(row.id));
499
- if (row.kind === "series")
500
- cancelOccurrences(Number(row.id), now);
501
- }
502
- refreshAround(removed.map((row) => Number(row.id)), now);
503
- return removed.length;
504
- }),
527
+ saveEntries: async (calendarId, entries, now) => write(database, () => saveAll(calendarId, entries, now)),
528
+ removeEntries: async (calendarId, externalIds, now) => write(database, () => removeAll(calendarId, externalIds, now)),
505
529
  markUnseen: async (calendarId, seenBefore, window, now) => write(database, () => {
506
530
  const marked = database
507
- .prepare(`UPDATE calendar_entries SET deleted_at = ?, updated_at = ? WHERE calendar_id = ? AND deleted_at IS NULL
508
- AND (seen_at IS NULL OR seen_at < ?)
509
- AND (kind <> 'occurrence' OR exception = 1 OR (original_starts_at >= ? AND original_starts_at < ?))
510
- RETURNING event_id`)
511
- .all(now, now, calendarId, seenBefore, window.from, window.to);
531
+ .prepare(`SELECT * FROM calendar_entries WHERE calendar_id = ? AND deleted_at IS NULL AND (seen_at IS NULL OR seen_at < ?)
532
+ AND min(coalesce(starts_at, original_starts_at), coalesce(original_starts_at, starts_at)) < ? AND (kind = 'series'
533
+ OR max(coalesce(ends_at, 0), coalesce(starts_at, 0) + 1, coalesce(original_starts_at, 0)) >= ?)`)
534
+ .all(calendarId, seenBefore, window.to, window.from)
535
+ .filter((row) => entryInWindow(entryOf(row), window));
536
+ const mark = database.prepare("UPDATE calendar_entries SET deleted_at = ?, updated_at = ? WHERE id = ?");
537
+ for (const row of marked)
538
+ mark.run(now, now, Number(row.id));
512
539
  for (const eventId of new Set(marked.map((row) => int(row.event_id))))
513
540
  if (eventId !== null)
514
541
  refreshEvent(eventId, now);
515
542
  return marked.length;
516
543
  }),
544
+ restoreUnseen: async (calendarId, windowAt, now) => write(database, () => {
545
+ const restored = database
546
+ .prepare("SELECT * FROM calendar_entries WHERE calendar_id = ? AND deleted_at IS NOT NULL")
547
+ .all(calendarId)
548
+ .map(entryOf)
549
+ .filter((entry) => !entryInWindow(entry, windowAt(entry.deletedAt)));
550
+ const restore = database.prepare("UPDATE calendar_entries SET deleted_at = NULL, updated_at = ? WHERE id = ?");
551
+ for (const { id } of restored)
552
+ restore.run(now, id);
553
+ refreshAround(restored.map(({ id }) => id), now);
554
+ return restored.length;
555
+ }),
517
556
  entry: async (calendarId, externalId) => read(database, () => {
518
557
  const row = database
519
558
  .prepare("SELECT * FROM calendar_entries WHERE calendar_id = ? AND external_id = ?")
520
559
  .get(calendarId, externalId);
521
560
  return row ? entryOf(row) : null;
522
561
  }),
562
+ slotsOf: async (calendarId, seriesExternalId, window) => read(database, () => slotRows(calendarId, seriesExternalId, window).map(entryOf)),
563
+ saveSlots: async (calendarId, groups, window, cursor, now) => write(database, () => {
564
+ let [saved, removed] = [0, 0];
565
+ const seen = database.prepare("UPDATE calendar_entries SET seen_at = ? WHERE id = ?");
566
+ for (const { series, slots } of groups) {
567
+ const held = slotRows(calendarId, series, window);
568
+ if (slots === null) {
569
+ for (const row of held)
570
+ seen.run(now, Number(row.id));
571
+ continue;
572
+ }
573
+ if (slots.length)
574
+ saved += saveAll(calendarId, slots, now).length;
575
+ const given = new Set(slots.map(({ externalId }) => externalId));
576
+ const stale = held
577
+ .filter((row) => Number(row.exception) !== 1 && row.status !== "cancelled" && !given.has(String(row.external_id)))
578
+ .map((row) => String(row.external_id));
579
+ if (stale.length)
580
+ removed += removeAll(calendarId, stale, now);
581
+ }
582
+ putCursor(calendarId, cursor, now);
583
+ return { saved, removed };
584
+ }),
523
585
  cursor: async (calendarId) => read(database, () => {
524
586
  const { account_id } = calendarRow(calendarId);
525
587
  return str(database
526
588
  .prepare("SELECT value FROM sync_cursors WHERE account_id = ? AND key = ?")
527
589
  .get(Number(account_id), cursorKey(calendarId))?.value);
528
590
  }),
529
- setCursor: async (calendarId, value, now) => write(database, () => {
530
- const accountId = Number(calendarRow(calendarId).account_id);
531
- if (value === null)
532
- database
533
- .prepare("DELETE FROM sync_cursors WHERE account_id = ? AND key = ?")
534
- .run(accountId, cursorKey(calendarId));
535
- else
536
- database
537
- .prepare(`INSERT INTO sync_cursors (account_id, key, value, created_at, updated_at) VALUES (?, ?, ?, ?, ?)
538
- ON CONFLICT (account_id, key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at`)
539
- .run(accountId, cursorKey(calendarId), value, now, now);
540
- }),
541
- events: async (filter = {}) => {
542
- const clauses = ["e.deleted_at IS NULL"];
543
- const params = [];
544
- const entries = (extra) => `EXISTS (SELECT 1 FROM calendar_entries ce JOIN calendars c ON c.id = ce.calendar_id WHERE ce.event_id = e.id AND ${extra})`;
545
- const dated = (column, date, instant, at) => {
546
- if (at === undefined)
547
- return;
548
- if (date === undefined) {
549
- clauses.push(instant);
550
- params.push(at);
551
- return;
552
- }
553
- clauses.push(`(CASE WHEN e.all_day = 1 THEN EXISTS (SELECT 1 FROM calendar_entries ce WHERE ce.event_id = e.id AND ce.${column}) ELSE ${instant} END)`);
554
- params.push(date, at);
555
- };
556
- dated("end_date > ?", filter.fromDate, "coalesce(e.ends_at, e.starts_at + 1) > ?", filter.from);
557
- dated("start_date < ?", filter.toDate, "e.starts_at < ?", filter.to);
558
- if (filter.calendarId !== undefined) {
559
- clauses.push(entries("ce.calendar_id = ?"));
560
- params.push(filter.calendarId);
561
- }
562
- if (filter.accountId !== undefined) {
563
- clauses.push(entries("c.account_id = ?"));
564
- params.push(filter.accountId);
565
- }
566
- if (filter.personId !== undefined) {
567
- clauses.push(entries("EXISTS (SELECT 1 FROM calendar_entry_participants p JOIN identity_links il ON il.identity_id = p.identity_id " +
568
- "WHERE p.entry_id = ce.id AND il.person_id = ?)"));
569
- params.push(filter.personId);
570
- }
571
- const limit = filter.limit ?? 100;
572
- const offset = filter.offset ?? 0;
573
- if (!Number.isInteger(limit) || limit < 1 || limit > 1000 || !Number.isInteger(offset) || offset < 0)
574
- throw new CliError("validation_error", "limit takes 1–1000 and offset 0 or more");
575
- return database
576
- .prepare(`SELECT e.* FROM events e WHERE ${clauses.join(" AND ")} ORDER BY ${filter.newest ? "e.starts_at DESC, e.id DESC" : "e.starts_at, e.id"} LIMIT ? OFFSET ?`)
577
- .all(...params, limit, offset)
578
- .map(eventOf);
579
- },
591
+ setCursor: async (calendarId, value, now) => write(database, () => putCursor(calendarId, value, now)),
592
+ events: async (filter = {}) => listEvents(database, filter),
580
593
  event: async (id) => read(database, () => {
581
594
  const row = database.prepare("SELECT * FROM events WHERE id = ?").get(id);
582
595
  if (!row)
@@ -605,6 +618,25 @@ export const eventStoreOver = (context) => {
605
618
  .map(attachmentOf),
606
619
  };
607
620
  }),
621
+ historyOf: async (eventIds, calendarIds, since) => read(database, () => {
622
+ const entries = database
623
+ .prepare(`SELECT * FROM calendar_entries WHERE event_id IN (SELECT value FROM json_each(?))
624
+ AND calendar_id IN (SELECT value FROM json_each(?)) ORDER BY id`)
625
+ .all(JSON.stringify(eventIds), JSON.stringify(calendarIds))
626
+ .map(entryOf);
627
+ const revisions = database
628
+ .prepare(`SELECT * FROM calendar_entry_revisions WHERE entry_id IN (SELECT value FROM json_each(?)) AND created_at >= ?
629
+ ORDER BY created_at, id`)
630
+ .all(JSON.stringify(entries.map(({ id }) => id)), since)
631
+ .map(revisionOf);
632
+ return { entries, revisions };
633
+ }),
634
+ participantsOf: async (eventIds, calendarIds) => read(database, () => database
635
+ .prepare(`SELECT p.*, ce.event_id FROM calendar_entry_participants p JOIN calendar_entries ce ON ce.id = p.entry_id
636
+ WHERE ce.event_id IN (SELECT value FROM json_each(?)) AND ce.calendar_id IN (SELECT value FROM json_each(?))
637
+ ORDER BY ce.event_id, p.entry_id, p.email`)
638
+ .all(JSON.stringify(eventIds), JSON.stringify(calendarIds))
639
+ .map((row) => ({ ...participantOf(row), eventId: Number(row.event_id) }))),
608
640
  linkEntry: async (entryId, eventId, now) => write(database, () => {
609
641
  const row = database.prepare("SELECT * FROM calendar_entries WHERE id = ?").get(entryId);
610
642
  if (!row)
@@ -636,11 +668,11 @@ export const eventStoreOver = (context) => {
636
668
  });
637
669
  }),
638
670
  reviews: async (filter = {}) => database
639
- .prepare(`SELECT * FROM link_reviews${filter.status ? " WHERE status = ?" : ""} ORDER BY created_at, id`)
671
+ .prepare(`${REVIEWS}${filter.status ? " WHERE r.status = ?" : ""} ORDER BY r.created_at, r.id`)
640
672
  .all(...(filter.status ? [filter.status] : []))
641
673
  .map(reviewOf),
642
674
  resolveReview: async (id, decision, now) => write(database, () => {
643
- const row = database.prepare("SELECT * FROM link_reviews WHERE id = ?").get(id);
675
+ const row = database.prepare(`${REVIEWS} WHERE r.id = ?`).get(id);
644
676
  if (!row)
645
677
  throw new CliError("not_found", `no review ${id} in the local store`);
646
678
  const review = reviewOf(row);
@@ -710,7 +742,7 @@ export const eventStoreOver = (context) => {
710
742
  database
711
743
  .prepare("UPDATE link_reviews SET status = ?, chosen_id = ?, decided_by = 'owner', decided_at = ?, updated_at = ? WHERE id = ?")
712
744
  .run(chosen === null ? "apart" : "linked", chosen, now, now, id);
713
- return reviewOf(database.prepare("SELECT * FROM link_reviews WHERE id = ?").get(id));
745
+ return reviewOf(database.prepare(`${REVIEWS} WHERE r.id = ?`).get(id));
714
746
  }),
715
747
  };
716
748
  };
@@ -28,7 +28,7 @@ export declare const people: ({ orm, database }: StoreContext, provider: Provide
28
28
  export interface ContactFilter {
29
29
  /** Three letters or more of the name, the @username or the local alias. */
30
30
  query?: string;
31
- /** Text in one of the owner's private notes about them; case folds for Latin letters only, as SQLite's lower(). */
31
+ /** Three characters or more of one of the owner's private notes about them, anywhere, in any letter case. */
32
32
  notes?: string;
33
33
  }
34
34
  export declare const contacts: ({ orm }: StoreContext, accountKey: number, key: AccountKey, { order, limit, offset, ...filter }: {
@@ -2,7 +2,9 @@ import { CliError } from "@wirecat/cli-base";
2
2
  import { fold } from "../normalize.js";
3
3
  import { and, asc, desc, eq, inArray, isNotNull, isNull, ne, sql } from "./drizzle/core.js";
4
4
  import { folded } from "./folded.js";
5
+ import { holdingText } from "./note-index.js";
5
6
  import { resolvePersonLinks } from "./person-resolution.js";
7
+ import { bound } from "./query-filter.js";
6
8
  import { accountIdentities, accounts, aliases, chatMembers, chats, identities, identityLinkEvents, identityLinks, identityRevisions, messages, persons, } from "./schema.js";
7
9
  import { toIso } from "./values.js";
8
10
  const flag = (value) => (value === undefined || value === null ? null : Number(value));
@@ -209,13 +211,14 @@ const contactsWhere = (accountKey, key, { query, notes }) => {
209
211
  if (query !== undefined && query.trim().length < 3) {
210
212
  throw new CliError("validation_error", `a contact search takes at least 3 characters, got "${query}"`);
211
213
  }
214
+ const noted = notes === undefined ? undefined : holdingText("note", "n.id", notes);
212
215
  return and(eq(accountIdentities.accountId, accountKey), ne(identities.externalId, key.account), sql `EXISTS (SELECT 1 FROM ${chatMembers} JOIN ${chats} ON ${chats.id} = ${chatMembers.chatId}
213
216
  WHERE ${chatMembers.identityId} = ${identities.id} AND ${chats.accountId} = ${accountIdentities.accountId}
214
217
  AND ${chats.kind} = 'dialog')`, query === undefined
215
218
  ? undefined
216
219
  : sql `(${identities.id} IN (SELECT rowid FROM identities_fts WHERE identities_fts MATCH ${`"${query.trim().replaceAll('"', '""')}"`}) OR EXISTS (SELECT 1 FROM aliases ca WHERE ca.aliasable_type='identity' AND ca.display=1 AND ca.account_id=${accountKey} AND ca.aliasable_id=${identities.id} AND instr(ca.name_folded, ${fold(query.trim())}) > 0))`, notes === undefined
217
220
  ? undefined
218
- : sql `EXISTS (SELECT 1 FROM notes n WHERE n.notable_type='identity' AND n.notable_id=${identities.id} AND n.deleted_at IS NULL AND instr(lower(n.body), lower(${notes})) > 0)`);
221
+ : sql `EXISTS (SELECT 1 FROM notes n WHERE n.notable_type='identity' AND n.notable_id=${identities.id} AND n.deleted_at IS NULL${noted ? sql ` AND ${bound(noted)}` : sql ``})`);
219
222
  };
220
223
  export const contacts = ({ orm }, accountKey, key, { order, limit, offset = 0, ...filter }) => {
221
224
  const byName = [
@@ -10,6 +10,7 @@ import { ulid } from "../ulid.js";
10
10
  import { ownerPerson } from "./actors.js";
11
11
  import { linkKind, RELATION_KINDS } from "./link-kinds.js";
12
12
  import { meetingProjectsStoreOver } from "./meeting-projects.js";
13
+ import { holdingText } from "./note-index.js";
13
14
  import { insertLink, linkOf } from "./notes.js";
14
15
  import { queryFilter } from "./query-filter.js";
15
16
  import { addTags as addThingTags, canonicalTagNamed, createTag, labelsOf, liveTagging, removeTags as removeThingTags, setMainTopic, } from "./tags.js";
@@ -314,10 +315,11 @@ export const knowledgeStoreOver = (context) => {
314
315
  const thing = options.target ? resolve(key, options.target).thing : undefined;
315
316
  if (options.target && !thing)
316
317
  return { items: [], hasMore: false };
318
+ const text = options.search === undefined ? undefined : holdingText("note", "id", options.search);
317
319
  const rows = database
318
320
  .prepare("SELECT * FROM notes WHERE deleted_at IS NULL AND (? IS NULL OR (notable_type = ? AND notable_id = ?)) " +
319
- "AND (? IS NULL OR instr(lower(body), lower(?)) > 0) ORDER BY created_at DESC, id DESC LIMIT ? OFFSET ?")
320
- .all(thing?.type ?? null, thing?.type ?? null, thing?.id ?? null, options.search ?? null, options.search ?? null, limit + 1, offset);
321
+ `${text ? `AND ${text.sql} ` : ""}ORDER BY created_at DESC, id DESC LIMIT ? OFFSET ?`)
322
+ .all(thing?.type ?? null, thing?.type ?? null, thing?.id ?? null, ...(text?.params ?? []), limit + 1, offset);
321
323
  const items = rows.slice(0, limit).flatMap((row) => annotationOf(row) ?? []);
322
324
  return { items, hasMore: rows.length > limit };
323
325
  },