@wirecat/cli-core 0.457.0 → 0.458.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.
@@ -0,0 +1,68 @@
1
+ import { CliError } from "@wirecat/cli-base";
2
+ import type { EntryInput, ParticipantResponse, StoredCalendar } from "../meetings/index.js";
3
+ import type { Level, PermissionKey } from "../sends/permissions.js";
4
+ /**
5
+ * A new event in neutral words: a timed one has `startsAt` and `endsAt` (epoch ms, end exclusive) in `timezone`; an
6
+ * all-day one has `startDate` and `endDate` (`YYYY-MM-DD`, end exclusive). Never repeating.
7
+ */
8
+ export interface EventDraft {
9
+ title: string;
10
+ startsAt?: number;
11
+ endsAt?: number;
12
+ startDate?: string;
13
+ endDate?: string;
14
+ timezone?: string | null;
15
+ location?: string | null;
16
+ description?: string | null;
17
+ attendees?: {
18
+ email: string;
19
+ optional?: boolean;
20
+ }[];
21
+ }
22
+ /** Only the fields to change; `null` clears one. `attendees` replaces the whole list. */
23
+ export type EventPatch = Partial<EventDraft>;
24
+ /** `this`: the one entry named; `series`: the repeating event it belongs to, named by the series' own id. */
25
+ export type WriteScope = "this" | "series";
26
+ export type EventResponse = Extract<ParticipantResponse, "accepted" | "tentative" | "declined">;
27
+ /**
28
+ * Writes to one provider's calendars. Each change carries the entry's `etag`, sent as the provider's precondition
29
+ * (Google and CalDAV `If-Match`); a mismatch throws `calendarConflict()`, never overwrites. `notify` says whether the
30
+ * provider mails the other attendees.
31
+ */
32
+ export interface CalendarWriter {
33
+ readonly provider: string;
34
+ /**
35
+ * `id` is chosen here and journaled before the call: a create with an id the provider already holds returns that
36
+ * event instead of making a second one, so a retry after `outcome_unknown` is safe.
37
+ */
38
+ create(calendar: StoredCalendar, draft: EventDraft, options: {
39
+ id: string;
40
+ notify: boolean;
41
+ }): Promise<EntryInput>;
42
+ update(calendar: StoredCalendar, externalId: string, patch: EventPatch, options: {
43
+ scope: WriteScope;
44
+ etag: string;
45
+ notify: boolean;
46
+ }): Promise<EntryInput>;
47
+ /** Nothing comes back from a deletion; the store cancels the entry as a sync would. */
48
+ remove(calendar: StoredCalendar, externalId: string, options: {
49
+ scope: WriteScope;
50
+ etag: string;
51
+ notify: boolean;
52
+ }): Promise<void>;
53
+ respond(calendar: StoredCalendar, externalId: string, response: EventResponse, options: {
54
+ etag: string;
55
+ notify: boolean;
56
+ }): Promise<EntryInput>;
57
+ }
58
+ /** Which write a command makes, for `CalendarWrites.extraKeys`. */
59
+ export type CalendarWrite = "create" | "update" | "delete" | "respond";
60
+ /** Mailing the other attendees: an agent may write to the calendar and still not to people (WC-PERM-14's spirit). */
61
+ export declare const EVENTS_NOTIFY_KEY: PermissionKey;
62
+ export declare const EVENTS_DELETE_SERIES_KEY: PermissionKey;
63
+ /** The keys the calendar writes check beyond their command paths, for a CLI's `commandPermissions({ extra })`. */
64
+ export declare const CALENDAR_WRITE_KEYS: readonly PermissionKey[];
65
+ /** Built in, and only ever tightening (WC-PERM-7): one change asks, a whole series is not deleted, nobody is mailed. */
66
+ export declare const CALENDAR_WRITE_DEFAULTS: Readonly<Record<PermissionKey, Level>>;
67
+ /** What an adapter throws when the provider refused a write because the entry's `etag` is old (HTTP 412). */
68
+ export declare const calendarConflict: (message?: string) => CliError;
@@ -0,0 +1,17 @@
1
+ import { CliError } from "@wirecat/cli-base";
2
+ /** Mailing the other attendees: an agent may write to the calendar and still not to people (WC-PERM-14's spirit). */
3
+ export const EVENTS_NOTIFY_KEY = "events.notify";
4
+ export const EVENTS_DELETE_SERIES_KEY = "events.delete.series";
5
+ /** The keys the calendar writes check beyond their command paths, for a CLI's `commandPermissions({ extra })`. */
6
+ export const CALENDAR_WRITE_KEYS = [EVENTS_NOTIFY_KEY, EVENTS_DELETE_SERIES_KEY];
7
+ /** Built in, and only ever tightening (WC-PERM-7): one change asks, a whole series is not deleted, nobody is mailed. */
8
+ export const CALENDAR_WRITE_DEFAULTS = {
9
+ "events.create": "ask",
10
+ "events.update": "ask",
11
+ "events.respond": "ask",
12
+ "events.delete": "ask",
13
+ [EVENTS_DELETE_SERIES_KEY]: "deny",
14
+ [EVENTS_NOTIFY_KEY]: "deny",
15
+ };
16
+ /** What an adapter throws when the provider refused a write because the entry's `etag` is old (HTTP 412). */
17
+ export const calendarConflict = (message = "the event changed at the provider since the last sync") => new CliError("validation_error", `${message}; sync and try again`, { reason: "event.conflict" });
@@ -0,0 +1,128 @@
1
+ import type { EventStore } from "../meetings/index.js";
2
+ import type { AppIdentity } from "../ports/app.js";
3
+ import type { Warn } from "../runs/events.js";
4
+ import { type Asker, type GuardSettings } from "../sends/guard.js";
5
+ import { type PermissionKey } from "../sends/permissions.js";
6
+ import { type CalendarWrite, type CalendarWriter, type EventPatch, type EventResponse, type WriteScope } from "./writer.js";
7
+ /** What a CLI or an MCP host hands the calendar writes for one profile. */
8
+ export interface CalendarWrites {
9
+ writer: CalendarWriter;
10
+ app: AppIdentity;
11
+ /** The profile's levels, read on every write so a config change applies at once. */
12
+ settings: () => GuardSettings;
13
+ /**
14
+ * Keys a write must also pass beyond its command path, so denying them cannot be bypassed through the shared
15
+ * commands: gcal names its generated API's (`api.calendar.events.insert`).
16
+ */
17
+ extraKeys?: (write: CalendarWrite) => readonly PermissionKey[];
18
+ env?: NodeJS.ProcessEnv;
19
+ warn?: Warn;
20
+ }
21
+ export declare const EVENT_RESPONSES: readonly EventResponse[];
22
+ /** An event's fields as typed: times in the caller's zone; an all-day event's dates, `endDate` its last day. */
23
+ export interface EventFields {
24
+ title?: string;
25
+ startTime?: string;
26
+ endTime?: string;
27
+ startDate?: string;
28
+ endDate?: string;
29
+ location?: string;
30
+ description?: string;
31
+ attendees?: string[];
32
+ }
33
+ export interface WriteContext {
34
+ store: EventStore;
35
+ accountId: number;
36
+ writes: CalendarWrites;
37
+ /** A terminal's question, or an agent's go-ahead (WC-MCP-7). */
38
+ ask: Asker;
39
+ now: number;
40
+ zone: string;
41
+ /** How the caller spells an input in a hint: `--notify or --no-notify` on the command line, `notify` over MCP. */
42
+ names: {
43
+ notify: string;
44
+ id: string;
45
+ calendar: string;
46
+ series: string;
47
+ };
48
+ }
49
+ /** Typed fields into a draft, or into a patch with only what was given. */
50
+ export declare const draftOf: (fields: EventFields, zone: string, { whole }: {
51
+ whole: boolean;
52
+ }) => EventPatch;
53
+ /** An id every provider takes as a client-chosen one: Google's base32hex `[a-v0-9]`, a CalDAV UID, a Graph id. */
54
+ export declare const newEventId: () => string;
55
+ export declare const createEvent: (context: WriteContext, calendarReference: string, fields: EventFields, options?: {
56
+ notify?: boolean;
57
+ id?: string;
58
+ }) => Promise<{
59
+ operation_id: string;
60
+ event: string | null;
61
+ entry: {
62
+ id: string;
63
+ calendar_id: string;
64
+ external_id: string;
65
+ kind: import("../meetings/event-store.js").EntryKind;
66
+ status: import("../meetings/event-store.js").EntryStatus;
67
+ event_type: import("../meetings/event-store.js").EntryEventType | null;
68
+ own_response: import("../meetings/event-store.js").ParticipantResponse | null;
69
+ starts_at: string | null;
70
+ ends_at: string | null;
71
+ join_url: string | null;
72
+ url: string | null;
73
+ deleted_at: string | null;
74
+ } | null;
75
+ }>;
76
+ export declare const updateEvent: (context: WriteContext, reference: string, fields: EventFields, options?: {
77
+ calendar?: string;
78
+ series?: boolean;
79
+ notify?: boolean;
80
+ }) => Promise<{
81
+ operation_id: string;
82
+ event: string | null;
83
+ entry: {
84
+ id: string;
85
+ calendar_id: string;
86
+ external_id: string;
87
+ kind: import("../meetings/event-store.js").EntryKind;
88
+ status: import("../meetings/event-store.js").EntryStatus;
89
+ event_type: import("../meetings/event-store.js").EntryEventType | null;
90
+ own_response: import("../meetings/event-store.js").ParticipantResponse | null;
91
+ starts_at: string | null;
92
+ ends_at: string | null;
93
+ join_url: string | null;
94
+ url: string | null;
95
+ deleted_at: string | null;
96
+ } | null;
97
+ }>;
98
+ export declare const deleteEvent: (context: WriteContext, reference: string, options?: {
99
+ calendar?: string;
100
+ series?: boolean;
101
+ notify?: boolean;
102
+ }) => Promise<{
103
+ operation_id: string;
104
+ event: string | null;
105
+ scope: WriteScope;
106
+ removed: number;
107
+ }>;
108
+ export declare const respondToEvent: (context: WriteContext, reference: string, response: EventResponse, options?: {
109
+ calendar?: string;
110
+ notify?: boolean;
111
+ }) => Promise<{
112
+ operation_id: string;
113
+ event: string | null;
114
+ entry: {
115
+ id: string;
116
+ calendar_id: string;
117
+ external_id: string;
118
+ kind: import("../meetings/event-store.js").EntryKind;
119
+ status: import("../meetings/event-store.js").EntryStatus;
120
+ event_type: import("../meetings/event-store.js").EntryEventType | null;
121
+ own_response: import("../meetings/event-store.js").ParticipantResponse | null;
122
+ starts_at: string | null;
123
+ ends_at: string | null;
124
+ join_url: string | null;
125
+ url: string | null;
126
+ deleted_at: string | null;
127
+ } | null;
128
+ }>;
@@ -0,0 +1,237 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { CliError, isCliError } from "@wirecat/cli-base";
3
+ import { zonedMoment } from "@wirecat/cli-base/query";
4
+ import { guardFor } from "../sends/guard.js";
5
+ import { guardedWrite } from "../sends/guarded.js";
6
+ import { DEFAULT_PERMISSIONS, levelFor } from "../sends/permissions.js";
7
+ import { newOperationId } from "../sends/send-id.js";
8
+ import { calendarIn, entryItem, eventIdOf, eventOf, plusDays } from "./reads.js";
9
+ import { CALENDAR_WRITE_DEFAULTS, EVENTS_DELETE_SERIES_KEY, EVENTS_NOTIFY_KEY, } from "./writer.js";
10
+ export const EVENT_RESPONSES = ["accepted", "tentative", "declined"];
11
+ /** Each write is checked by its command's path, the key a declared CLI's `commandPermissions` already knows. */
12
+ const COMMAND_KEY = {
13
+ create: "events.create",
14
+ update: "events.update",
15
+ delete: "events.delete",
16
+ respond: "events.respond",
17
+ };
18
+ const DATE = /^\d{4}-\d{2}-\d{2}$/u;
19
+ const EMAIL = /^[^\s@]+@[^\s@]+\.[^\s@]+$/u;
20
+ const dateOf = (text, what) => {
21
+ const [year, month, day] = text.split("-").map(Number);
22
+ if (!DATE.test(text) || new Date(Date.UTC(year, month - 1, day)).toISOString().slice(0, 10) !== text)
23
+ throw new CliError("validation_error", `${what} of an all-day event is a date, YYYY-MM-DD`);
24
+ return text;
25
+ };
26
+ const momentOf = (text, zone) => zonedMoment(text, zone, { start: 0, end: text.length });
27
+ /** Typed fields into a draft, or into a patch with only what was given. */
28
+ export const draftOf = (fields, zone, { whole }) => {
29
+ const patch = {};
30
+ if (fields.title !== undefined) {
31
+ if (!fields.title.trim())
32
+ throw new CliError("validation_error", "an event needs a title");
33
+ patch.title = fields.title.trim();
34
+ }
35
+ else if (whole)
36
+ throw new CliError("validation_error", "an event needs a title");
37
+ const timed = fields.startTime !== undefined || fields.endTime !== undefined;
38
+ const dated = fields.startDate !== undefined || fields.endDate !== undefined;
39
+ if (timed && dated)
40
+ throw new CliError("validation_error", "an event has start and end times, or dates, not both");
41
+ if (timed || dated || whole) {
42
+ const [start, end] = dated ? [fields.startDate, fields.endDate] : [fields.startTime, fields.endTime];
43
+ if (start === undefined || end === undefined)
44
+ throw new CliError("validation_error", "an event needs its start and its end");
45
+ if (dated) {
46
+ patch.startDate = dateOf(start, "the start");
47
+ patch.endDate = plusDays(dateOf(end, "the end"), 1);
48
+ if (patch.endDate <= patch.startDate)
49
+ throw new CliError("validation_error", "the event ends before it starts");
50
+ }
51
+ else {
52
+ patch.startsAt = momentOf(start, zone);
53
+ patch.endsAt = momentOf(end, zone);
54
+ if (patch.endsAt <= patch.startsAt)
55
+ throw new CliError("validation_error", "the event ends before it starts");
56
+ }
57
+ patch.timezone = zone;
58
+ }
59
+ if (fields.location !== undefined)
60
+ patch.location = fields.location.trim() || null;
61
+ if (fields.description !== undefined)
62
+ patch.description = fields.description.trim() || null;
63
+ if (fields.attendees !== undefined)
64
+ patch.attendees = [...new Set(fields.attendees.map((one) => one.trim().toLowerCase()))].map((email) => {
65
+ if (!EMAIL.test(email))
66
+ throw new CliError("validation_error", `${email} is not an email address`);
67
+ return { email };
68
+ });
69
+ if (!whole && Object.keys(patch).length === 0)
70
+ throw new CliError("validation_error", "nothing to change");
71
+ return patch;
72
+ };
73
+ /**
74
+ * Over the profile's guard, the write's other keys are checked too. A key that refuses is refused before anything is
75
+ * asked, so nobody answers yes to a write that a stricter key then stops, and one answer covers every key of a write.
76
+ */
77
+ const writeGuard = (context, extra) => {
78
+ const settings = context.writes.settings();
79
+ const defaults = { ...DEFAULT_PERMISSIONS, ...CALENDAR_WRITE_DEFAULTS, ...settings.permissionDefaults };
80
+ let question;
81
+ let answered = false;
82
+ const guard = guardFor(context.writes.app, { ...settings, permissionDefaults: defaults }, context.writes.warn ?? (() => { }), context.writes.env, async (key, request) => {
83
+ if (answered)
84
+ return;
85
+ await context.ask(question ?? key, request);
86
+ answered = true;
87
+ });
88
+ const probes = new WeakMap();
89
+ // A probe is checked and never journaled or counted: the write itself holds the one journal line.
90
+ const all = (request) => {
91
+ let found = probes.get(request);
92
+ if (!found) {
93
+ found = extra.map((key) => ({ chatId: null, kind: "read", key }));
94
+ probes.set(request, found);
95
+ }
96
+ return [request, ...found];
97
+ };
98
+ return {
99
+ ask: async (request) => {
100
+ const levelOf = ({ key }) => levelFor(settings.permissions, key, defaults).level;
101
+ const refused = all(request).find((one) => ["deny", "readonly"].includes(levelOf(one)));
102
+ if (refused)
103
+ guard.check(refused, { reserve: false });
104
+ // The one question names every key that asks, so a yes to the write is never a silent yes to mailing people.
105
+ question = all(request)
106
+ .filter((one) => levelOf(one) === "ask")
107
+ .map(({ key }) => key)
108
+ .join(" and ");
109
+ for (const one of all(request))
110
+ await guard.ask?.(one);
111
+ },
112
+ check: (request, options) => {
113
+ for (const one of all(request).slice(1))
114
+ guard.check(one, { reserve: false });
115
+ guard.check(request, options);
116
+ },
117
+ ...(guard.reserve ? { reserve: guard.reserve } : {}),
118
+ record: (entry) => guard.record(entry),
119
+ };
120
+ };
121
+ const notifyOf = (context, choice, others) => {
122
+ if (choice !== undefined)
123
+ return choice;
124
+ if (others)
125
+ throw new CliError("validation_error", `the event has attendees other than you: say whether the provider mails them (${context.names.notify})`, { reason: "event.notify-required", param: context.names.notify });
126
+ return false;
127
+ };
128
+ const hasOthers = (participants) => participants.some((one) => !one.self && !one.resource);
129
+ const keysFor = (context, write, notify) => [
130
+ ...(context.writes.extraKeys?.(write) ?? []),
131
+ ...(notify ? [EVENTS_NOTIFY_KEY] : []),
132
+ ];
133
+ const answerOf = (operationId, saved) => ({
134
+ operation_id: operationId,
135
+ event: saved?.eventId == null ? null : `event:${saved.eventId}`,
136
+ entry: saved ? entryItem(saved) : null,
137
+ });
138
+ /** An id every provider takes as a client-chosen one: Google's base32hex `[a-v0-9]`, a CalDAV UID, a Graph id. */
139
+ export const newEventId = () => randomUUID().replaceAll("-", "");
140
+ const ID = /^[a-z0-9]{5,1024}$/u;
141
+ export const createEvent = async (context, calendarReference, fields, options = {}) => {
142
+ const calendar = await calendarIn(context.store, context.accountId, calendarReference);
143
+ const draft = draftOf(fields, context.zone, { whole: true });
144
+ if (options.id !== undefined && !ID.test(options.id))
145
+ throw new CliError("validation_error", `${context.names.id} is the id a create's outcome_unknown named`, {
146
+ param: context.names.id,
147
+ });
148
+ const notify = notifyOf(context, options.notify, (draft.attendees?.length ?? 0) > 0);
149
+ const id = options.id ?? newEventId();
150
+ const guard = writeGuard(context, keysFor(context, "create", notify));
151
+ let entry;
152
+ try {
153
+ entry = await guardedWrite(guard, { chatId: null, kind: "edit", key: COMMAND_KEY.create, operationId: id, sendId: id, notify }, () => context.writes.writer.create(calendar, draft, { id, notify }));
154
+ }
155
+ catch (error) {
156
+ if (isCliError(error) && error.code === "outcome_unknown")
157
+ throw new CliError("outcome_unknown", `${error.message} — repeat the create with ${context.names.id} ${id}: the provider then finds the event ` +
158
+ "instead of making a second one", { ...error.details, operation_id: id });
159
+ throw error;
160
+ }
161
+ const [saved] = await context.store.saveEntries(calendar.id, [entry], context.now);
162
+ return answerOf(id, saved);
163
+ };
164
+ const targetOf = async (context, reference, { calendar, scope }) => {
165
+ const details = await eventOf(context.store, context.accountId, eventIdOf(reference));
166
+ const calendars = await context.store.calendars(context.accountId);
167
+ const only = calendar === undefined ? undefined : await calendarIn(context.store, context.accountId, calendar);
168
+ const live = details.entries.filter((one) => one.deletedAt === null && one.status !== "cancelled" && (!only || one.calendarId === only.id));
169
+ const [entry] = live;
170
+ if (!entry)
171
+ throw new CliError("not_found", `${reference} has no live copy${only ? ` on calendar ${only.id}` : ""}`);
172
+ if (live.length > 1)
173
+ throw new CliError("validation_error", `${reference} is on calendars ${live.map(({ calendarId }) => calendarId).join(", ")}; name one with ` +
174
+ `${context.names.calendar}`, { param: context.names.calendar });
175
+ let target = entry;
176
+ if (scope === "series") {
177
+ if (entry.kind !== "occurrence" || !entry.seriesExternalId)
178
+ throw new CliError("validation_error", `${reference} does not repeat; leave out ${context.names.series}`, {
179
+ param: context.names.series,
180
+ });
181
+ const series = await context.store.entry(entry.calendarId, entry.seriesExternalId);
182
+ if (!series)
183
+ throw new CliError("not_found", `the series of ${reference} is not stored; sync and try again`);
184
+ target = series;
185
+ }
186
+ if (!target.etag)
187
+ throw new CliError("validation_error", `no provider version is stored for ${reference}; sync and try again`, {
188
+ reason: "event.conflict",
189
+ sent: false,
190
+ });
191
+ return {
192
+ calendar: calendars.find(({ id }) => id === entry.calendarId),
193
+ entry,
194
+ target,
195
+ participants: details.participants.filter(({ entryId }) => entryId === entry.id),
196
+ };
197
+ };
198
+ export const updateEvent = async (context, reference, fields, options = {}) => {
199
+ const patch = draftOf(fields, context.zone, { whole: false });
200
+ const scope = options.series ? "series" : "this";
201
+ const { calendar, target, participants } = await targetOf(context, reference, { ...options, scope });
202
+ const notify = notifyOf(context, options.notify, hasOthers(participants) || (patch.attendees?.length ?? 0) > 0);
203
+ const operationId = newOperationId();
204
+ const entry = await guardedWrite(writeGuard(context, keysFor(context, "update", notify)), { chatId: null, kind: "edit", key: COMMAND_KEY.update, operationId, notify }, () => context.writes.writer.update(calendar, target.externalId, patch, { scope, etag: target.etag, notify }));
205
+ const [saved] = await context.store.saveEntries(calendar.id, [entry], context.now);
206
+ return answerOf(operationId, saved);
207
+ };
208
+ export const deleteEvent = async (context, reference, options = {}) => {
209
+ const scope = options.series ? "series" : "this";
210
+ const { calendar, entry, target, participants } = await targetOf(context, reference, { ...options, scope });
211
+ const notify = notifyOf(context, options.notify, hasOthers(participants));
212
+ const operationId = newOperationId();
213
+ await guardedWrite(writeGuard(context, keysFor(context, "delete", notify)), {
214
+ chatId: null,
215
+ kind: "edit",
216
+ key: scope === "series" ? EVENTS_DELETE_SERIES_KEY : COMMAND_KEY.delete,
217
+ operationId,
218
+ notify,
219
+ }, () => context.writes.writer.remove(calendar, target.externalId, { scope, etag: target.etag, notify }));
220
+ const removed = await context.store.removeEntries(calendar.id, [target.externalId], context.now);
221
+ return {
222
+ operation_id: operationId,
223
+ event: entry.eventId === null ? null : `event:${entry.eventId}`,
224
+ scope,
225
+ removed,
226
+ };
227
+ };
228
+ export const respondToEvent = async (context, reference, response, options = {}) => {
229
+ if (!EVENT_RESPONSES.includes(response))
230
+ throw new CliError("validation_error", `a response is one of ${EVENT_RESPONSES.join(", ")}`);
231
+ const { calendar, target, participants } = await targetOf(context, reference, { ...options, scope: "this" });
232
+ const notify = notifyOf(context, options.notify, hasOthers(participants));
233
+ const operationId = newOperationId();
234
+ const entry = await guardedWrite(writeGuard(context, keysFor(context, "respond", notify)), { chatId: null, kind: "edit", key: COMMAND_KEY.respond, operationId, notify }, () => context.writes.writer.respond(calendar, target.externalId, response, { etag: target.etag, notify }));
235
+ const [saved] = await context.store.saveEntries(calendar.id, [entry], context.now);
236
+ return answerOf(operationId, saved);
237
+ };
@@ -3,9 +3,9 @@ import type { CacheDatabase } from "../../store/driver.js";
3
3
  import type { Messenger } from "./context.js";
