@mulmoclaude/core 5.1.0 → 5.3.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 (39) hide show
  1. package/assets/helps/error-recovery.md +78 -0
  2. package/assets/helps/google-calendar-collection.md +92 -12
  3. package/dist/collection/core/schemaZ.d.ts +15 -2
  4. package/dist/collection/core/viewChatPolicy.d.ts +6 -3
  5. package/dist/collection/index.cjs +6 -3
  6. package/dist/collection/index.cjs.map +1 -1
  7. package/dist/collection/index.js +6 -3
  8. package/dist/collection/index.js.map +1 -1
  9. package/dist/collection/registry/server/index.cjs +2 -2
  10. package/dist/collection/registry/server/index.js +2 -2
  11. package/dist/collection/server/index.cjs +2 -2
  12. package/dist/collection/server/index.js +2 -2
  13. package/dist/collection-watchers/index.cjs +2 -2
  14. package/dist/collection-watchers/index.js +2 -2
  15. package/dist/{discovery-CBjI1lx9.js → discovery-B_64PBZU.js} +14 -3
  16. package/dist/{discovery-CBjI1lx9.js.map → discovery-B_64PBZU.js.map} +1 -1
  17. package/dist/{discovery-DCMW05Bp.cjs → discovery-CDXYswtw.cjs} +14 -3
  18. package/dist/{discovery-DCMW05Bp.cjs.map → discovery-CDXYswtw.cjs.map} +1 -1
  19. package/dist/feeds/server/index.cjs +2 -2
  20. package/dist/feeds/server/index.js +2 -2
  21. package/dist/google/calendar.d.ts +24 -0
  22. package/dist/google/collectionPush.d.ts +45 -2
  23. package/dist/google/deletePlan.d.ts +32 -0
  24. package/dist/google/eventDerived.d.ts +19 -0
  25. package/dist/google/index.cjs +179 -18
  26. package/dist/google/index.cjs.map +1 -1
  27. package/dist/google/index.d.ts +4 -2
  28. package/dist/google/index.js +174 -19
  29. package/dist/google/index.js.map +1 -1
  30. package/dist/remote-view/index.cjs +7 -7
  31. package/dist/remote-view/index.cjs.map +1 -1
  32. package/dist/remote-view/index.d.ts +3 -3
  33. package/dist/remote-view/index.js +7 -7
  34. package/dist/remote-view/index.js.map +1 -1
  35. package/dist/{server-BMZ_BcPH.js → server-7POrhHKv.js} +2 -2
  36. package/dist/{server-BMZ_BcPH.js.map → server-7POrhHKv.js.map} +1 -1
  37. package/dist/{server-rA9FSkl2.cjs → server-DfYwSX8b.cjs} +2 -2
  38. package/dist/{server-rA9FSkl2.cjs.map → server-DfYwSX8b.cjs.map} +1 -1
  39. package/package.json +1 -1
@@ -4,8 +4,8 @@ const require_dist = require("../../dist-Gj7ygaW2.cjs");
4
4
  const require_root = require("../../root-BvwBUcol.cjs");
5
5
  const require_itemId = require("../../itemId-CGT2J7YK.cjs");
6
6
  const require_project = require("../../project-C1ep9pvo.cjs");
7
- const require_discovery = require("../../discovery-DCMW05Bp.cjs");
8
- const require_server = require("../../server-rA9FSkl2.cjs");
7
+ const require_discovery = require("../../discovery-CDXYswtw.cjs");
8
+ const require_server = require("../../server-DfYwSX8b.cjs");
9
9
  const require_feeds_paths = require("../paths.cjs");
10
10
  const require_ingestTypes = require("../../ingestTypes-DsVH9BYy.cjs");
11
11
  const require_notifier = require("../../notifier-B65Mvngb.cjs");
@@ -2,8 +2,8 @@ import { a as isErrorWithCode, l as isUnknownArray, s as isRecord$1 } from "../.
2
2
  import { t as canonicalRoot } from "../../root-BcS4-Kge.js";
3
3
  import { G as INGEST_KINDS, W as FEED_SCHEDULES } from "../../itemId-4P9S_ehz.js";
4
4
  import { t as mergeIntoExisting } from "../../project-B0j_Vf_Q.js";
