@wirecat/cli-core 0.526.0 → 0.528.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -7,6 +7,41 @@ shape of this file.
7
7
 
8
8
  ## Unreleased
9
9
 
10
+ ## 0.528.0 — 01.11.2026
11
+
12
+ ### Added
13
+
14
+ - **Every profile keeps its messenger library's warnings, and `doctor` counts the last day's.** A message the
15
+ library could not decrypt used to leave no trace unless the CLI's own log variable was set. `providerLogPathFor(app,
16
+ profile, env)` names `<state>/provider-log/<profile>.jsonl`; `openProviderLog(path)` makes it private (mode 600)
17
+ and keeps it under 1 MB with one older file, so an engine in another process can append to the path it is given;
18
+ `writeProviderWarning(path, kind, message)` appends from Node. A line is `{"time","level","kind"?,"message"}`, the
19
+ shape zerolog writes; `kind` is the adapter's tag. The adapter masks phone numbers, ids of people and text before
20
+ writing. `doctor` answers `provider_log: { path, last_day: { total, kinds } }` and checks the file is private.
21
+
22
+ ## 0.527.0 — 01.11.2026
23
+
24
+ ### Added
25
+
26
+ - **`events list --source <accounts>`: events of several calendar accounts as one list.** The shared calendar
27
+ `events list` takes `--source` as `search messages` does (`all`, a provider or its alias, `provider:account`,
28
+ `group:<name>`; WC-ACCT-8) and reads the accounts in one store query (`EventFilter.accountIds`), so a meeting two
29
+ accounts hold is one item and pages stay exact. Every answer names the accounts it read in `meta.accounts`
30
+ (WC-ACCT-9); `meta.complete` is false, with an issue per account, when one has no synced calendar
31
+ (`calendar.unsynced`) or a sync that never finished or is mid-round (`calendar.sync-incomplete`); the command
32
+ declares `partial`, so such an answer exits 0. `CalendarCommandDeps.locked` keeps a process under a profile lock to
33
+ its own account (`profile.locked`).
34
+ - **`overlapping:true` in `events list`: meetings that overlap.** A field of the event entity (`overlapping`, with
35
+ `overlaps` listing the others), filtered in the store with a self-join. Timed events only, not cancelled, not
36
+ declined, not free and not a working-location block; back-to-back events do not overlap. `overlapping:false` keeps
37
+ the rest. Across `--source` it finds a work meeting that clashes with a personal one.
38
+
39
+ ### Changed — may break callers
40
+
41
+ - `EventStoreFixture` (the event store contract) takes `otherAccountId`, a second calendar account.
42
+ - `EventFilter.overlapping: false` now keeps the events that do not overlap; before, it filtered nothing.
43
+ - `EventFilter.overlapping` also leaves out working-location blocks.
44
+
10
45
  ## 0.526.0 — 01.11.2026
11
46
 
12
47
  ### Added
@@ -113,6 +113,7 @@ export declare const calendarEvent: v.StrictObjectSchema<{
113
113
  readonly starts_at: v.NullableSchema<v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.IsoTimestampAction<string, "an ISO 8601 time in UTC">, v.EndsWithAction<string, "Z", "an ISO 8601 time in UTC, ending in `Z`">]>, undefined>;
114
114
  readonly ends_at: v.NullableSchema<v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.IsoTimestampAction<string, "an ISO 8601 time in UTC">, v.EndsWithAction<string, "Z", "an ISO 8601 time in UTC, ending in `Z`">]>, undefined>;
115
115
  }, undefined>, undefined>;
116
+ readonly overlapping: v.OptionalSchema<v.BooleanSchema<undefined>, undefined>;
116
117
  readonly overlaps: v.OptionalSchema<v.ArraySchema<v.StringSchema<undefined>, undefined>, undefined>;
117
118
  readonly ref: v.StringSchema<undefined>;
118
119
  readonly entries: v.ArraySchema<v.StrictObjectSchema<{
@@ -39,5 +39,7 @@ export interface CalendarCommandDeps {
39
39
  timezone?: string;
40
40
  /** The provider's writer and the profile's guard: `events create|update|delete|respond` exist only with it. */
41
41
  writes?: CalendarWrites;
42
+ /** A profile lock pins the process (WC-ACCT-5): `events list --source` may then read only the profile's account. */
43
+ locked?: boolean;
42
44
  }
43
45
  export declare const addCalendarCommands: (program: Command, deps: CalendarCommandDeps) => void;
@@ -10,7 +10,7 @@ import { lineOf } from "../../cli/steps.js";
10
10
  import { searchEvents } from "../../services/events-search.js";
11
11
  import { linkPeople } from "../../services/people.js";
12
12
  import { BUSY_QUERY, BUSY_QUERY_NOTE, busyAnswer, busyQuery, workingHoursOf } from "../busy.js";
13
- import { CALENDAR_UNTRUSTED_FIELDS, calendarIn, calendarItem, EVENTS_QUERY, EVENTS_QUERY_NOTE, eventAnswer, eventIdOf, eventOf, eventsAnswer, eventsFilter, peopleAnswer, resolveAnswer, reviewsAnswer, weekFilter, } from "../reads.js";
13
+ import { accountsMeta, CALENDAR_UNTRUSTED_FIELDS, calendarIn, calendarItem, EVENTS_QUERY, EVENTS_QUERY_NOTE, eventAnswer, eventIdOf, eventOf, eventsAnswer, eventsFilter, peopleAnswer, resolveAnswer, reviewsAnswer, sourceAccounts, weekFilter, } from "../reads.js";
14
14
  import { REPEAT_HELP } from "../recurrence.js";
15
15
  import { bothSearchAnswer, serverSearchAnswer } from "../server-search.js";
16
16
  import { calendarStatus, syncCalendars } from "../sync.js";
@@ -201,18 +201,30 @@ const addCalendarCommandsDefinition = (program, deps) => {
201
201
  }), command);
202
202
  });
203
203
  const events = annotate(program.command("events").description("the account's stored events"), { effects: GROUP });
204
- example(withQuery(annotate(events.command("list"), { effects: LOCAL_READ }).description("list stored events, earliest first"), EVENTS_QUERY).addHelpText("after", EVENTS_QUERY_NOTE), "events list starts_at>=2026-11-02 starts_at<2026-11-07 --json", "events list ends_at>=today person:ana@example.com", "events list starts_at>=2026-11-02 --limit 50 --page 2")
204
+ example(withQuery(annotate(events.command("list"), { effects: LOCAL_READ, partial: true }).description("list stored events, earliest first; --source reads several accounts as one list"), EVENTS_QUERY).addHelpText("after", EVENTS_QUERY_NOTE), "events list starts_at>=2026-11-02 starts_at<2026-11-07 --json", "events list ends_at>=today person:ana@example.com", "events list overlapping:true --source all")
205
205
  .option("--limit <n>", "events per page, 1–1000", integer, 100)
206
206
  .option("--page <n>", "which page, starting at 1", integer, 1)
207
+ .option("--source <accounts>", "calendar accounts held in the store to read as one list: all, a provider, or a list such as " +
208
+ `${program.name()}:work,${program.name()}:personal or group:<name>; the profile's account when absent`)
207
209
  .action(async (words, opts, command) => {
210
+ const own = { id: deps.accountId, key: deps.account };
211
+ const { accounts, skipped } = opts.source === undefined
212
+ ? { accounts: [own], skipped: [] }
213
+ : await sourceAccounts(messages("--source"), deps.account, opts.source, program.name());
214
+ if (deps.locked && accounts.some(({ id }) => id !== own.id))
215
+ throw new CliError("permission_error", "this process is locked to one profile; --source may name only its own account", { reason: "profile.locked" });
208
216
  const filter = await eventsFilter(words, {
209
217
  store: deps.store,
210
218
  accountId: deps.accountId,
219
+ accountIds: accounts.map(({ id }) => id),
211
220
  zone: zone(),
212
221
  now: now(),
213
222
  personOf: personId,
214
223
  });
215
- print(eachMarked(await eventsAnswer(deps.store, filter && { ...filter, limit: opts.limit }, opts.page), EVENT_ITEM_UNTRUSTED), command);
224
+ print({
225
+ ...eachMarked(await eventsAnswer(deps.store, filter && { ...filter, limit: opts.limit }, opts.page), EVENT_ITEM_UNTRUSTED),
226
+ meta: await accountsMeta(deps.store, accounts, skipped, now()),
227
+ }, command);
216
228
  });