4
4
  /** `doctor`'s short summary of the store. Must answer for a broken file, so an error is a field. */
5
5
  export declare const storeSummary: (env: NodeJS.ProcessEnv) => Promise<{
6
+ error?: undefined;
6
7
  path: string;
7
8
  exists: boolean;
8
- error?: undefined;
9
9
  } | {
10
10
  path: string;
11
11
  exists: boolean;
@@ -54,6 +54,8 @@ const OTHERS = [
54
54
  validation("spreadsheet.unreadable", "the workbook could not be read"),
55
55
  ["state.invalid", "configuration_error", "the saved sync state is unreadable or invalid; it is kept, not reset"],
56
56
  ["sync.partial", "provider_error", "some calendars did not sync; the next sync retries them"],
57
+ validation("event.conflict", "the event changed at the provider since the last sync; sync and try again"),
58
+ validation("event.notify-required", "the event has other attendees; say whether to notify them"),
57
59
  validation("store.refused", "the local store refused the document text"),
58
60
  ["store.not-writable", "permission_error", "the message store cannot be written from this process"],
59
61
  [
@@ -140,6 +140,8 @@ export interface StoredEntry {
140
140
  joinKey: string | null;
141
141
  url: string | null;
142
142
  externalUpdatedAt: number | null;
143
+ /** The provider's version of the entry, sent back with a write so a change made since the last sync is refused. */
144
+ etag: string | null;
143
145
  seenAt: number | null;
144
146
  deletedAt: number | null;
145
147
  }
@@ -239,6 +241,8 @@ export interface EventStore {
239
241
  cursor(calendarId: number): Promise<string | null>;
240
242
  /** `null` forgets the cursor, so the next sync is a full list. */
241
243
  setCursor(calendarId: number, value: string | null, now: number): Promise<void>;
244
+ /** One entry by the provider's id; a series, which no event holds, is found only this way. */
245
+ entry(calendarId: number, externalId: string): Promise<StoredEntry | null>;
242
246
  events(filter?: EventFilter): Promise<Event[]>;
243
247
  event(id: number): Promise<EventDetails | null>;
244
248
  /** The owner's link of an entry to an event; no rule replaces it. */
@@ -48,6 +48,7 @@ const entryOf = (row) => ({
48
48
  joinKey: str(row.join_key),
49
49
  url: str(row.url),
50
50
  externalUpdatedAt: int(row.external_updated_at),
51
+ etag: str(row.etag),
51
52
  seenAt: int(row.seen_at),
52
53
  deletedAt: int(row.deleted_at),
53
54
  });
@@ -513,6 +514,12 @@ export const eventStoreOver = (context) => {
513
514
  refreshEvent(eventId, now);
514
515
  return marked.length;
515
516
  }),
517
+ entry: async (calendarId, externalId) => read(database, () => {
518
+ const row = database
519
+ .prepare("SELECT * FROM calendar_entries WHERE calendar_id = ? AND external_id = ?")
520
+ .get(calendarId, externalId);
521
+ return row ? entryOf(row) : null;
522
+ }),
516
523
  cursor: async (calendarId) => read(database, () => {
517
524
  const { account_id } = calendarRow(calendarId);
518
525
  return str(database
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wirecat/cli-core",
3
- "version": "0.457.0",
3
+ "version": "0.458.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",