5
- import { W as resolveDataDir, _t as createForwardingLogger, d as storeFor, k as listItems, n as discoverCollections, q as safeSlugName, vt as createHostSlot } from "../../discovery-CBjI1lx9.js";
6
- import { F as buildCollectionActionSeedPrompt, I as promptPathsFor, z as readSkillTemplate } from "../../server-BMZ_BcPH.js";
5
+ import { W as resolveDataDir, _t as createForwardingLogger, d as storeFor, k as listItems, n as discoverCollections, q as safeSlugName, vt as createHostSlot } from "../../discovery-B_64PBZU.js";
6
+ import { F as buildCollectionActionSeedPrompt, I as promptPathsFor, z as readSkillTemplate } from "../../server-7POrhHKv.js";
7
7
  import { FEEDS_DIR, feedDir, feedStatePath, feedsRoot, ingestStateDir, ingestStatePath } from "../paths.js";
8
8
  import { n as DEFAULT_FEED_MAX_ITEMS, r as isFeedSchedule, t as AGENT_INGEST_KIND } from "../../ingestTypes-GkG7bJdB.js";
9
9
  import { d as publish, n as clear } from "../../notifier-vAp5t6wL.js";
@@ -1,3 +1,8 @@
1
+ /** A GET or DELETE on an event that is not there. Exported because the push's
2
+ * delete sweep has to read "already gone" as success, not as a failure to
3
+ * retry — a retained baseline would announce the same phantom deletion on
4
+ * every run. */
5
+ export declare const EVENT_ABSENT_STATUSES: readonly number[];
1
6
  /** An `If-Match` write whose etag no longer matches — someone else changed the
2
7
  * event since it was read. */
3
8
  export declare const HTTP_PRECONDITION_FAILED = 412;