217
229
  example(annotate(events.command("show"), { effects: LOCAL_READ })
218
230
  .description("one event: its calendar copies, attendees with their person, Drive files and history")
@@ -30,7 +30,8 @@ export const calendarTools = [
30
30
  name: "events_list",
31
31
  description: "stored events, earliest first; `query` takes field terms joined by AND: starts_at (>=, <, or " +
32
32
  "[a TO b}), ends_at>=, changed_at>= (events new, moved or cancelled since, each with change and was), " +
33
- "calendar:<id>; a date in the zone given, UTC when absent; without starts_at or ends_at, from today on",
33
+ "overlapping:true (timed events that clash, each with overlaps), calendar:<id>; a date in the zone given, " +
34
+ "UTC when absent; without starts_at or ends_at, from today on",
34
35
  properties: {
35
36
  account_id: integer,
36
37
  query: text,
@@ -1,6 +1,8 @@
1
1
  import { type Predicate, type QueryRegistry } from "@wirecat/cli-base/query";
2
+ import type { Issue, Meta } from "@wirecat/cli-base/standards";
2
3
  import { type QueryWords } from "../cli/list-query.js";
3
4
  import { type EventDetails, type EventFilter, type EventStore, type StoredCalendar, type StoredEntry } from "../meetings/index.js";
5
+ import type { AccountKey, MessageStore } from "../store/store.js";
4
6
  /** Who looks a person up: the shared store's `people.personOf`, for an attendee's `email` contact. */
5
7
  export type PersonOfEmail = (email: string) => Promise<string | null>;
6
8
  /** Fields whose text whoever sent the invitation wrote: an agent reads them as data. */
@@ -28,12 +30,14 @@ export declare const timeWindow: (node: Predicate, zone: string, now: number) =>
28
30
  upper?: number;
29
31
  };
30
32
  /**
31
- * `events list`'s query into a store filter, for one account. A person named by address is found through the
32
- * shared store; one nobody knows matches no event.
33
+ * `events list`'s query into a store filter, for one account or, with `accountIds`, several. A person named by
34
+ * address is found through the shared store; one nobody knows matches no event.
33
35
  */
34
36
  export declare const eventsFilter: (query: QueryWords, context: {
35
37
  store: EventStore;
36
38
  accountId: number;
39
+ /** These accounts in place of `accountId`. */
40
+ accountIds?: readonly number[];
37
41
  zone: string;
38
42
  now: number;
39
43
  personOf?: (reference: string) => Promise<number | null>;
@@ -80,6 +84,7 @@ export declare const eventAnswer: (details: EventDetails, personOf?: PersonOfEma
80
84
  starts_at: string | null;
81
85
  ends_at: string | null;
82
86
  } | undefined;
87
+ overlapping?: boolean | undefined;
83
88
  overlaps?: string[] | undefined;
84
89
  ref: string;
85
90
  entries: {
@@ -209,7 +214,27 @@ export declare const eventsAnswer: (store: EventStore, filter: EventFilter | nul
209
214
  starts_at: string | null;
210
215
  ends_at: string | null;
211
216
  } | undefined;
217
+ overlapping?: boolean | undefined;
212
218
  overlaps?: string[] | undefined;
213
219
  }[];
214
220
  page: number;
215
221
  }>;
222
+ /** A calendar account a list reads: the store's id, and its name as `meta.accounts` gives it. */
223
+ export interface ReadAccount {
224
+ id: number;
225
+ key: AccountKey;
226
+ }
227
+ /**
228
+ * The calendar accounts `--source` names, as `search messages --source` reads them (WC-ACCT-8): `all`, a provider,
229
+ * `provider:account` with an account or a profile, or `group:<name>`. An entry that names no calendar account is in
230
+ * `skipped`; nothing at all is an error.
231
+ */
232
+ export declare const sourceAccounts: (messages: MessageStore, running: AccountKey, source: string, command: string) => Promise<{
233
+ accounts: ReadAccount[];
234
+ skipped: Issue[];
235
+ }>;
236
+ /**
237
+ * `meta` of a list over calendar accounts (WC-ACCT-9): which it read, and `complete: false` when one has no synced
238
+ * calendar, or a calendar whose sync never finished or is in the middle of a round, so its events may be missing.
239
+ */
240
+ export declare const accountsMeta: (store: EventStore, accounts: readonly ReadAccount[], skipped: readonly Issue[], now: number) => Promise<Pick<Meta, "accounts" | "complete" | "issues">>;
@@ -2,6 +2,9 @@ import { CliError } from "@wirecat/cli-base";
2
2
  import { dateRange, dayBoundary, queryError, queryFieldsOf, } from "@wirecat/cli-base/query";
3
3
  import { LIST_QUERY_GUIDE, listQuery, nativeTerms, typedQuery } from "../cli/list-query.js";
4
4
  import { event, eventChange, eventItem, listed, } from "../meetings/index.js";
5
+ import { entriesOfList, fitting } from "../search/account-list.js";
6
+ import { listContextOf } from "../services/search-kind.js";
7
+ import { calendarStatus } from "./sync.js";
5
8
  const iso = (at) => (at === null ? null : new Date(at).toISOString());
6
9
  /** Fields whose text whoever sent the invitation wrote: an agent reads them as data. */
7
10
  export const CALENDAR_UNTRUSTED_FIELDS = [
@@ -27,6 +30,22 @@ export const calendarItem = (one) => ({
27
30
  synced: one.synced,
28
31
  seen_at: iso(one.seenAt),
29
32
  });
33
+ /** A stored calendar by its id or the provider's id, among the accounts' calendars; one the id names twice is refused. */
34
+ const calendarAmong = async (store, accountIds, reference) => {
35
+ if (accountIds.length === 1)
36
+ return calendarIn(store, accountIds[0], reference);
37
+ const calendars = (await Promise.all(accountIds.map((id) => store.calendars(id)))).flat();
38
+ const byId = calendars.find((one) => String(one.id) === reference);
39
+ if (byId)
40
+ return byId;
41
+ const byExternal = calendars.filter((one) => one.externalId === reference);
42
+ if (byExternal.length > 1)
43
+ throw new CliError("validation_error", `calendar ${reference} is in more than one of these accounts; name it by its id: ` +
44
+ byExternal.map(({ id }) => id).join(", "));
45
+ if (!byExternal[0])
46
+ throw new CliError("not_found", `no calendar ${reference} in these accounts`);
47
+ return byExternal[0];
48
+ };
30
49
  /** A stored calendar by its id or the provider's id, among the account's calendars. */
31
50
  export const calendarIn = async (store, accountId, reference) => {
32
51
  const calendars = await store.calendars(accountId);
@@ -59,7 +78,7 @@ export const EVENTS_QUERY = {
59
78
  entity: "event",
60
79
  backend: "native",
61
80
  fields: queryFieldsOf(event, {
62
- native: ["starts_at", "ends_at", "changed_at", "calendar", "person"],
81
+ native: ["starts_at", "ends_at", "changed_at", "overlapping", "calendar", "person"],
63
82
  relations: {
64
83
  calendar: { type: "kw", example: "calendar:3", relation: "one calendar, by its id or the provider's id" },
65
84
  person: {
@@ -72,17 +91,27 @@ export const EVENTS_QUERY = {
72
91
  starts_at: "starts_at:[2026-11-02 TO 2026-11-07}",
73
92
  ends_at: "ends_at>=2026-11-02",
74
93
  changed_at: "changed_at>=2026-10-01",
94
+ overlapping: "overlapping:true",
75
95
  },
76
96
  validate: {
77
97
  ends_at: oneSided("ends_at", "ends_at>=2026-11-02; for the latest, write starts_at<2026-11-07"),
78
98
  changed_at: oneSided("changed_at", "changed_at>=2026-10-01"),
79
99
  },
80
100
  }).map((field) => field.native
81
- ? { ...field, operators: field.type === "time" ? ["term", "range"] : ["term", "phrase"] }
101
+ ? {
102
+ ...field,
103
+ operators: field.type === "time"
104
+ ? ["term", "range"]
105
+ : field.type === "bool"
106
+ ? ["term"]
107
+ : ["term", "phrase"],
108
+ }
82
109
  : field),
83
110
  };
84
111
  export const EVENTS_QUERY_NOTE = "\nends_at keeps events that end after its moment, so one that ends exactly then is left out. Without starts_at " +
85
- "or ends_at: events that end after the start of today, in your time zone.";
112
+ "or ends_at: events that end after the start of today, in your time zone.\noverlapping:true keeps timed events " +
113
+ "that overlap another in the accounts read, each with overlaps: the others. Left out: all-day, cancelled, " +
114
+ "declined and free events and working-location blocks; back-to-back events do not overlap.";
86
115
  /** A time term as epoch ms: from `lower` on, and before `upper`. */
87
116
  export const timeWindow = (node, zone, now) => {
88
117
  const range = node.operator === "range"
@@ -94,11 +123,12 @@ export const timeWindow = (node, zone, now) => {
94
123
  };
95
124
  };
96
125
  /**
97
- * `events list`'s query into a store filter, for one account. A person named by address is found through the
98
- * shared store; one nobody knows matches no event.
126
+ * `events list`'s query into a store filter, for one account or, with `accountIds`, several. A person named by
127
+ * address is found through the shared store; one nobody knows matches no event.
99
128
  */
100
129
  export const eventsFilter = async (query, context) => {
101
- const filter = { accountId: context.accountId };
130
+ const accounts = context.accountIds ?? [context.accountId];
131
+ const filter = accounts.length === 1 ? { accountId: accounts[0] } : { accountIds: accounts };
102
132
  const { zone, now } = context;
103
133
  const span = (text) => ({ start: 0, end: text.length });
104
134
  const day = (date) => dayBoundary(date, zone, false, span(date));
@@ -122,8 +152,10 @@ export const eventsFilter = async (query, context) => {
122
152
  if (lower !== undefined)
123
153
  filter.changedSince = lower;
124
154
  }
155
+ else if (field === "overlapping")
156
+ filter.overlapping = node.value.toLowerCase() === "true";
125
157
  else if (field === "calendar")
126
- filter.calendarId = (await calendarIn(context.store, context.accountId, node.value)).id;
158
+ filter.calendarId = (await calendarAmong(context.store, accounts, node.value)).id;
127
159
  else if (field === "person") {
128
160
  if (!context.personOf)
129
161
  throw new CliError("configuration_error", "this tool cannot look people up");
@@ -172,6 +204,12 @@ const ownCopies = (details, calendars) => {
172
204
  };
173
205
  };
174
206
  const calendarIds = async (store, accountId) => new Set((await store.calendars(accountId)).map(({ id }) => id));
207
+ const accountsOf = (filter) => filter.accountIds ?? (filter.accountId === undefined ? [] : [filter.accountId]);
208
+ const scopeOf = (filter) => filter.accountIds !== undefined
209
+ ? { accountIds: filter.accountIds }
210
+ : filter.accountId === undefined
211
+ ? {}
212
+ : { accountId: filter.accountId };
175
213
  /** An event the account holds, with only its copies, or `not_found`: another account's event is not shown. */
176
214
  export const eventOf = async (store, accountId, id) => {
177
215
  const details = await store.event(id);
@@ -305,7 +343,7 @@ export const resolveAnswer = async (store, accountId, review, choice, now) => {
305
343
  };
306
344
  /** What changed about each listed event since the filter's instant, from its own copies. */
307
345
  const changesOf = async (store, rows, filter, since) => {
308
- const mine = filter.accountId === undefined ? [] : [...(await calendarIds(store, filter.accountId))];
346
+ const mine = (await Promise.all(accountsOf(filter).map(async (id) => [...(await calendarIds(store, id))]))).flat();
309
347
  const { entries, revisions } = await store.historyOf(rows.map(({ id }) => id), mine, since);
310
348
  return new Map(rows.map((event) => [
311
349
  event.id,
@@ -318,7 +356,7 @@ const overlapsOf = async (store, rows, filter) => {
318
356
  if (!timed.length)
319
357
  return new Map();
320
358
  const others = await store.events({
321
- ...(filter.accountId === undefined ? {} : { accountId: filter.accountId }),
359
+ ...scopeOf(filter),
322
360
  from: Math.min(...timed.map(({ startsAt }) => startsAt)),
323
361
  to: Math.max(...timed.map(({ endsAt }) => endsAt)),
324
362
  overlapping: true,
@@ -337,7 +375,7 @@ export const eventsAnswer = async (store, filter, number = 1) => {
337
375
  return listed([]);
338
376
  const { rows, limit, hasMore } = await page(store, { ...filter, offset: (number - 1) * (filter.limit ?? 100) });
339
377
  const changes = filter.changedSince === undefined ? null : await changesOf(store, rows, filter, filter.changedSince);
340
- const overlaps = filter.overlapping ? await overlapsOf(store, rows, filter) : null;
378
+ const overlaps = filter.overlapping === true ? await overlapsOf(store, rows, filter) : null;
341
379
  return listed(rows.map((event) => {
342
380
  const change = changes?.get(event.id);
343
381
  return {
@@ -349,7 +387,52 @@ export const eventsAnswer = async (store, filter, number = 1) => {
349
387
  ...(change.was ? { was: { starts_at: iso(change.was.startsAt), ends_at: iso(change.was.endsAt) } } : {}),
350
388
  }
351
389
  : {}),
390
+ ...(filter.overlapping === undefined ? {} : { overlapping: filter.overlapping }),
352
391
  ...(overlaps ? { overlaps: (overlaps.get(event.id) ?? []).map(({ id }) => `event:${id}`) } : {}),
353
392
  };
354
393
  }), { limit, hasMore, page: number });
355
394
  };
395
+ const nameOf = ({ provider, account }) => `${provider}:${account}`;
396
+ /**
397
+ * The calendar accounts `--source` names, as `search messages --source` reads them (WC-ACCT-8): `all`, a provider,
398
+ * `provider:account` with an account or a profile, or `group:<name>`. An entry that names no calendar account is in
399
+ * `skipped`; nothing at all is an error.
400
+ */
401
+ export const sourceAccounts = async (messages, running, source, command) => {
402
+ const context = await listContextOf(messages, running, undefined);
403
+ const { accounts, skipped } = fitting(entriesOfList(source, { ...context, running: { provider: running.provider, command } }), ["events"], `${command} events list`);
404
+ const stored = await messages.accounts.storedAccounts();
405
+ const read = [];
406
+ const issues = skipped.map(({ code, source }) => ({ code, source }));
407
+ for (const key of accounts) {
408
+ const held = stored.find((one) => one.provider === key.provider && one.account === key.account);
409
+ if (held)
410
+ read.push({ id: held.id, key });
411
+ else
412
+ issues.push({ code: "not_found", reason: "calendar.unsynced", source: nameOf(key) });
413
+ }
414
+ if (read.length === 0)
415
+ throw new CliError("not_found", `--source ${source} names no calendar account this store holds`, {
416
+ reason: "calendar.unsynced",
417
+ });
418
+ return { accounts: read, skipped: issues };
419
+ };
420
+ /**
421
+ * `meta` of a list over calendar accounts (WC-ACCT-9): which it read, and `complete: false` when one has no synced
422
+ * calendar, or a calendar whose sync never finished or is in the middle of a round, so its events may be missing.
423
+ */
424
+ export const accountsMeta = async (store, accounts, skipped, now) => {
425
+ const issues = [...skipped];
426
+ for (const { id, key } of accounts) {
427
+ const { items } = await calendarStatus(store, id, { profile: nameOf(key), now });
428
+ if (items.length === 0)
429
+ issues.push({ code: "not_found", reason: "calendar.unsynced", source: nameOf(key) });
430
+ else if (items.some(({ state }) => state !== "complete"))
431
+ issues.push({ code: "not_found", reason: "calendar.sync-incomplete", source: nameOf(key) });
432
+ }
433
+ return {
434
+ accounts: accounts.map(({ key }) => nameOf(key)),
435
+ complete: issues.length === 0,
436
+ ...(issues.length ? { issues } : {}),
437
+ };
438
+ };
@@ -453,8 +453,13 @@ export const memoryEventStore = () => {
453
453
  if (!Number.isInteger(limit) || limit < 1 || limit > 1000 || !Number.isInteger(offset) || offset < 0)
454
454
  throw new CliError("validation_error", "limit takes 1–1000 and offset 0 or more");
455
455
  const has = (event, check) => entries.some((one) => one.eventId === event.id && check(one));
456
- const copiesOf = (event) => entries.filter((one) => one.eventId === event.id &&
457
- (filter.accountId === undefined || calendarOf(one.calendarId).accountId === filter.accountId));
456
+ if (filter.accountIds !== undefined && filter.accountId !== undefined)
457
+ throw new CliError("validation_error", "an event filter takes accountId or accountIds, not both");
458
+ if (filter.accountIds?.length === 0)
459
+ throw new CliError("validation_error", "accountIds names no account");
460
+ const accounts = filter.accountIds ?? (filter.accountId === undefined ? undefined : [filter.accountId]);
461
+ const inAccounts = (one) => accounts === undefined || accounts.includes(calendarOf(one.calendarId).accountId);
462
+ const copiesOf = (event) => entries.filter((one) => one.eventId === event.id && inAccounts(one));
458
463
  const busy = (event) => event.deletedAt === null &&
459
464
  event.status !== "cancelled" &&
460
465
  !event.allDay &&
@@ -463,7 +468,13 @@ export const memoryEventStore = () => {
463
468
  copiesOf(event).some((one) => one.deletedAt === null &&
464
469
  one.status !== "cancelled" &&
465
470
  one.ownResponse !== "declined" &&
466
- one.transparency !== "transparent");
471
+ one.transparency !== "transparent" &&
472
+ one.eventType !== "working-location");
473
+ const clashes = (event) => busy(event) &&
474
+ [...events.values()].some((other) => other.id !== event.id &&
475
+ busy(other) &&
476
+ other.startsAt < event.endsAt &&
477
+ other.endsAt > event.startsAt);
467
478
  return [...events.values()]
468
479
  .filter((event) => event.deletedAt === null &&
469
480
  (filter.from === undefined ||
@@ -479,18 +490,12 @@ export const memoryEventStore = () => {
479
490
  ? has(event, (one) => one.startDate !== null && one.startDate >= (filter.startsFromDate ?? ""))
480
491
  : event.startsAt !== null && event.startsAt >= filter.startsFrom)) &&
481
492
  (filter.calendarId === undefined || has(event, (one) => one.calendarId === filter.calendarId)) &&
482
- (filter.accountId === undefined ||
483
- has(event, (one) => calendarOf(one.calendarId).accountId === filter.accountId)) &&
493
+ (accounts === undefined || has(event, inAccounts)) &&
484
494
  (filter.personId === undefined ||
485
495
  has(event, (one) => participants.some((p) => p.entryId === one.id && p.identityId === filter.personId))) &&
486
496
  (filter.changedSince === undefined ||
487
497
  eventChange(event, copiesOf(event), revisions, filter.changedSince) !== null) &&
488
- (!filter.overlapping ||
489
- (busy(event) &&
490
- [...events.values()].some((other) => other.id !== event.id &&
491
- busy(other) &&
492
- other.startsAt < event.endsAt &&
493
- other.endsAt > event.startsAt))))
498
+ (filter.overlapping === undefined || clashes(event) === filter.overlapping))
494
499
  .sort((a, b) => ((a.startsAt ?? -1) - (b.startsAt ?? -1) || a.id - b.id) * (filter.newest ? -1 : 1))
495
500
  .slice(offset, offset + limit)
496
501
  .map((event) => ({ ...event }));
@@ -23,6 +23,7 @@ export { NO_RULES, NO_TASKS, NOT_ALLOWED, ONLY_TASK, type Replied, type Replier,
23
23
  export { repliesStatePathFor } from "../replies/state.js";
24
24
  export { type CacheEvent, type DiagnosticEvent, type EventSink, providerErrorKey, type RequestEvent, renderEvent, type WarningEvent, } from "../runs/events.js";
25
25
  export { CORE_LOG_EVENTS } from "../runs/log-events.js";
26
+ export { openProviderLog, PROVIDER_LOG_BYTES, type ProviderWarnings, providerLogPathFor, providerWarnings, writeProviderWarning, } from "../runs/provider-log.js";
26
27
  export { crashOf, type Recording, type RecordingOptions, recorded, startRecording, wasSettled, } from "../runs/recording.js";
27
28
  export { findRun, KEEP_RUNS_FOR_DAYS, listRuns, pruneRuns, type RunContext, type RunMetadata, readEvents, runsDirFor, runtime, startRun, } from "../runs/run.js";
28
29
  export { runContextOf, secretInput } from "../runs/run-context.js";
package/dist/cli/index.js CHANGED
@@ -19,6 +19,7 @@ export { NO_RULES, NO_TASKS, NOT_ALLOWED, ONLY_TASK, replyTo } from "../replies/
19
19
  export { repliesStatePathFor } from "../replies/state.js";
20
20
  export { providerErrorKey, renderEvent, } from "../runs/events.js";
21
21
  export { CORE_LOG_EVENTS } from "../runs/log-events.js";
22
+ export { openProviderLog, PROVIDER_LOG_BYTES, providerLogPathFor, providerWarnings, writeProviderWarning, } from "../runs/provider-log.js";
22
23
  export { crashOf, recorded, startRecording, wasSettled, } from "../runs/recording.js";
23
24
  export { findRun, KEEP_RUNS_FOR_DAYS, listRuns, pruneRuns, readEvents, runsDirFor, runtime, startRun, } from "../runs/run.js";
24
25
  export { runContextOf, secretInput } from "../runs/run-context.js";
@@ -5,6 +5,7 @@ import { annotate } from "@wirecat/cli-base/commands";
5
5
  import { Command } from "commander";
6
6
  import { engineStates } from "../../attachments/extract.js";
7
7
  import { providerErrorKey } from "../../runs/events.js";
8
+ import { providerLogPathFor, providerWarnings } from "../../runs/provider-log.js";
8
9
  import { listRuns, runsDirFor, runtime } from "../../runs/run.js";
9
10
  import { FloodMemory, floodPathFor } from "../../sends/flood.js";
10
11
  import { SendJournal, sendsPathFor } from "../../sends/journal.js";
@@ -20,6 +21,7 @@ import { recalledAccount } from "./accounts.js";
20
21
  import { messengerContext } from "./context.js";
21
22
  import { privateFiles, withSqliteSidecars } from "./private-files.js";
22
23
  import { buildReport, reportFileName } from "./report.js";
24
+ import { DAY_MS } from "./serve-members.js";
23
25
  import { storeSummary } from "./store-maintenance-command.js";
24
26
  /**
25
27
  * The state this installation is in, read from disk and **never from the messenger unless
@@ -121,6 +123,7 @@ const rememberedOf = (report) => report.account?.remembered ?? undefined;
121
123
  * What `doctor` answers, as data. A configuration that will not load is a field, not an exception:
122
124
  * then there is no renderer from the settings, and the caller renders with the defaults.
123
125
  */
126
+ const providerLogState = ({ path, total, kinds }) => ({ path, last_day: { total, kinds } });
124
127
  const diagnose = async (command, messenger) => {
125
128
  const { app, provider } = messenger;
126
129
  const env = environmentOf(command).env ?? process.env;
@@ -183,8 +186,9 @@ const diagnose = async (command, messenger) => {
183
186
  const store = storePath(env);
184
187
  const sends = sendsPathFor(app, profile, env);
185
188
  const flood = floodPathFor(app, profile, env);
189
+ const providerLog = providerLogPathFor(app, profile, env);
186
190
  const files = privateFiles({
187
- files: [...own.files, ...withSqliteSidecars(store), sends, flood],
191
+ files: [...own.files, ...withSqliteSidecars(store), sends, flood, providerLog],
188
192
  // A folder the owner chose with MESSAGING_STORE may rightly be shared, like the home folder itself.
189
193
  dirs: [
190
194
  ...own.dirs,
@@ -192,6 +196,7 @@ const diagnose = async (command, messenger) => {
192
196
  dirname(sends),
193
197
  dirname(flood),
194
198
  runsDirFor(app, env),
199
+ dirname(providerLog),
195
200
  ],
196
201
  });
197
202
  return {
@@ -209,6 +214,7 @@ const diagnose = async (command, messenger) => {
209
214
  readers: engineStates(),
210
215
  sends: sendsState(new SendJournal(sendsPathFor(app, profile, env))),
211
216
  flood: floodState(new FloodMemory(flood)),
217
+ provider_log: providerLogState(providerWarnings(providerLog, new Date(Date.now() - DAY_MS))),
212
218
  runs: { directory: runsDirFor(app, env), kept: listRuns(runsDirFor(app, env)).length },
213
219
  // The messenger's own block keeps the keys it gives: some of them are file paths.
214
220
  files: filesReport(files),
@@ -6362,6 +6362,7 @@ export declare const ENTITIES: {
6362
6362
  readonly starts_at: v.NullableSchema<v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.IsoTimestampAction<string, "an ISO 8601 time in UTC">, v.EndsWithAction<string, "Z", "an ISO 8601 time in UTC, ending in `Z`">]>, undefined>;
6363
6363
  readonly ends_at: v.NullableSchema<v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.IsoTimestampAction<string, "an ISO 8601 time in UTC">, v.EndsWithAction<string, "Z", "an ISO 8601 time in UTC, ending in `Z`">]>, undefined>;
6364
6364
  }, undefined>, undefined>;
6365
+ readonly overlapping: v.OptionalSchema<v.BooleanSchema<undefined>, undefined>;
6365
6366
  readonly overlaps: v.OptionalSchema<v.ArraySchema<v.StringSchema<undefined>, undefined>, undefined>;
6366
6367
  }, undefined>;
6367
6368
  readonly meeting_review: v.StrictObjectSchema<{
@@ -63,6 +63,9 @@ const OTHERS = [
63
63
  validation("event.repeat-count", "a COUNT series is split only with a rule for its new part"),
64
64
  validation("event.unmovable", "the provider keeps this kind of event on its calendar"),
65
65
  ["calendar.read-only", "permission_error", "the calendar takes no new events from this account"],
66
+ ["calendar.unsynced", "not_found", "the calendar account has no synced calendar in the store"],
67
+ ["calendar.sync-incomplete", "not_found", "a synced calendar's sync never finished or is mid-round"],
68
+ ["profile.locked", "permission_error", "a profile lock pins this process to its own account"],
66
69
  ["busy.offline", "permission_error", "calendars busy asks the provider; --offline was given"],
67
70
  validation("busy.no-calendars", "no calendar syncs and no person was named, so there is nobody to ask"),
68
71
  ["search.offline", "permission_error", "search --backend server asks the provider; --offline was given"],
@@ -194,13 +194,16 @@ export interface EventFilter {
194
194
  /** Events `eventChange` finds new, moved or cancelled at or after this instant. */
195
195
  changedSince?: number;
196
196
  /**
197
- * Events whose time overlaps another's. Only timed events that take the owner's time count: not cancelled, and with
198
- * a live copy the owner did not decline or mark free.
197
+ * `true`: events whose time overlaps another's; `false`: every other event. Only timed events that take the owner's
198
+ * time count: not cancelled, and with a live copy, in the accounts read, that the owner did not decline or mark
199
+ * free and that is no working-location marker. Back-to-back events do not overlap.
199
200
  */
200
201
  overlapping?: boolean;
201
202
  /** All-day events starting before this local day (exclusive); `to` without it. */
202
203
  toDate?: string;
203
204
  accountId?: number;
205
+ /** Several accounts' events at once, in place of `accountId`. */
206
+ accountIds?: readonly number[];
204
207
  calendarId?: number;
205
208
  /** Events whose entries list one of this person's contacts. */
206
209
  personId?: number;
@@ -195,7 +195,12 @@ export declare const event: v.StrictObjectSchema<{
195
195
  readonly starts_at: v.NullableSchema<v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.IsoTimestampAction<string, "an ISO 8601 time in UTC">, v.EndsWithAction<string, "Z", "an ISO 8601 time in UTC, ending in `Z`">]>, undefined>;
196
196
  readonly ends_at: v.NullableSchema<v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.IsoTimestampAction<string, "an ISO 8601 time in UTC">, v.EndsWithAction<string, "Z", "an ISO 8601 time in UTC, ending in `Z`">]>, undefined>;
197
197
  }, undefined>, undefined>;
198
- /** On a calendar CLI's `events list overlapping`: the events it clashes with, as `event:<id>`. */
198
+ /**
199
+ * Whether its time overlaps another event's, in the accounts read: a timed event, not cancelled, not declined, not
200
+ * free and no working-location block. On a calendar CLI's `events list` when the query names it.
201
+ */
202
+ readonly overlapping: v.OptionalSchema<v.BooleanSchema<undefined>, undefined>;
203
+ /** With `overlapping:true`: the events it overlaps, as `event:<id>`. */
199
204
  readonly overlaps: v.OptionalSchema<v.ArraySchema<v.StringSchema<undefined>, undefined>, undefined>;
200
205
  }, undefined>;
201
206
  export declare const eventItem: (one: Event) => v.InferOutput<typeof event>;
@@ -207,7 +207,12 @@ export const event = v.strictObject({
207
207
  changed_at: v.optional(v.nullable(isoTime)),
208
208
  /** The times a moved event had at that date. */
209
209
  was: v.optional(v.strictObject({ starts_at: v.nullable(isoTime), ends_at: v.nullable(isoTime) })),
210
- /** On a calendar CLI's `events list overlapping`: the events it clashes with, as `event:<id>`. */
210
+ /**
211
+ * Whether its time overlaps another event's, in the accounts read: a timed event, not cancelled, not declined, not
212
+ * free and no working-location block. On a calendar CLI's `events list` when the query names it.
213
+ */
214
+ overlapping: v.optional(v.boolean()),
215
+ /** With `overlapping:true`: the events it overlaps, as `event:<id>`. */
211
216
  overlaps: v.optional(v.array(text)),
212
217
  });
213
218
  export const eventItem = (one) => ({
@@ -1,9 +1,10 @@
1
1
  import { type EventStore } from "../event-store.js";
2
2
  import type { ContractCase } from "../store.js";
3
- /** A fresh, empty store and one calendar account in it. */
3
+ /** A fresh, empty store and two calendar accounts in it. */
4
4
  export interface EventStoreFixture {
5
5
  store: EventStore;
6
6
  accountId: number;
7
+ otherAccountId: number;
7
8
  }
8
9
  /** "Now" in every case: entries before it have happened. */
9
10
  export declare const CONTRACT_NOW: number;
@@ -354,6 +354,34 @@ export const eventStoreContract = (make) => {
354
354
  const ids = new Set(found.map(({ id }) => id));
355
355
  for (const externalId of ["first", "second"])
356
356
  assert.ok(ids.has((await store.entry(calendar, externalId))?.eventId), externalId);
357
+ const all = await store.events({ from: at - HOUR, to: at + 8 * HOUR });
358
+ const rest = await store.events({ overlapping: false, from: at - HOUR, to: at + 8 * HOUR });
359
+ assert.deepEqual(rest.map(({ id }) => id), all.filter(({ id }) => !ids.has(id)).map(({ id }) => id));
360
+ }),
361
+ test("a working-location block overlaps nothing; a meeting in another account overlaps when both are read", async ({ store, calendar, ...fixture }) => {
362
+ const at = later;
363
+ const [other] = await store.saveCalendars(fixture.otherAccountId, [
364
+ {
365
+ externalId: "other-primary",
366
+ title: "Home",
367
+ timezone: "UTC",
368
+ accessRole: "owner",
369
+ primary: true,
370
+ syncByDefault: true,
371
+ },
372
+ ], 1);
373
+ await store.saveEntries(calendar, [single("office", at, { eventType: "working-location", endsAt: at + 8 * HOUR }), single("work", at + HOUR)], 2);
374
+ await store.saveEntries(other.id, [single("dentist", at + HOUR + HOUR / 2)], 2);
375
+ const window = { from: at - HOUR, to: at + 8 * HOUR };
376
+ assert.equal((await store.events({ ...window, overlapping: true, accountId: fixture.accountId })).length, 0);
377
+ const both = await store.events({
378
+ ...window,
379
+ overlapping: true,
380
+ accountIds: [fixture.accountId, fixture.otherAccountId],
381
+ });
382
+ assert.equal(both.length, 2);
383
+ assert.equal((await store.events({ ...window, accountIds: [fixture.accountId, fixture.otherAccountId] })).length, 3);
384
+ assert.equal((await store.events({ ...window, accountIds: [fixture.otherAccountId] })).length, 1);
357
385
  }),
358
386
  test("participants are kept by address; a room is listed and is no contact", async ({ store, calendar }) => {
359
387
  const [entry] = await store.saveEntries(calendar, [
@@ -0,0 +1,22 @@
1
+ import type { AppIdentity } from "../ports/app.js";
2
+ /**
3
+ * The messenger library's warnings for one profile, always kept: a message it failed to decrypt is lost without a
4
+ * trace otherwise. One JSON object per line, `{"time","level","kind"?,"message"}` — the shape zerolog writes, so an
5
+ * engine in another process appends to the file directly. **No phone number, id of a person or message text**: the
6
+ * adapter or its engine masks before writing. `kind` is the adapter's tag (`decrypt`), counted by `doctor`.
7
+ */
8
+ export declare const providerLogPathFor: (app: AppIdentity, profile: string, env?: NodeJS.ProcessEnv) => string;
9
+ export declare const PROVIDER_LOG_BYTES: number;
10
+ /**
11
+ * The file, private and under its cap, ready for an engine to append to. The cap is kept here rather than by the
12
+ * writer, because an engine in another language appends without knowing it: one older file is kept.
13
+ */
14
+ export declare const openProviderLog: (path: string, maxBytes?: number) => string;
15
+ export declare const writeProviderWarning: (path: string, kind: string, message: string, now?: Date) => void;
16
+ export interface ProviderWarnings {
17
+ path: string;
18
+ total: number;
19
+ kinds: Record<string, number>;
20
+ }
21
+ /** Warnings since `since`, by kind; a line without one counts as `other`, a line that is not JSON is skipped. */
22
+ export declare const providerWarnings: (path: string, since: Date) => ProviderWarnings;
@@ -0,0 +1,55 @@
1
+ import { appendFileSync, chmodSync, closeSync, existsSync, mkdirSync, openSync, readFileSync, renameSync, statSync, } from "node:fs";
2
+ import { dirname, join } from "node:path";
3
+ import { resolvePaths } from "@wirecat/cli-base";
4
+ /**
5
+ * The messenger library's warnings for one profile, always kept: a message it failed to decrypt is lost without a
6
+ * trace otherwise. One JSON object per line, `{"time","level","kind"?,"message"}` — the shape zerolog writes, so an
7
+ * engine in another process appends to the file directly. **No phone number, id of a person or message text**: the
8
+ * adapter or its engine masks before writing. `kind` is the adapter's tag (`decrypt`), counted by `doctor`.
9
+ */
10
+ export const providerLogPathFor = (app, profile, env = process.env) => join(resolvePaths({ appName: app.appName, prefix: app.envPrefix, env }).state, "provider-log", `${profile}.jsonl`);
11
+ export const PROVIDER_LOG_BYTES = 1024 * 1024;
12
+ const previousOf = (path) => path.replace(/\.jsonl$/, ".1.jsonl");
13
+ /**
14
+ * The file, private and under its cap, ready for an engine to append to. The cap is kept here rather than by the
15
+ * writer, because an engine in another language appends without knowing it: one older file is kept.
16
+ */
17
+ export const openProviderLog = (path, maxBytes = PROVIDER_LOG_BYTES) => {
18
+ mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
19
+ if (existsSync(path) && statSync(path).size >= maxBytes)
20
+ renameSync(path, previousOf(path));
21
+ closeSync(openSync(path, "a", 0o600));
22
+ chmodSync(path, 0o600);
23
+ return path;
24
+ };
25
+ export const writeProviderWarning = (path, kind, message, now = new Date()) => {
26
+ openProviderLog(path);
27
+ appendFileSync(path, `${JSON.stringify({ time: now.toISOString(), level: "warn", kind, message })}\n`);
28
+ };
29
+ /** Warnings since `since`, by kind; a line without one counts as `other`, a line that is not JSON is skipped. */
30
+ export const providerWarnings = (path, since) => {
31
+ const kinds = {};
32
+ let total = 0;
33
+ for (const file of [previousOf(path), path]) {
34
+ if (!existsSync(file))
35
+ continue;
36
+ for (const line of readFileSync(file, "utf8").split("\n")) {
37
+ if (!line)
38
+ continue;
39
+ let entry;
40
+ try {
41
+ entry = JSON.parse(line);
42
+ }
43
+ catch {
44
+ continue;
45
+ }
46
+ const at = typeof entry.time === "string" ? Date.parse(entry.time) : Number.NaN;
47
+ if (!(at >= since.getTime()))
48
+ continue;
49
+ const kind = typeof entry.kind === "string" && entry.kind ? entry.kind : "other";
50
+ kinds[kind] = (kinds[kind] ?? 0) + 1;
51
+ total++;
52
+ }
53
+ }
54
+ return { path, total, kinds };
55
+ };
@@ -1,6 +1,13 @@
1
1
  import { CliError } from "@wirecat/cli-base";
2
2
  import { eventOf } from "./events.js";
3
3
  const DAY = 86_400_000;
4
+ const accountsOf = (filter) => {
5
+ if (filter.accountIds !== undefined && filter.accountId !== undefined)
6
+ throw new CliError("validation_error", "an event filter takes accountId or accountIds, not both");
7
+ if (filter.accountIds?.length === 0)
8
+ throw new CliError("validation_error", "accountIds names no account");
9
+ return filter.accountIds ?? (filter.accountId === undefined ? undefined : [filter.accountId]);
10
+ };
4
11
  /** The events of a filter, earliest first (`EventStore.events`). */
5
12
  export const listEvents = (database, filter) => {
6
13
  const clauses = ["e.deleted_at IS NULL"];
@@ -40,21 +47,22 @@ export const listEvents = (database, filter) => {
40
47
  clauses.push(entries("ce.calendar_id = ?"));
41
48
  params.push(filter.calendarId);
42
49
  }
43
- if (filter.accountId !== undefined) {
44
- clauses.push(entries("c.account_id = ?"));
45
- params.push(filter.accountId);
46
- }
50
+ const accounts = accountsOf(filter);
51
+ const inAccounts = (column) => {
52
+ if (accounts === undefined)
53
+ return "";
54
+ for (const id of accounts)
55
+ params.push(id);
56
+ return `${column} IN (${accounts.map(() => "?").join(", ")})`;
57
+ };
58
+ if (accounts !== undefined)
59
+ clauses.push(entries(inAccounts("c.account_id")));
47
60
  if (filter.personId !== undefined) {
48
61
  clauses.push(entries("EXISTS (SELECT 1 FROM calendar_entry_participants p JOIN identity_links il ON il.identity_id = p.identity_id " +
49
62
  "WHERE p.entry_id = ce.id AND il.person_id = ?)"));
50
63
  params.push(filter.personId);
51
64
  }
52
- const account = (alias) => {
53
- if (filter.accountId === undefined)
54
- return "";
55
- params.push(filter.accountId);
56
- return ` AND ${alias}.account_id = ?`;
57
- };
65
+ const account = (alias) => (accounts === undefined ? "" : ` AND ${inAccounts(`${alias}.account_id`)}`);
58
66
  if (filter.changedSince !== undefined) {
59
67
  // The SQL form of eventChange: first stored since, cancelled since, or a live copy's time changed since.
60
68
  const since = filter.changedSince;
@@ -72,16 +80,18 @@ export const listEvents = (database, filter) => {
72
80
  OR r.ends_at IS NOT ce.ends_at FROM calendar_entry_revisions r WHERE r.entry_id = ce.id AND r.created_at >= ?
73
81
  ORDER BY r.created_at, r.id LIMIT 1) = 1)))`);
74
82
  }
75
- if (filter.overlapping) {
83
+ if (filter.overlapping !== undefined) {
76
84
  const busy = (event) => `${event}.deleted_at IS NULL AND ${event}.status <> 'cancelled' AND ${event}.all_day = 0
77
85
  AND ${event}.starts_at IS NOT NULL AND ${event}.ends_at IS NOT NULL AND EXISTS (SELECT 1 FROM calendar_entries
78
86
  bce JOIN calendars bc ON bc.id = bce.calendar_id WHERE bce.event_id = ${event}.id${account("bc")}
79
87
  AND bce.deleted_at IS NULL AND bce.status <> 'cancelled' AND coalesce(bce.own_response, '') <> 'declined'
80
- AND coalesce(bce.transparency, '') <> 'transparent')`;
88
+ AND coalesce(bce.transparency, '') <> 'transparent'
89
+ AND coalesce(bce.event_type, '') <> 'working-location')`;
81
90
  const own = busy("e");
82
91
  params.push(longest());
83
- clauses.push(`${own} AND EXISTS (SELECT 1 FROM events e2 WHERE e2.id <> e.id AND e2.starts_at < e.ends_at
84
- AND e2.ends_at > e.starts_at AND e2.starts_at >= e.starts_at - ? AND ${busy("e2")})`);
92
+ const clash = `${own} AND EXISTS (SELECT 1 FROM events e2 WHERE e2.id <> e.id AND e2.starts_at < e.ends_at
93
+ AND e2.ends_at > e.starts_at AND e2.starts_at >= e.starts_at - ? AND ${busy("e2")})`;
94
+ clauses.push(filter.overlapping ? clash : `NOT (${clash})`);
85
95
  }
86
96
  const limit = filter.limit ?? 100;
87
97
  const offset = filter.offset ?? 0;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wirecat/cli-core",
3
- "version": "0.526.0",
3
+ "version": "0.528.0",
4
4
  "description": "The messenger-neutral half of a messaging CLI: domain model, message locators, rendering, name resolution, the SQLite seam, the command skeleton and shared read commands, the send guard, run records and the message store",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Viacheslav Ptsarev",