@@ -74,6 +79,12 @@ export interface UpdateCalendarEventInput {
74
79
  export interface DeleteCalendarEventInput {
75
80
  eventId: string;
76
81
  calendarId?: string | undefined;
82
+ /** Etag of the version this decision was made against. Sent as `If-Match`, so
83
+ * Google answers 412 rather than removing an event that changed after it was
84
+ * read — the same guard `updateCalendarEvent` takes, and it matters more
85
+ * here: a delete cannot be undone from this side. Omit for an unconditional
86
+ * delete. */
87
+ ifMatch?: string | undefined;
77
88
  }
78
89
  export interface ListEventsInput {
79
90
  timeMin?: string | undefined;
@@ -117,6 +128,14 @@ export interface CalendarEventSummary {
117
128
  eventType: string;
118
129
  /** Google Meet URL, "" when nothing is attached. */
119
130
  hangoutLink: string;
131
+ /** The signed-in user's own `responseStatus`, folded out of `attendees`.
132
+ * `""` when Google reported none — which is every event that HAS no
133
+ * attendees, so it reads as "nothing said", never as "not going". */
134
+ selfResponseStatus: string;
135
+ /** The URL that joins this event's meeting, folded out of
136
+ * `conferenceData.entryPoints`. Covers the conferences `hangoutLink` does
137
+ * not: Zoom, Teams, anything attached as conference data. */
138
+ conferenceVideoUri: string;
120
139
  }
121
140
  export interface CalendarSummary {
122
141
  id: string;
@@ -166,6 +185,11 @@ export interface FetchedCalendarEvent {
166
185
  /** Google's `etag` for this version, `""` when absent. Sent back as
167
186
  * `If-Match` so a PATCH cannot overwrite a version we never read. */
168
187
  etag: string;
188
+ /** How many people the event lists, `0` for a solo one. Carried here rather
189
+ * than on the summary because only the delete guard reads it, and it is the
190
+ * one thing that guard cannot get from the record — by then the record is
191
+ * gone (`deletePlan.ts`). */
192
+ attendeeCount: number;
169
193
  }
170
194
  /** Read ONE event, or null when it is gone (404 / 410).
171
195
  *
@@ -1,6 +1,6 @@
1
1
  import { LoadedCollection } from '../collection/server/discoveredCollection.js';
2
2
  import { CollectionFieldSpec, CollectionItem } from '../collection/core/schema.js';
3
- import { CalendarEventSummary } from './calendar.js';
3
+ import { deleteCalendarEvent, getCalendarEvent, CalendarEventSummary } from './calendar.js';
4
4
  import { ShadowEvent } from './calendarPushState.js';
5
5
  import { PushableSourceField } from './pushPlan.js';
6
6
  /** What the up-front writability gate knows about the target calendar. */
@@ -31,8 +31,12 @@ export interface CalendarCollectionPushResult {
31
31
  updated: number;
32
32
  /** Edited on both sides; skipped so neither version is destroyed. */
33
33
  conflicts: number;
34
- /** Records deleted locally. Reported only — v1 never deletes in Google. */
34
+ /** Records deleted locally. Reported whether or not the deletion carried, so
35
+ * the count still answers "how many rows went away here". */
35
36
  localDeletes: number;
37
+ /** Of those, how many were deleted in Google too. Always `0` unless the
38
+ * collection opted in with `propagateDeletes` (#3234). */
39
+ deletedInGoogle: number;
36
40
  /** Records that cannot be pushed as they stand, each with the reason. */
37
41
  skipped: string[];
38
42
  errors: string[];
@@ -72,6 +76,15 @@ export interface CalendarPushDeps {
72
76
  isLinked: () => Promise<boolean>;
73
77
  accessToken: () => Promise<string>;
74
78
  calendarMeta: (accessToken: string, calendarId: string | undefined) => Promise<CalendarWriteTarget>;
79
+ /** Read and write for the delete guard. Injected like the rest so a test can
80
+ * exercise "this event has attendees" without a grant (#3234).
81
+ *
82
+ * OPTIONAL, unlike the rest: `CalendarPushDeps` is an exported type, and a
83
+ * deps object written before these existed must keep satisfying it — the
84
+ * more so because a collection that never opted into `propagateDeletes`
85
+ * cannot reach either of them. Absent means the live implementation. */
86
+ fetchEvent?: typeof getCalendarEvent;
87
+ deleteEvent?: typeof deleteCalendarEvent;
75
88
  }
76
89
  type PushOutcome = {
77
90
  kind: "created";
@@ -114,6 +127,36 @@ export declare function locallyEditedIds(records: readonly CollectionItem[], sha
114
127
  * from the stored baseline alone, so it works precisely when Google is the
115
128
  * thing that is unreachable. */
116
129
  export declare function unsentLocalEdits(collection: LoadedCollection, workspaceRoot: string): Promise<string[]>;
130
+ /** What one run's deletions did. `seen` counts every record that went away
131
+ * locally, whether or not the deletion carried — so the count means the same
132
+ * thing with the feature off as it always did. */
133
+ export interface DeleteSweep {
134
+ seen: number;
135
+ deleted: string[];
136
+ skipped: string[];
137
+ errors: string[];
138
+ }
139
+ /** What `sweepDeletes` needs, narrowed to the three effects it has. Taken as an
140
+ * argument rather than reached for, so the guard can be exercised against
141
+ * "this event has attendees" with no grant and no workspace. */
142
+ export interface DeleteSweepDeps {
143
+ /** What Google currently holds for the event, or null when it is gone. The
144
+ * `etag` is the version the decision was made against. */
145
+ fetchEvent: (eventId: string) => Promise<{
146
+ attendeeCount: number;
147
+ etag: string;
148
+ } | null>;
149
+ /** Delete it, conditional on that version. */
150
+ deleteEvent: (eventId: string, ifMatch: string) => Promise<void>;
151
+ /** Drop the event's baseline entry. */
152
+ forget: (eventId: string) => Promise<void>;
153
+ }
154
+ /** Carry every local deletion, when the collection opted in.
155
+ *
156
+ * Sequential rather than concurrent: each one writes the shared baseline file,
157
+ * and an interrupted run has to leave a state the next run resumes from rather
158
+ * than a partially applied batch. */
159
+ export declare function sweepDeletes(eventIds: readonly string[], propagate: boolean, deps: DeleteSweepDeps): Promise<DeleteSweep>;
117
160
  /** Push one collection WITHOUT taking the calendar lock.
118
161
  *
119
162
  * The caller must already hold it. The scheduled push→pull cycle
@@ -0,0 +1,32 @@
1
+ /** Why an event was left in Google although its record is gone. */
2
+ export type DeleteRefusal = {
3
+ kind: "has-attendees";
4
+ } | {
5
+ kind: "already-gone";
6
+ };
7
+ /** An approval carries the version it was made against, so the delete can be
8
+ * conditional on it. Keeping the two together is what stops a caller deleting
9
+ * against a version the guard never saw. */
10
+ export type DeleteDecision = {
11
+ ok: true;
12
+ etag: string;
13
+ } | ({
14
+ ok: false;
15
+ } & DeleteRefusal);
16
+ /** The message the push reports for a refusal, with the event named. */
17
+ export declare const deleteRefusalMessage: (eventId: string, refusal: DeleteRefusal) => string;
18
+ /** Decide one deletion against what Google currently holds.
19
+ *
20
+ * `null` means the event is already absent — a 404/410 on the read. Nothing to
21
+ * delete, and reported apart from a refusal so "we chose not to" never reads
22
+ * as "we could not".
23
+ *
24
+ * ANY attendee refuses, including the entry Google adds for the organiser. A
25
+ * rule that tried to exclude "only me" would have to work out which entry is
26
+ * the user from a payload that may not say, and being wrong there withdraws a
27
+ * real invitation. Solo events — the ones this feature exists for — carry no
28
+ * attendees at all, so the blunt rule costs them nothing. */
29
+ export declare function planDelete(fetched: {
30
+ attendeeCount: number;
31
+ etag: string;
32
+ } | null): DeleteDecision;
@@ -0,0 +1,19 @@
1
+ /** The signed-in user's own `responseStatus` on this event, `""` when Google
2
+ * reported none.
3
+ *
4
+ * `""` is the common case, not an edge one: an event with no attendees has no
5
+ * entry to mark `self`, which is most of a personal calendar. So it means
6
+ * "Google said nothing", never "not going" — filter with `!= "declined"`, not
7
+ * with `== "accepted"`, or every solo event disappears too.
8
+ *
9
+ * Google's values: `needsAction`, `declined`, `tentative`, `accepted`. */
10
+ export declare function selfResponseStatus(attendees: unknown): string;
11
+ /** The URL that joins this event's meeting, `""` when it has none.
12
+ *
13
+ * Only the `video` entry point. The others are a phone number and a dial-in
14
+ * PIN page, and a column named for joining that sometimes holds `tel:` would
15
+ * be worse than one that is empty — the caller can see empty and fall back.
16
+ *
17
+ * `hangoutLink` (#3229) already carries this for Google Meet; this is what
18
+ * reaches the events whose conference is Zoom or Teams. */
19
+ export declare function conferenceVideoUri(conferenceData: unknown): string;
@@ -7,7 +7,7 @@ const require_root = require("../root-BvwBUcol.cjs");
7
7
  const require_utils_fetch = require("../utils/fetch.cjs");
8
8
  const require_itemId = require("../itemId-CGT2J7YK.cjs");
9
9
  const require_project = require("../project-C1ep9pvo.cjs");
10
- const require_discovery = require("../discovery-DCMW05Bp.cjs");
10
+ const require_discovery = require("../discovery-CDXYswtw.cjs");
11
11
  const require_paths = require("../paths-C8-jxR0s.cjs");
12
12
  let node_fs = require("node:fs");
13
13
  let node_path = require("node:path");
@@ -45,6 +45,74 @@ var isIsoDateTimeWithOffset = (value) => {
45
45
  return isRealCalendarDate(year, month, day) && timeInRange && offsetInRange;
46
46
  };
47
47
  //#endregion
48
+ //#region src/google/deletePlan.ts
49
+ var ATTENDEES_REASON = "it carries attendees — deleting an invited event withdraws it from their calendars too";
50
+ var ALREADY_GONE_REASON = "it is no longer in Google";
51
+ /** The message the push reports for a refusal, with the event named. */
52
+ var deleteRefusalMessage = (eventId, refusal) => `${eventId}: left in Google because ${refusal.kind === "has-attendees" ? ATTENDEES_REASON : ALREADY_GONE_REASON}`;
53
+ /** Decide one deletion against what Google currently holds.
54
+ *
55
+ * `null` means the event is already absent — a 404/410 on the read. Nothing to
56
+ * delete, and reported apart from a refusal so "we chose not to" never reads
57
+ * as "we could not".
58
+ *
59
+ * ANY attendee refuses, including the entry Google adds for the organiser. A
60
+ * rule that tried to exclude "only me" would have to work out which entry is
61
+ * the user from a payload that may not say, and being wrong there withdraws a
62
+ * real invitation. Solo events — the ones this feature exists for — carry no
63
+ * attendees at all, so the blunt rule costs them nothing. */
64
+ function planDelete(fetched) {
65
+ if (fetched === null) return {
66
+ ok: false,
67
+ kind: "already-gone"
68
+ };
69
+ return fetched.attendeeCount > 0 ? {
70
+ ok: false,
71
+ kind: "has-attendees"
72
+ } : {
73
+ ok: true,
74
+ etag: fetched.etag
75
+ };
76
+ }
77
+ //#endregion
78
+ //#region src/google/util.ts
79
+ var ONE_SECOND_MS = 1e3;
80
+ var ONE_MINUTE_MS = 6e4;
81
+ function isRecord(value) {
82
+ return typeof value === "object" && value !== null && !Array.isArray(value);
83
+ }
84
+ //#endregion
85
+ //#region src/google/eventDerived.ts
86
+ var stringAt = (record, key) => typeof record[key] === "string" ? record[key] : "";
87
+ var recordsIn = (value) => Array.isArray(value) ? value.filter(isRecord) : [];
88
+ /** The signed-in user's own `responseStatus` on this event, `""` when Google
89
+ * reported none.
90
+ *
91
+ * `""` is the common case, not an edge one: an event with no attendees has no
92
+ * entry to mark `self`, which is most of a personal calendar. So it means
93
+ * "Google said nothing", never "not going" — filter with `!= "declined"`, not
94
+ * with `== "accepted"`, or every solo event disappears too.
95
+ *
96
+ * Google's values: `needsAction`, `declined`, `tentative`, `accepted`. */
97
+ function selfResponseStatus(attendees) {
98
+ const self = recordsIn(attendees).find((attendee) => attendee.self === true);
99
+ return self === void 0 ? "" : stringAt(self, "responseStatus");
100
+ }
101
+ var VIDEO_ENTRY_POINT = "video";
102
+ /** The URL that joins this event's meeting, `""` when it has none.
103
+ *
104
+ * Only the `video` entry point. The others are a phone number and a dial-in
105
+ * PIN page, and a column named for joining that sometimes holds `tel:` would
106
+ * be worse than one that is empty — the caller can see empty and fall back.
107
+ *
108
+ * `hangoutLink` (#3229) already carries this for Google Meet; this is what
109
+ * reaches the events whose conference is Zoom or Teams. */
110
+ function conferenceVideoUri(conferenceData) {
111
+ if (!isRecord(conferenceData)) return "";
112
+ const video = recordsIn(conferenceData.entryPoints).find((entry) => entry.entryPointType === VIDEO_ENTRY_POINT);
113
+ return video === void 0 ? "" : stringAt(video, "uri");
114
+ }
115
+ //#endregion
48
116
  //#region src/google/eventSpanInput.ts
49
117
  var DATE_ONLY_RE$1 = /^\d{4}-\d{2}-\d{2}$/;
50
118
  /** Whether `value` is a bare calendar day — the all-day spelling of an event
@@ -160,13 +228,6 @@ function googleSecretsDir(home) {
160
228
  return (0, node_path.join)(home ?? (0, node_os.homedir)(), ".secrets");
161
229
  }
162
230
  //#endregion
163
- //#region src/google/util.ts
164
- var ONE_SECOND_MS = 1e3;
165
- var ONE_MINUTE_MS = 6e4;
166
- function isRecord(value) {
167
- return typeof value === "object" && value !== null && !Array.isArray(value);
168
- }
169
- //#endregion
170
231
  //#region src/google/clientSecret.ts
171
232
  var isNonEmptyString = (value) => typeof value === "string" && value !== "";
172
233
  var isInstalledClientSecret = (value) => {
@@ -697,6 +758,10 @@ var EVENT_SYNC_PAGE_SIZE = 2500;
697
758
  var MAX_EVENT_SYNC_PAGES = 200;
698
759
  var CALENDAR_META_PAGE_SIZE = 1;
699
760
  var HTTP_GONE = 410;
761
+ /** A GET or DELETE on an event that is not there. Exported because the push's
762
+ * delete sweep has to read "already gone" as success, not as a failure to
763
+ * retry — a retained baseline would announce the same phantom deletion on
764
+ * every run. */
700
765
  var EVENT_ABSENT_STATUSES = [404, HTTP_GONE];
701
766
  /** An `If-Match` write whose etag no longer matches — someone else changed the
702
767
  * event since it was read. */
@@ -714,6 +779,10 @@ var CANCELLED_EVENT_STATUS = "cancelled";
714
779
  * apart while they share a sync token silently loses events (#2184). */
715
780
  var canonicalCalendarId = (calendarId) => calendarId || DEFAULT_CALENDAR_ID;
716
781
  var eventsUrl = (calendarId) => `${CALENDAR_BASE_URL}/calendars/${encodeURIComponent(canonicalCalendarId(calendarId))}/events`;
782
+ /** People Google lists on the event, `0` when it carries none. Counts EVERY
783
+ * entry, including the one Google adds for the organiser: see `deletePlan.ts`
784
+ * for why the guard is not cleverer than that. */
785
+ var attendeeCount = (record) => Array.isArray(record.attendees) ? record.attendees.length : 0;
717
786
  var eventTime$1 = (value) => {
718
787
  if (!isRecord(value)) return "";
719
788
  if (typeof value.dateTime === "string") return value.dateTime;
@@ -737,7 +806,9 @@ var toEventSummary = (value) => {
737
806
  updated: stringField(record, "updated"),
738
807
  transparency: stringField(record, "transparency"),
739
808
  eventType: stringField(record, "eventType"),
740
- hangoutLink: stringField(record, "hangoutLink")
809
+ hangoutLink: stringField(record, "hangoutLink"),
810
+ selfResponseStatus: selfResponseStatus(record.attendees),
811
+ conferenceVideoUri: conferenceVideoUri(record.conferenceData)
741
812
  };
742
813
  };
743
814
  var toCalendarSummary = (value) => {
@@ -828,9 +899,11 @@ async function updateCalendarEvent(accessToken, input) {
828
899
  async function getCalendarEvent(accessToken, input) {
829
900
  try {
830
901
  const fetched = await googleRequest(CALENDAR_API_LABEL, accessToken, eventUrl(input.calendarId, input.eventId));
902
+ const record = asRecord(fetched);
831
903
  return {
832
904
  event: toEventSummary(fetched),
833
- etag: stringField(asRecord(fetched), "etag")
905
+ etag: stringField(record, "etag"),
906
+ attendeeCount: attendeeCount(record)
834
907
  };
835
908
  } catch (error) {
836
909
  if (isGoogleApiError(error) && EVENT_ABSENT_STATUSES.includes(error.status)) return null;
@@ -841,7 +914,10 @@ async function getCalendarEvent(accessToken, input) {
841
914
  * return; a second delete of the same id answers 410, which surfaces as a
842
915
  * GoogleApiError rather than being swallowed. */
843
916
  async function deleteCalendarEvent(accessToken, input) {
844
- await googleRequest(CALENDAR_API_LABEL, accessToken, eventUrl(input.calendarId, input.eventId), { method: "DELETE" });
917
+ await googleRequest(CALENDAR_API_LABEL, accessToken, eventUrl(input.calendarId, input.eventId), {
918
+ method: "DELETE",
919
+ ...input.ifMatch ? { extraHeaders: { "If-Match": input.ifMatch } } : {}
920
+ });
845
921
  }
846
922
  async function listCalendarEvents(accessToken, input = {}) {
847
923
  const params = new URLSearchParams({
@@ -1623,7 +1699,9 @@ var UNPUSHABLE_EVENT_FIELDS = {
1623
1699
  updated: "",
1624
1700
  transparency: "",
1625
1701
  eventType: "",
1626
- hangoutLink: ""
1702
+ hangoutLink: "",
1703
+ selfResponseStatus: "",
1704
+ conferenceVideoUri: ""
1627
1705
  };
1628
1706
  /** A baseline shaped as the event it came from, so the comparison can run
1629
1707
  * through the very projection that wrote the record. */
@@ -1744,7 +1822,9 @@ var liveDeps = {
1744
1822
  findCollection: findCalendarCollection,
1745
1823
  isLinked: async () => Boolean((await loadGoogleTokens())?.refresh_token),
1746
1824
  accessToken: getGoogleAccessToken,
1747
- calendarMeta: liveCalendarMeta
1825
+ calendarMeta: liveCalendarMeta,
1826
+ fetchEvent: getCalendarEvent,
1827
+ deleteEvent: deleteCalendarEvent
1748
1828
  };
1749
1829
  var localValue = (ctx, record, source) => {
1750
1830
  const field = ctx.bySource[source];
@@ -1954,7 +2034,7 @@ var UNPUSHED_KINDS = ["conflict", "error"];
1954
2034
  /** Whether this outcome leaves a local edit that only exists locally, so the
1955
2035
  * pull must not overwrite the record and the baseline must not advance. */
1956
2036
  var isUnpushed = (kind) => UNPUSHED_KINDS.includes(kind);
1957
- function tally(slug, attempts, localDeletes) {
2037
+ function tally(slug, attempts, deletes) {
1958
2038
  const outcomes = attempts.map((attempt) => attempt.outcome);
1959
2039
  const count = (kind) => outcomes.filter((outcome) => outcome.kind === kind).length;
1960
2040
  return {
@@ -1962,9 +2042,10 @@ function tally(slug, attempts, localDeletes) {
1962
2042
  created: count("created"),
1963
2043
  updated: count("updated"),
1964
2044
  conflicts: count("conflict"),
1965
- localDeletes,
1966
- skipped: outcomes.flatMap((outcome) => outcome.kind === "skipped" ? [outcome.message] : []),
1967
- errors: outcomes.flatMap((outcome) => outcome.kind === "error" ? [outcome.message] : []),
2045
+ localDeletes: deletes.seen,
2046
+ deletedInGoogle: deletes.deleted.length,
2047
+ skipped: [...outcomes.flatMap((outcome) => outcome.kind === "skipped" ? [outcome.message] : []), ...deletes.skipped],
2048
+ errors: [...outcomes.flatMap((outcome) => outcome.kind === "error" ? [outcome.message] : []), ...deletes.errors],
1968
2049
  unpushedIds: attempts.filter((attempt) => isUnpushed(attempt.outcome.kind)).map((attempt) => attempt.eventId)
1969
2050
  };
1970
2051
  }
@@ -1993,6 +2074,69 @@ async function unsentLocalEdits(collection, workspaceRoot) {
1993
2074
  const shadow = await loadCalendarShadow(schema.googleCalendar?.calendarId, workspaceRoot);
1994
2075
  return locallyEditedIds(await require_discovery.storeFor(collection, { workspaceRoot }).list(), shadow, pushableMap(schema.googleCalendar?.map ?? {}), schema.primaryKey, schema.fields);
1995
2076
  }
2077
+ var noDeletes = (seen) => ({
2078
+ seen,
2079
+ deleted: [],
2080
+ skipped: [],
2081
+ errors: []
2082
+ });
2083
+ /** A delete whose event changed in Google between the guard reading it and the
2084
+ * delete landing. Reported rather than retried: what changed may be the very
2085
+ * thing the guard refuses, and re-reading in a loop would race the same way. */
2086
+ var staleDeleteMessage = (eventId) => `${eventId}: left in Google because it changed there while this push was checking it — press Push again to re-check`;
2087
+ /** Delete ONE event and forget its baseline, or say why it was left alone.
2088
+ *
2089
+ * The baseline entry goes only after Google confirms, and it must go: the
2090
+ * baseline is what `locallyDeletedIds` reads, so a kept entry would re-report
2091
+ * the same deletion on every run forever. A refusal keeps its entry on
2092
+ * purpose — the event IS still standing in Google, and the report is the only
2093
+ * thing saying so.
2094
+ *
2095
+ * The delete carries the etag the guard read. Without it, an attendee added
2096
+ * between the two requests would be withdrawn by a decision taken before they
2097
+ * existed — the same race `updateCalendarEvent` already guards, and it costs
2098
+ * more here because a delete cannot be undone from this side. */
2099
+ async function propagateOneDelete(eventId, deps) {
2100
+ const decision = planDelete(await deps.fetchEvent(eventId));
2101
+ if (!decision.ok) {
2102
+ if (decision.kind === "already-gone") {
2103
+ await deps.forget(eventId);
2104
+ return {};
2105
+ }
2106
+ return { skipped: [deleteRefusalMessage(eventId, decision)] };
2107
+ }
2108
+ try {
2109
+ await deps.deleteEvent(eventId, decision.etag);
2110
+ } catch (error) {
2111
+ if (!isGoogleApiError(error)) throw error;
2112
+ if (EVENT_ABSENT_STATUSES.includes(error.status)) {
2113
+ await deps.forget(eventId);
2114
+ return {};
2115
+ }
2116
+ if (error.status !== 412) throw error;
2117
+ return { skipped: [staleDeleteMessage(eventId)] };
2118
+ }
2119
+ await deps.forget(eventId);
2120
+ log.info("google", "calendar event deleted by push", { id: eventId });
2121
+ return { deleted: [eventId] };
2122
+ }
2123
+ /** Carry every local deletion, when the collection opted in.
2124
+ *
2125
+ * Sequential rather than concurrent: each one writes the shared baseline file,
2126
+ * and an interrupted run has to leave a state the next run resumes from rather
2127
+ * than a partially applied batch. */
2128
+ async function sweepDeletes(eventIds, propagate, deps) {
2129
+ if (!propagate || eventIds.length === 0) return noDeletes(eventIds.length);
2130
+ const sweep = noDeletes(eventIds.length);
2131
+ for (const eventId of eventIds) try {
2132
+ const one = await propagateOneDelete(eventId, deps);
2133
+ sweep.deleted.push(...one.deleted ?? []);
2134
+ sweep.skipped.push(...one.skipped ?? []);
2135
+ } catch (error) {
2136
+ sweep.errors.push(`${eventId}: could not be deleted in Google — ${require_dist.errorMessage(error)}`);
2137
+ }
2138
+ return sweep;
2139
+ }
1996
2140
  /** Push one collection WITHOUT taking the calendar lock.
1997
2141
  *
1998
2142
  * The caller must already hold it. The scheduled push→pull cycle
@@ -2031,7 +2175,18 @@ async function pushCollectionNow(collection, workspaceRoot, deps = liveDeps) {
2031
2175
  });
2032
2176
  await saveCalendarShadow(calendarId, pushedShadow([outcome]), workspaceRoot);
2033
2177
  }
2034
- const result = tally(slug, attempts, locallyDeletedIds(shadow, records.map((record) => fieldText(record[schema.primaryKey]))).length);
2178
+ const result = tally(slug, attempts, await sweepDeletes(locallyDeletedIds(shadow, records.map((record) => fieldText(record[schema.primaryKey]))), schema.googleCalendar?.propagateDeletes === true, {
2179
+ fetchEvent: (eventId) => (deps.fetchEvent ?? getCalendarEvent)(accessToken, {
2180
+ calendarId,
2181
+ eventId
2182
+ }),
2183
+ deleteEvent: (eventId, ifMatch) => (deps.deleteEvent ?? deleteCalendarEvent)(accessToken, {
2184
+ calendarId,
2185
+ eventId,
2186
+ ifMatch
2187
+ }),
2188
+ forget: (eventId) => saveCalendarShadow(calendarId, { [eventId]: null }, workspaceRoot)
2189
+ }));
2035
2190
  if (result.errors.length > 0) log.warn("google", "calendar push finished with errors", {
2036
2191
  slug,
2037
2192
  errors: result.errors.length
@@ -2992,6 +3147,7 @@ async function deleteDriveFile(accessToken, input) {
2992
3147
  exports.ALL_DAY_END_HINT = ALL_DAY_END_HINT;
2993
3148
  exports.CANCELLED_EVENT_STATUS = CANCELLED_EVENT_STATUS;
2994
3149
  exports.DEFAULT_LIST_MAX_RESULTS = DEFAULT_LIST_MAX_RESULTS;
3150
+ exports.EVENT_ABSENT_STATUSES = EVENT_ABSENT_STATUSES;
2995
3151
  exports.EVENT_TIME_HINT = EVENT_TIME_HINT;
2996
3152
  exports.GOOGLE_AUTH_CANCELLED = GOOGLE_AUTH_CANCELLED;
2997
3153
  exports.GOOGLE_CALENDARLIST_SCOPE = GOOGLE_CALENDARLIST_SCOPE;
@@ -3042,6 +3198,7 @@ exports.collectCalendarPages = collectCalendarPages;
3042
3198
  exports.collectionsNeedingBackfill = collectionsNeedingBackfill;
3043
3199
  exports.commitLinkedTokens = commitLinkedTokens;
3044
3200
  exports.completeTask = completeTask;
3201
+ exports.conferenceVideoUri = conferenceVideoUri;
3045
3202
  exports.configureGoogleHost = configureGoogleHost;
3046
3203
  exports.conflictingFields = conflictingFields;
3047
3204
  exports.createCalendarEvent = createCalendarEvent;
@@ -3051,6 +3208,7 @@ exports.createTask = createTask;
3051
3208
  exports.deleteCalendarEvent = deleteCalendarEvent;
3052
3209
  exports.deleteDriveFile = deleteDriveFile;
3053
3210
  exports.deleteGoogleTokens = deleteGoogleTokens;
3211
+ exports.deleteRefusalMessage = deleteRefusalMessage;
3054
3212
  exports.deleteTask = deleteTask;
3055
3213
  exports.eventTimeHint = eventTimeHint;
3056
3214
  exports.fieldText = fieldText;
@@ -3099,6 +3257,7 @@ exports.mergeShadow = mergeShadow;
3099
3257
  exports.needsCalendarBackfill = needsCalendarBackfill;
3100
3258
  exports.orphanedCalendarId = orphanedCalendarId;
3101
3259
  exports.pickBoundary = pickBoundary;
3260
+ exports.planDelete = planDelete;
3102
3261
  exports.planRecord = planRecord;
3103
3262
  exports.pullProtectionFor = pullProtectionFor;
3104
3263
  exports.pullableEvents = pullableEvents;
@@ -3116,8 +3275,10 @@ exports.resumableToken = resumableToken;
3116
3275
  exports.saveCalendarShadow = saveCalendarShadow;
3117
3276
  exports.saveCalendarSyncToken = saveCalendarSyncToken;
3118
3277
  exports.saveGoogleTokens = saveGoogleTokens;
3278
+ exports.selfResponseStatus = selfResponseStatus;
3119
3279
  exports.shadowUpdates = shadowUpdates;
3120
3280
  exports.stateLockPath = stateLockPath;
3281
+ exports.sweepDeletes = sweepDeletes;
3121
3282
  exports.syncCalendarEvents = syncCalendarEvents;
3122
3283
  exports.syncCalendarForCollection = syncCalendarForCollection;
3123
3284
  exports.syncCalendarGroup = syncCalendarGroup;