@mulmoclaude/core 1.5.0 → 1.7.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 (69) hide show
  1. package/assets/helps/bug-report-faq.md +130 -0
  2. package/assets/helps/error-recovery.md +134 -1
  3. package/assets/helps/google-calendar-collection.md +57 -3
  4. package/assets/helps/google.md +3 -2
  5. package/assets/helps/index.md +2 -1
  6. package/dist/calendarGrid-CaS9er8i.cjs +576 -0
  7. package/dist/calendarGrid-CaS9er8i.cjs.map +1 -0
  8. package/dist/calendarGrid-CrGxeCOG.js +397 -0
  9. package/dist/calendarGrid-CrGxeCOG.js.map +1 -0
  10. package/dist/collection/index.cjs +44 -44
  11. package/dist/collection/index.cjs.map +1 -1
  12. package/dist/collection/index.js +2 -2
  13. package/dist/collection/registry/server/index.cjs +2 -2
  14. package/dist/collection/registry/server/index.js +2 -2
  15. package/dist/collection/server/index.cjs +4 -4
  16. package/dist/collection/server/index.js +3 -3
  17. package/dist/collection-watchers/index.cjs +5 -5
  18. package/dist/collection-watchers/index.cjs.map +1 -1
  19. package/dist/collection-watchers/index.js +4 -4
  20. package/dist/{discovery--kRUWed0.cjs → discovery-DYcR83qx.cjs} +64 -53
  21. package/dist/discovery-DYcR83qx.cjs.map +1 -0
  22. package/dist/{discovery-CKR4TOX3.js → discovery-_fjsluLx.js} +43 -32
  23. package/dist/discovery-_fjsluLx.js.map +1 -0
  24. package/dist/feeds/index.cjs +4 -4
  25. package/dist/feeds/index.js +2 -2
  26. package/dist/feeds/server/index.cjs +6 -6
  27. package/dist/feeds/server/index.js +4 -4
  28. package/dist/google/apiClient.d.ts +6 -0
  29. package/dist/google/calendar.d.ts +79 -8
  30. package/dist/google/calendarPushState.d.ts +17 -0
  31. package/dist/google/collectionPush.d.ts +64 -0
  32. package/dist/google/collectionSync.d.ts +10 -0
  33. package/dist/google/index.cjs +712 -36
  34. package/dist/google/index.cjs.map +1 -1
  35. package/dist/google/index.d.ts +7 -3
  36. package/dist/google/index.js +683 -37
  37. package/dist/google/index.js.map +1 -1
  38. package/dist/google/pushDateTime.d.ts +10 -0
  39. package/dist/google/pushPlan.d.ts +73 -0
  40. package/dist/{ingestTypes-xX8VpQqh.cjs → ingestTypes-D1GQdG8e.cjs} +3 -3
  41. package/dist/{ingestTypes-xX8VpQqh.cjs.map → ingestTypes-D1GQdG8e.cjs.map} +1 -1
  42. package/dist/{ingestTypes-L59cIGgX.js → ingestTypes-XReG-li7.js} +2 -2
  43. package/dist/{ingestTypes-L59cIGgX.js.map → ingestTypes-XReG-li7.js.map} +1 -1
  44. package/dist/{promptSafety-CQ5Un4wi.js → promptSafety-C--op_6a.js} +3 -269
  45. package/dist/promptSafety-C--op_6a.js.map +1 -0
  46. package/dist/{promptSafety-DfZYNtjK.cjs → promptSafety-FD8cQn3e.cjs} +8 -358
  47. package/dist/promptSafety-FD8cQn3e.cjs.map +1 -0
  48. package/dist/remote-host/index.cjs +8 -0
  49. package/dist/remote-host/index.cjs.map +1 -1
  50. package/dist/remote-host/index.d.ts +21 -0
  51. package/dist/remote-host/index.js +8 -1
  52. package/dist/remote-host/index.js.map +1 -1
  53. package/dist/{server-D_6FLygK.js → server-BlIrtGKl.js} +4 -4
  54. package/dist/{server-D_6FLygK.js.map → server-BlIrtGKl.js.map} +1 -1
  55. package/dist/{server-gEOeV_Nv.cjs → server-l4TZ5Lbf.cjs} +21 -21
  56. package/dist/{server-gEOeV_Nv.cjs.map → server-l4TZ5Lbf.cjs.map} +1 -1
  57. package/dist/workspace-setup/faq-entries.d.ts +24 -0
  58. package/dist/workspace-setup/index.d.ts +1 -0
  59. package/dist/workspace-setup/index.js +50 -1
  60. package/dist/workspace-setup/index.js.map +1 -1
  61. package/package.json +1 -1
  62. package/dist/discovery--kRUWed0.cjs.map +0 -1
  63. package/dist/discovery-CKR4TOX3.js.map +0 -1
  64. package/dist/ids-BnTh3Hl0.cjs +0 -226
  65. package/dist/ids-BnTh3Hl0.cjs.map +0 -1
  66. package/dist/ids-s4GfmE93.js +0 -131
  67. package/dist/ids-s4GfmE93.js.map +0 -1
  68. package/dist/promptSafety-CQ5Un4wi.js.map +0 -1
  69. package/dist/promptSafety-DfZYNtjK.cjs.map +0 -1
@@ -4,7 +4,8 @@ const require_dist = require("../dist-pWpC-b04.cjs");
4
4
  const require_text = require("../text-CaAh9ZSO.cjs");
5
5
  const require_atomic = require("../atomic-C_7YpMiM.cjs");
6
6
  const require_utils_fetch = require("../utils/fetch.cjs");
7
- const require_discovery = require("../discovery--kRUWed0.cjs");
7
+ const require_calendarGrid = require("../calendarGrid-CaS9er8i.cjs");
8
+ const require_discovery = require("../discovery-DYcR83qx.cjs");
8
9
  let node_fs = require("node:fs");
9
10
  let node_path = require("node:path");
10
11
  node_path = require_rolldown_runtime.__toESM(node_path, 1);
@@ -24,7 +25,7 @@ var log = require_discovery.createForwardingLogger(() => hostSlot.peek());
24
25
  //#endregion
25
26
  //#region src/google/datetime.ts
26
27
  var ISO_DATE_TIME_WITH_OFFSET_RE = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:Z|[+-](\d{2}):(\d{2}))$/;
27
- var FRACTIONAL_SECONDS_RE$1 = /\.\d+(?=Z|[+-])/;
28
+ var FRACTIONAL_SECONDS_RE$2 = /\.\d+(?=Z|[+-])/;
28
29
  var MAX_HOUR = 23;
29
30
  var MAX_MINUTE = 59;
30
31
  var MAX_SECOND = 59;
@@ -33,7 +34,7 @@ var isRealCalendarDate = (year, month, day) => {
33
34
  return roundTrip.getUTCFullYear() === year && roundTrip.getUTCMonth() === month - 1 && roundTrip.getUTCDate() === day;
34
35
  };
35
36
  var isIsoDateTimeWithOffset = (value) => {
36
- const match = ISO_DATE_TIME_WITH_OFFSET_RE.exec(value.replace(FRACTIONAL_SECONDS_RE$1, ""));
37
+ const match = ISO_DATE_TIME_WITH_OFFSET_RE.exec(value.replace(FRACTIONAL_SECONDS_RE$2, ""));
37
38
  if (!match) return false;
38
39
  const [year = 0, month = 0, day = 0, hour = 0, minute = 0, second = 0] = match.slice(1, 7).map(Number);
39
40
  const timeInRange = hour <= MAX_HOUR && minute <= MAX_MINUTE && second <= MAX_SECOND;
@@ -555,14 +556,15 @@ var isGoogleApiError = (value) => value instanceof GoogleApiError;
555
556
  /** 403 usually means the API is not enabled for the user's Cloud project —
556
557
  * name the API so the agent's recovery guidance can be specific. */
557
558
  var googleApiError = (apiLabel, status, body) => {
558
- return new GoogleApiError(status, `${apiLabel}: HTTP ${status}${status === HTTP_FORBIDDEN ? ` (is the ${apiLabel} enabled for the Cloud project?)` : ""}${body ? ` — ${require_text.truncate(body, ERROR_BODY_MAX_CHARS)}` : ""}`);
559
+ return new GoogleApiError(status, `${apiLabel}: HTTP ${status}${status === 403 ? ` (is the ${apiLabel} enabled for the Cloud project?)` : ""}${body ? ` — ${require_text.truncate(body, ERROR_BODY_MAX_CHARS)}` : ""}`);
559
560
  };
560
561
  async function googleRequest(apiLabel, accessToken, url, init = {}) {
561
- const { contentType = "application/json", expectText = false, ...rest } = init;
562
+ const { contentType = "application/json", expectText = false, extraHeaders, ...rest } = init;
562
563
  const response = await require_utils_fetch.fetchWithTimeout(url, {
563
564
  ...rest,
564
565
  timeoutMs: GOOGLE_API_TIMEOUT_MS,
565
566
  headers: {
567
+ ...extraHeaders,
566
568
  Authorization: `Bearer ${accessToken}`,
567
569
  "Content-Type": contentType
568
570
  }
@@ -591,6 +593,16 @@ var MAX_CALENDAR_LIST_PAGES = 40;
591
593
  var EVENT_SYNC_PAGE_SIZE = 2500;
592
594
  var MAX_EVENT_SYNC_PAGES = 200;
593
595
  var HTTP_GONE = 410;
596
+ var EVENT_ABSENT_STATUSES = [404, HTTP_GONE];
597
+ /** An `If-Match` write whose etag no longer matches — someone else changed the
598
+ * event since it was read. */
599
+ var HTTP_PRECONDITION_FAILED = 412;
600
+ /** An `insert` whose caller-supplied event id is already taken. */
601
+ var HTTP_CONFLICT = 409;
602
+ /** Google's status for an event that was deleted; a sync window reports it so
603
+ * consumers can remove their copy. Single-sourced — the pull and the push both
604
+ * branch on it. */
605
+ var CANCELLED_EVENT_STATUS = "cancelled";
594
606
  /** Resolve a declared calendarId to the one the API and the sync-token store
595
607
  * both address. `||` (not `??`) so an empty string also falls back instead of
596
608
  * building a malformed `/calendars//events` URL. Single-sourced because an
@@ -598,7 +610,7 @@ var HTTP_GONE = 410;
598
610
  * apart while they share a sync token silently loses events (#2184). */
599
611
  var canonicalCalendarId = (calendarId) => calendarId || DEFAULT_CALENDAR_ID;
600
612
  var eventsUrl = (calendarId) => `${CALENDAR_BASE_URL}/calendars/${encodeURIComponent(canonicalCalendarId(calendarId))}/events`;
601
- var eventTime = (value) => {
613
+ var eventTime$1 = (value) => {
602
614
  if (!isRecord(value)) return "";
603
615
  if (typeof value.dateTime === "string") return value.dateTime;
604
616
  if (typeof value.date === "string") return value.date;
@@ -609,8 +621,8 @@ var toEventSummary = (value) => {
609
621
  return {
610
622
  id: stringField(record, "id"),
611
623
  summary: stringField(record, "summary"),
612
- start: eventTime(record.start),
613
- end: eventTime(record.end),
624
+ start: eventTime$1(record.start),
625
+ end: eventTime$1(record.end),
614
626
  htmlLink: stringField(record, "htmlLink"),
615
627
  status: stringField(record, "status"),
616
628
  colorId: stringField(record, "colorId")
@@ -626,7 +638,8 @@ var toCalendarSummary = (value) => {
626
638
  accessRole: stringField(record, "accessRole"),
627
639
  backgroundColor: stringField(record, "backgroundColor"),
628
640
  foregroundColor: stringField(record, "foregroundColor"),
629
- colorId: stringField(record, "colorId")
641
+ colorId: stringField(record, "colorId"),
642
+ timeZone: stringField(record, "timeZone")
630
643
  };
631
644
  };
632
645
  var toColorMap = (value) => {
@@ -642,13 +655,21 @@ var toColorMap = (value) => {
642
655
  /** Kept as a named export for the existing unit tests / callers; the shared
643
656
  * helper now carries the wording. */
644
657
  var calendarApiError = (status, body) => googleApiError(CALENDAR_API_LABEL, status, body);
658
+ /** Normalise either spelling of a span into the pair the API body carries. */
659
+ var resolveEventSpan = (span) => "start" in span ? {
660
+ start: span.start,
661
+ end: span.end
662
+ } : {
663
+ start: { dateTime: span.startDateTime },
664
+ end: { dateTime: span.endDateTime }
665
+ };
645
666
  async function createCalendarEvent(accessToken, input) {
646
667
  const body = {
647
668
  summary: input.summary,
648
669
  description: input.description,
649
- start: { dateTime: input.startDateTime },
650
- end: { dateTime: input.endDateTime },
651
- ...input.colorId ? { colorId: input.colorId } : {}
670
+ ...resolveEventSpan(input),
671
+ ...input.colorId ? { colorId: input.colorId } : {},
672
+ ...input.eventId ? { id: input.eventId } : {}
652
673
  };
653
674
  return toEventSummary(await googleRequest(CALENDAR_API_LABEL, accessToken, eventsUrl(input.calendarId), {
654
675
  method: "POST",
@@ -661,11 +682,17 @@ var eventUrl = (calendarId, eventId) => `${eventsUrl(calendarId)}/${encodeURICom
661
682
  * `undefined` means "leave as is" and `""` means "clear it", so the two cannot
662
683
  * be collapsed: dropping `description: ""` would silently ignore a request to
663
684
  * empty the body text. Pure so the distinction is testable without network. */
685
+ /** One end of the patch: the structured value if given, else the flat one
686
+ * wrapped, else absent (= leave it alone). */
687
+ var eventEnd = (key, structured, flat) => {
688
+ if (structured !== void 0) return { [key]: structured };
689
+ return flat !== void 0 ? { [key]: { dateTime: flat } } : {};
690
+ };
664
691
  var buildEventPatch = (input) => ({
665
692
  ...input.summary !== void 0 ? { summary: input.summary } : {},
666
693
  ...input.description !== void 0 ? { description: input.description } : {},
667
- ...input.startDateTime !== void 0 ? { start: { dateTime: input.startDateTime } } : {},
668
- ...input.endDateTime !== void 0 ? { end: { dateTime: input.endDateTime } } : {},
694
+ ...eventEnd("start", input.start, input.startDateTime),
695
+ ...eventEnd("end", input.end, input.endDateTime),
669
696
  ...input.colorId ? { colorId: input.colorId } : {}
670
697
  });
671
698
  /** Edit an existing event in place. PATCH, not PUT — a PUT would need the whole
@@ -674,9 +701,28 @@ var buildEventPatch = (input) => ({
674
701
  async function updateCalendarEvent(accessToken, input) {
675
702
  return toEventSummary(await googleRequest(CALENDAR_API_LABEL, accessToken, eventUrl(input.calendarId, input.eventId), {
676
703
  method: "PATCH",
677
- body: JSON.stringify(buildEventPatch(input))
704
+ body: JSON.stringify(buildEventPatch(input)),
705
+ ...input.ifMatch ? { extraHeaders: { "If-Match": input.ifMatch } } : {}
678
706
  }));
679
707
  }
708
+ /** Read ONE event, or null when it is gone (404 / 410).
709
+ *
710
+ * A push needs Google's current value per changed record to tell a local-only
711
+ * edit from a both-sides conflict. Deliberately not `syncCalendarEvents`: that
712
+ * consumes the calendar's sync token, and the token's window is served once —
713
+ * spending it here would make the next pull miss everything in it. */
714
+ async function getCalendarEvent(accessToken, input) {
715
+ try {
716
+ const fetched = await googleRequest(CALENDAR_API_LABEL, accessToken, eventUrl(input.calendarId, input.eventId));
717
+ return {
718
+ event: toEventSummary(fetched),
719
+ etag: stringField(asRecord(fetched), "etag")
720
+ };
721
+ } catch (error) {
722
+ if (isGoogleApiError(error) && EVENT_ABSENT_STATUSES.includes(error.status)) return null;
723
+ throw error;
724
+ }
725
+ }
680
726
  /** Remove an event. Google answers 204 with no body, so there is nothing to
681
727
  * return; a second delete of the same id answers 410, which surfaces as a
682
728
  * GoogleApiError rather than being swallowed. */
@@ -756,6 +802,17 @@ async function collectCalendarPages(fetchPage, maxPages = MAX_CALENDAR_LIST_PAGE
756
802
  /** The calendars the user has added/subscribed to (primary + secondary +
757
803
  * shared), each with its id, name and colour, following pagination. Needs the
758
804
  * calendar-list read scope (GOOGLE_CALENDARLIST_SCOPE). */
805
+ /** One calendar's own resource, addressed by id rather than looked up in the
806
+ * user's list.
807
+ *
808
+ * `calendarList` only holds calendars the user has ADDED; a calendar shared
809
+ * with them can be readable and writable by id without appearing there. This is
810
+ * how a push learns such a calendar's `timeZone`. Note the resource carries no
811
+ * `accessRole` — that is a calendarList property — so reachability here says
812
+ * nothing about writability. */
813
+ async function getCalendar(accessToken, calendarId) {
814
+ return toCalendarSummary(await googleRequest(CALENDAR_API_LABEL, accessToken, `${CALENDAR_BASE_URL}/calendars/${encodeURIComponent(canonicalCalendarId(calendarId))}`));
815
+ }
759
816
  async function listCalendars(accessToken) {
760
817
  return collectCalendarPages(async (pageToken) => {
761
818
  const params = new URLSearchParams({ maxResults: String(CALENDAR_LIST_PAGE_SIZE) });
@@ -784,25 +841,25 @@ function calendarSyncStatePath(workspaceRoot) {
784
841
  return node_path.default.join(workspaceRoot ?? require_discovery.getWorkspaceRoot(), "data", "calendar", ".sync-state.json");
785
842
  }
786
843
  var calendarKey = canonicalCalendarId;
787
- async function readState(workspaceRoot) {
844
+ async function readState$1(workspaceRoot) {
788
845
  const stored = await readJsonOrNull(calendarSyncStatePath(workspaceRoot));
789
846
  return stored?.tokens ? stored : { tokens: {} };
790
847
  }
791
- var writeQueue = { tail: Promise.resolve() };
792
- async function updateState(mutate, workspaceRoot) {
793
- const run = writeQueue.tail.then(async () => {
794
- const state = await readState(workspaceRoot);
848
+ var writeQueue$1 = { tail: Promise.resolve() };
849
+ async function updateState$1(mutate, workspaceRoot) {
850
+ const run = writeQueue$1.tail.then(async () => {
851
+ const state = await readState$1(workspaceRoot);
795
852
  await writeJsonAtomicWithMode(calendarSyncStatePath(workspaceRoot), { tokens: mutate(state.tokens) }, SYNC_STATE_MODE);
796
853
  });
797
- writeQueue.tail = run.catch(() => void 0);
854
+ writeQueue$1.tail = run.catch(() => void 0);
798
855
  return await run;
799
856
  }
800
857
  async function loadCalendarSyncToken(calendarId, workspaceRoot) {
801
- return (await readState(workspaceRoot)).tokens[calendarKey(calendarId)] ?? null;
858
+ return (await readState$1(workspaceRoot)).tokens[calendarKey(calendarId)] ?? null;
802
859
  }
803
860
  async function saveCalendarSyncToken(calendarId, syncToken, workspaceRoot) {
804
861
  const stored = calendarKey(calendarId);
805
- await updateState((tokens) => ({
862
+ await updateState$1((tokens) => ({
806
863
  ...tokens,
807
864
  [stored]: syncToken
808
865
  }), workspaceRoot);
@@ -811,7 +868,60 @@ async function saveCalendarSyncToken(calendarId, syncToken, workspaceRoot) {
811
868
  * starts a clean full sync. */
812
869
  async function clearCalendarSyncToken(calendarId, workspaceRoot) {
813
870
  const dropped = calendarKey(calendarId);
814
- await updateState((tokens) => Object.fromEntries(Object.entries(tokens).filter(([key]) => key !== dropped)), workspaceRoot);
871
+ await updateState$1((tokens) => Object.fromEntries(Object.entries(tokens).filter(([key]) => key !== dropped)), workspaceRoot);
872
+ }
873
+ //#endregion
874
+ //#region src/google/calendarPushState.ts
875
+ var PUSH_STATE_MODE = 384;
876
+ /** `<workspace>/data/calendar/.push-state.json` */
877
+ function calendarPushStatePath(workspaceRoot) {
878
+ return node_path.default.join(workspaceRoot ?? require_discovery.getWorkspaceRoot(), "data", "calendar", ".push-state.json");
879
+ }
880
+ var toShadowEvent = (event) => ({
881
+ summary: event.summary,
882
+ start: event.start,
883
+ end: event.end,
884
+ colorId: event.colorId
885
+ });
886
+ async function readState(workspaceRoot) {
887
+ const stored = await readJsonOrNull(calendarPushStatePath(workspaceRoot));
888
+ return stored?.events ? stored : { events: {} };
889
+ }
890
+ var writeQueue = { tail: Promise.resolve() };
891
+ async function updateState(mutate, workspaceRoot) {
892
+ const run = writeQueue.tail.then(async () => {
893
+ const state = await readState(workspaceRoot);
894
+ await writeJsonAtomicWithMode(calendarPushStatePath(workspaceRoot), { events: mutate(state.events) }, PUSH_STATE_MODE);
895
+ });
896
+ writeQueue.tail = run.catch(() => void 0);
897
+ return await run;
898
+ }
899
+ async function loadCalendarShadow(calendarId, workspaceRoot) {
900
+ return (await readState(workspaceRoot)).events[canonicalCalendarId(calendarId)] ?? {};
901
+ }
902
+ /** Apply a batch to one calendar's baseline. `null` removes an entry (the event
903
+ * was cancelled), so a cancel-then-recreate cannot resume from a stale one.
904
+ * Merged rather than replaced: a sync window describes only what changed. */
905
+ function mergeShadow(current, updates) {
906
+ const removed = new Set(Object.entries(updates).filter(([, entry]) => entry === null).map(([eventId]) => eventId));
907
+ const kept = Object.entries(current).filter(([eventId]) => !removed.has(eventId));
908
+ const written = Object.entries(updates).filter((entry) => entry[1] !== null);
909
+ return Object.fromEntries([...kept, ...written]);
910
+ }
911
+ async function saveCalendarShadow(calendarId, updates, workspaceRoot) {
912
+ if (Object.keys(updates).length === 0) return;
913
+ const key = canonicalCalendarId(calendarId);
914
+ await updateState((events) => ({
915
+ ...events,
916
+ [key]: mergeShadow(events[key] ?? {}, updates)
917
+ }), workspaceRoot);
918
+ }
919
+ /** Drop one calendar's whole baseline. Called wherever the sync token is
920
+ * cleared: a baseline outliving its token would let a push diff against events
921
+ * the workspace no longer holds. */
922
+ async function clearCalendarShadow(calendarId, workspaceRoot) {
923
+ const dropped = canonicalCalendarId(calendarId);
924
+ await updateState((events) => Object.fromEntries(Object.entries(events).filter(([key]) => key !== dropped)), workspaceRoot);
815
925
  }
816
926
  //#endregion
817
927
  //#region src/google/collectionDateTime.ts
@@ -819,9 +929,9 @@ async function clearCalendarSyncToken(calendarId, workspaceRoot) {
819
929
  var ALL_DAY_CLOCK = "00:00";
820
930
  var DATE_ONLY_RE = /^\d{4}-\d{2}-\d{2}$/;
821
931
  /** RFC3339 zone designator: `Z`, `+09:00`, `-0500`. */
822
- var ZONE_SUFFIX_RE = /(?:Z|[+-]\d{2}:?\d{2})$/;
932
+ var ZONE_SUFFIX_RE$1 = /(?:Z|[+-]\d{2}:?\d{2})$/;
823
933
  /** Fractional seconds have no place in the collection datetime shape. */
824
- var FRACTIONAL_SECONDS_RE = /\.\d+$/;
934
+ var FRACTIONAL_SECONDS_RE$1 = /\.\d+$/;
825
935
  /** Normalise one Google Calendar time value for storage in a `datetime` field.
826
936
  * Anything that isn't a recognisable Google shape (empty string, non-string,
827
937
  * free text) is returned untouched — the record lint should report it rather
@@ -831,13 +941,12 @@ function toCollectionDateTime(value) {
831
941
  const trimmed = value.trim();
832
942
  if (DATE_ONLY_RE.test(trimmed)) return `${trimmed}T${ALL_DAY_CLOCK}`;
833
943
  if (!trimmed.includes("T")) return value;
834
- return trimmed.replace(ZONE_SUFFIX_RE, "").replace(FRACTIONAL_SECONDS_RE, "");
944
+ return trimmed.replace(ZONE_SUFFIX_RE$1, "").replace(FRACTIONAL_SECONDS_RE$1, "");
835
945
  }
836
946
  //#endregion
837
947
  //#region src/google/collectionSync.ts
838
948
  var GOOGLE_CALENDAR_SYNC_TASK_ID = "system:google-calendar-sync";
839
949
  var DEFAULT_SYNC_INTERVAL_MS = 3600 * 1e3;
840
- var CANCELLED_STATUS = "cancelled";
841
950
  var DATETIME_FIELD_TYPE = "datetime";
842
951
  /** Own-property lookup, mirroring what the record lint reads: a spec reachable
843
952
  * only through the prototype chain is not a DECLARED field, so it must not
@@ -893,7 +1002,7 @@ async function applyEvent(collection, event, workspaceRoot) {
893
1002
  kind: "unwritable",
894
1003
  message: `collection '${collection.slug}' is read-only`
895
1004
  };
896
- if (event.status === CANCELLED_STATUS) {
1005
+ if (event.status === "cancelled") {
897
1006
  const deleted = await store.delete(event.id);
898
1007
  return classifyDelete(event.id, deleted.kind);
899
1008
  }
@@ -909,6 +1018,7 @@ async function applyEvent(collection, event, workspaceRoot) {
909
1018
  }
910
1019
  async function restartFullSync(accessToken, calendarId, workspaceRoot) {
911
1020
  await clearCalendarSyncToken(calendarId, workspaceRoot);
1021
+ await clearCalendarShadow(calendarId, workspaceRoot);
912
1022
  return await syncCalendarEvents(accessToken, { calendarId });
913
1023
  }
914
1024
  /** Serialise `run` against whatever is already running for `key`.
@@ -946,8 +1056,16 @@ var calendarLocks = /* @__PURE__ */ new Map();
946
1056
  * writes are upserts by event id — but it is a wasted full walk. Queued, the
947
1057
  * second pass resumes from the token the first just stored and fetches only
948
1058
  * what is genuinely new. */
1059
+ /** Serialise anything that touches ONE calendar's sync state.
1060
+ *
1061
+ * Shared with the push path (#2598), not only the pull doors: a push that
1062
+ * overtakes an in-flight pull would record a baseline for events the pull is
1063
+ * still writing, so the next push would diff against a future it never saw. */
1064
+ async function withCalendarLock(calendarId, run) {
1065
+ return await withKeyedLock(calendarLocks, canonicalCalendarId(calendarId), run);
1066
+ }
949
1067
  async function syncCalendarGroup(calendarId, collections, workspaceRoot) {
950
- return await withKeyedLock(calendarLocks, canonicalCalendarId(calendarId), () => syncCalendarGroupNow(calendarId, collections, workspaceRoot));
1068
+ return await withCalendarLock(calendarId, () => syncCalendarGroupNow(calendarId, collections, workspaceRoot));
951
1069
  }
952
1070
  async function syncCalendarGroupNow(calendarId, collections, workspaceRoot) {
953
1071
  const accessToken = await getGoogleAccessToken();
@@ -971,9 +1089,15 @@ async function syncCalendarGroupNow(calendarId, collections, workspaceRoot) {
971
1089
  });
972
1090
  return results;
973
1091
  }
1092
+ await saveCalendarShadow(calendarId, shadowUpdates(result.events), workspaceRoot);
974
1093
  if (result.nextSyncToken) await advanceToken(calendarId, result.nextSyncToken, collections, workspaceRoot);
975
1094
  return results;
976
1095
  }
1096
+ /** The baseline this window establishes: what Google now says per event, and
1097
+ * `null` for a cancelled one so a recreate cannot resume from a dead baseline. */
1098
+ function shadowUpdates(events) {
1099
+ return Object.fromEntries(events.map((event) => [event.id, event.status === "cancelled" ? null : toShadowEvent(event)]));
1100
+ }
977
1101
  /** Save the window's token unless every collection that consumed it was deleted
978
1102
  * while the sync was in flight.
979
1103
  *
@@ -1065,6 +1189,7 @@ async function releaseOrphanedCalendarToken(deleted, workspaceRoot) {
1065
1189
  const orphaned = orphanedCalendarId(deleted, (await require_discovery.discoverCollections({ workspaceRoot })).map((collection) => collection.schema));
1066
1190
  if (orphaned === null) return null;
1067
1191
  await clearCalendarSyncToken(orphaned, workspaceRoot);
1192
+ await clearCalendarShadow(orphaned, workspaceRoot);
1068
1193
  log.info("google", "cleared the sync token of a calendar no collection reads any more", { calendarId: orphaned });
1069
1194
  return orphaned;
1070
1195
  } catch (error) {
@@ -1174,6 +1299,522 @@ function googleCalendarSyncTaskDef(opts) {
1174
1299
  };
1175
1300
  }
1176
1301
  //#endregion
1302
+ //#region src/google/pushDateTime.ts
1303
+ /** RFC3339 zone designator: `Z`, `+09:00`, `-0500`. */
1304
+ var ZONE_SUFFIX_RE = /(?:Z|[+-]\d{2}:?\d{2})$/;
1305
+ var FRACTIONAL_SECONDS_RE = /\.\d+$/;
1306
+ var TWO_DIGITS_RE = /^\d{2}$/;
1307
+ var CLOCK_SEPARATOR = ":";
1308
+ var DATE_TIME_SEPARATOR = "T";
1309
+ var MIN_CLOCK_SEGMENTS = 2;
1310
+ var MAX_CLOCK_SEGMENTS = 3;
1311
+ var WHOLE_MINUTE_SECONDS = "00";
1312
+ var MAX_SECONDS = 59;
1313
+ /** Whether `value` is a real calendar day (`2026-02-30` is not).
1314
+ *
1315
+ * Delegated to the parser the record lint, the calendar grid and the day view
1316
+ * already share, so the push agrees with the rest of the app on what a valid
1317
+ * stored date IS — and rejects an impossible one here instead of turning it
1318
+ * into an opaque Google 400. */
1319
+ var isCalendarDate = (value) => require_calendarGrid.parseIsoDate(value) !== null;
1320
+ /** A value Google already accepts verbatim, as the event time to send, or null.
1321
+ *
1322
+ * Reached when the schema maps `start` onto a `string` field: `projectValue`
1323
+ * normalises only `datetime` fields, so that column holds Google's own text,
1324
+ * zone and all. The civil part is still validated — matching a trailing `Z` is
1325
+ * not evidence that the rest is a date-time. */
1326
+ function rawGoogleTime(value) {
1327
+ if (isCalendarDate(value)) return { date: value };
1328
+ const zone = value.match(ZONE_SUFFIX_RE)?.[0];
1329
+ if (zone === void 0) return null;
1330
+ return require_calendarGrid.parseIsoDateTime(value.slice(0, value.length - zone.length).replace(FRACTIONAL_SECONDS_RE, "")) === null ? null : { dateTime: value };
1331
+ }
1332
+ /** Google's own all-day `end` is EXCLUSIVE — a single day on the 12th ends on
1333
+ * the 13th. The pull stores that as `2026-05-13T00:00` and this takes the date
1334
+ * part straight back, so the exclusive end round-trips untouched. Looks
1335
+ * off-by-one; adjusting it would shorten every all-day event by a day. */
1336
+ function allDayFrom(datePart) {
1337
+ return { date: datePart };
1338
+ }
1339
+ /** Split `HH:MM[:SS]` into RFC3339's always-three segments, or null.
1340
+ *
1341
+ * Split rather than one regex: expressing the optional seconds as `(?::(\d{2}))?`
1342
+ * nests a quantifier inside a quantified group, which the ReDoS lint rejects. */
1343
+ function clockSegments(clockPart) {
1344
+ const segments = clockPart.split(CLOCK_SEPARATOR);
1345
+ if (segments.length < MIN_CLOCK_SEGMENTS || segments.length > MAX_CLOCK_SEGMENTS) return null;
1346
+ if (!segments.every((segment) => TWO_DIGITS_RE.test(segment))) return null;
1347
+ const [hours, minutes, seconds] = segments;
1348
+ if (seconds !== void 0 && Number(seconds) > MAX_SECONDS) return null;
1349
+ return [
1350
+ hours,
1351
+ minutes,
1352
+ seconds ?? WHOLE_MINUTE_SECONDS
1353
+ ].join(CLOCK_SEPARATOR);
1354
+ }
1355
+ /** `YYYY-MM-DDTHH:MM[:SS]` → its date and its RFC3339 clock, or null.
1356
+ *
1357
+ * Validity comes from the shared strict parser, so an out-of-range clock or an
1358
+ * impossible day is refused here rather than sent to Google. */
1359
+ function parseStoredDateTime(value) {
1360
+ if (require_calendarGrid.parseIsoDateTime(value) === null) return null;
1361
+ const [datePart, clockPart, ...extra] = value.split(DATE_TIME_SEPARATOR);
1362
+ if (datePart === void 0 || clockPart === void 0 || extra.length > 0) return null;
1363
+ const clock = clockSegments(clockPart);
1364
+ return clock === null ? null : {
1365
+ datePart,
1366
+ clock
1367
+ };
1368
+ }
1369
+ /** The zone designator to re-attach, or null when `previous` carries none. */
1370
+ function zoneSuffixOf(previous) {
1371
+ if (previous === void 0) return null;
1372
+ return previous.match(ZONE_SUFFIX_RE)?.[0] ?? null;
1373
+ }
1374
+ /** Build the event time to send for one field.
1375
+ *
1376
+ * `previous` is the value Google last reported for this event, absent for a
1377
+ * locally-created record. Returns null when `local` is neither a stored
1378
+ * datetime nor a raw Google value — the caller reports that record rather than
1379
+ * letting this invent a time Google would silently accept. */
1380
+ function toGoogleEventTime(local, previous, calendarTimeZone) {
1381
+ if (typeof local !== "string") return null;
1382
+ const trimmed = local.trim();
1383
+ if (trimmed.length === 0) return null;
1384
+ const raw = rawGoogleTime(trimmed);
1385
+ if (raw !== null) return raw;
1386
+ const parts = parseStoredDateTime(trimmed);
1387
+ if (parts === null) return null;
1388
+ if (previous !== void 0 && isCalendarDate(previous)) return allDayFrom(parts.datePart);
1389
+ const zone = zoneSuffixOf(previous);
1390
+ const dateTime = `${parts.datePart}${DATE_TIME_SEPARATOR}${parts.clock}`;
1391
+ return zone === null ? {
1392
+ dateTime,
1393
+ timeZone: calendarTimeZone
1394
+ } : { dateTime: `${dateTime}${zone}` };
1395
+ }
1396
+ //#endregion
1397
+ //#region src/google/pushPlan.ts
1398
+ /** The event fields Google lets a caller write. `htmlLink` and `status` are
1399
+ * read-only, so a record column mapped to either is ignored here rather than
1400
+ * rejected — the mapping was authored for the pull, and a push has no business
1401
+ * invalidating it. */
1402
+ var PUSHABLE_SOURCE_FIELDS = [
1403
+ "summary",
1404
+ "start",
1405
+ "end",
1406
+ "colorId"
1407
+ ];
1408
+ var isPushableSource = (source) => PUSHABLE_SOURCE_FIELDS.includes(source);
1409
+ /** The subset of a schema's `map` a push can act on: collection field → event field. */
1410
+ function pushableMap(map) {
1411
+ return Object.fromEntries(Object.entries(map).filter((entry) => isPushableSource(entry[1])));
1412
+ }
1413
+ /** Event field → the record column feeding it. The schema authors the map the
1414
+ * other way round (for the pull); a push looks up by event field. Two columns
1415
+ * mapped to one event field is already ambiguous for the pull — last wins. */
1416
+ function bySourceField(map) {
1417
+ return Object.fromEntries(Object.entries(map).map(([field, source]) => [source, field]));
1418
+ }
1419
+ /** Google accepts a caller-chosen event id of 5-1024 base32hex characters. A
1420
+ * record id outside that (a semantic id like `team-standup`) cannot be used,
1421
+ * and v1 reports it instead of re-keying the record — see the plan. */
1422
+ var CLIENT_EVENT_ID_RE = /^[0-9a-v]{5,1024}$/;
1423
+ var isClientSettableEventId = (eventId) => CLIENT_EVENT_ID_RE.test(eventId);
1424
+ /** A field's value as comparable/sendable text.
1425
+ *
1426
+ * Absent, null and `""` all collapse to `""`: Google reports an inherited colour
1427
+ * as `""` while the record file may omit the key entirely, and treating those as
1428
+ * different would report a local edit on every single push.
1429
+ *
1430
+ * A non-primitive is JSON-encoded rather than left to `String`, whose
1431
+ * `[object Object]` would make two DIFFERENT objects compare equal — a
1432
+ * silently-skipped edit. */
1433
+ var fieldText = (value) => {
1434
+ if (value === void 0 || value === null) return "";
1435
+ if (typeof value === "string") return value;
1436
+ if (typeof value === "number" || typeof value === "boolean" || typeof value === "bigint") return String(value);
1437
+ return JSON.stringify(value) ?? "";
1438
+ };
1439
+ /** A stored datetime with no seconds — what the collection's own date-time input
1440
+ * produces. Google's values always carry seconds, so the pull writes them and a
1441
+ * hand-typed value does not. */
1442
+ var SECONDLESS_DATETIME_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}$/;
1443
+ var WHOLE_MINUTE = ":00";
1444
+ /** Field text with `…T09:30` and `…T09:30:00` collapsed to one spelling.
1445
+ *
1446
+ * Without this, a record the user typed a time into never stops looking edited:
1447
+ * the push writes it, Google answers with seconds, the baseline projection keeps
1448
+ * them, and the seconds-less record then differs from its own baseline on every
1449
+ * later push — re-PATCHing the same event on every click, forever.
1450
+ *
1451
+ * A value that carries a zone offset (a `start` mapped onto a `string` field) is
1452
+ * left alone: it is Google's own text on both sides already. */
1453
+ var comparableText = (value) => {
1454
+ const text = fieldText(value);
1455
+ return SECONDLESS_DATETIME_RE.test(text) ? `${text}${WHOLE_MINUTE}` : text;
1456
+ };
1457
+ /** A baseline shaped as the event it came from, so the comparison can run
1458
+ * through the very projection that wrote the record. `htmlLink`/`status` are
1459
+ * never pushable, so their absence from the baseline cannot matter. */
1460
+ var asEventSummary = (eventId, shadow) => ({
1461
+ id: eventId,
1462
+ htmlLink: "",
1463
+ status: "",
1464
+ ...shadow
1465
+ });
1466
+ /** What the record WOULD hold if nobody had edited it since the baseline.
1467
+ *
1468
+ * Deliberately `toCollectionRecord` — the same function the pull writes with —
1469
+ * so "unchanged" here means exactly "the pull would produce this". A private
1470
+ * re-implementation would drift from it and report phantom edits. */
1471
+ function baselineRecord(eventId, shadow, map, primaryKey, fields) {
1472
+ return toCollectionRecord(asEventSummary(eventId, shadow), map, primaryKey, fields);
1473
+ }
1474
+ /** The mapped fields whose local value no longer matches the baseline. */
1475
+ function locallyChangedFields(record, baseline, map) {
1476
+ return Object.entries(map).filter(([field]) => comparableText(record[field]) !== comparableText(baseline[field])).map(([, source]) => source);
1477
+ }
1478
+ /** The fields Google changed since the baseline, restricted to the ones the
1479
+ * local edit touches.
1480
+ *
1481
+ * Field-level on purpose: Google moving an event the user retitled locally is
1482
+ * not a conflict, because the patch only carries the title. Comparing whole
1483
+ * events would refuse those pushes for no reason. */
1484
+ function conflictingFields(shadow, current, changed) {
1485
+ return changed.filter((source) => comparableText(shadow[source]) !== comparableText(current[source]));
1486
+ }
1487
+ /** Classify one record against its baseline, before Google is consulted.
1488
+ *
1489
+ * No baseline means the record never came from a sync, so it is local-only and
1490
+ * has to be created. Google is queried ONLY for a `changed` record, which is
1491
+ * what keeps a push over a large collection cheap. */
1492
+ function planRecord(eventId, record, shadow, map, primaryKey, fields) {
1493
+ if (shadow === void 0) return { kind: "create" };
1494
+ const changed = locallyChangedFields(record, baselineRecord(eventId, shadow, map, primaryKey, fields), map);
1495
+ return changed.length === 0 ? { kind: "unchanged" } : {
1496
+ kind: "changed",
1497
+ fields: changed
1498
+ };
1499
+ }
1500
+ /** Whether an event that already holds this record's id may be adopted as its
1501
+ * baseline.
1502
+ *
1503
+ * A 409 from `insert` usually means a previous push created the event but its
1504
+ * baseline never landed. It can ALSO mean an unrelated event happens to hold
1505
+ * this id, and adopting that would bind the record to a stranger's event — so
1506
+ * adoption needs evidence the two are the same logical event, not just the same
1507
+ * id (Codex review).
1508
+ *
1509
+ * The evidence is that the remote event ALREADY equals what this record would
1510
+ * write. Adoption then records a baseline and writes nothing, so it cannot
1511
+ * modify whatever that event is. Anything else is refused. */
1512
+ function mayAdoptExisting(eventId, record, existing, map, primaryKey, fields) {
1513
+ return locallyChangedFields(record, baselineRecord(eventId, existing, map, primaryKey, fields), map).length === 0;
1514
+ }
1515
+ /** Record ids the baseline knows but the collection no longer holds — deleted
1516
+ * locally. v1 reports the count and pushes nothing: `calendarDeleteEvent`
1517
+ * removes the event for every attendee and cannot be undone. */
1518
+ function locallyDeletedIds(shadow, presentIds) {
1519
+ const present = new Set(presentIds);
1520
+ return Object.keys(shadow).filter((eventId) => !present.has(eventId));
1521
+ }
1522
+ //#endregion
1523
+ //#region src/google/collectionPush.ts
1524
+ /** Access roles that may write events. `reader` / `freeBusyReader` cannot, and a
1525
+ * push under them fails per event with an opaque 403 — checked once up front so
1526
+ * the user is told the real reason. */
1527
+ var WRITABLE_ACCESS_ROLES = ["owner", "writer"];
1528
+ /** Whether to refuse the whole push before touching a single event.
1529
+ *
1530
+ * Only on POSITIVE evidence of a non-writable role. An unlisted calendar is
1531
+ * unknown and must fall through: hard-denying it would block the feature
1532
+ * outright for a calendar the user can in fact write to. Those attempts surface
1533
+ * Google's own 403 per record if the write really is not allowed. */
1534
+ var isDeniedAccessRole = (accessRole) => accessRole !== null && !WRITABLE_ACCESS_ROLES.includes(accessRole);
1535
+ /** Resolve the calendar a schema names, for its writability and its timezone.
1536
+ *
1537
+ * The list is consulted first because only it reports `accessRole`, which is
1538
+ * what lets a read-only calendar be refused with the real reason instead of an
1539
+ * opaque per-event 403. `"primary"` is matched on the `primary` flag, not the
1540
+ * id — the primary calendar's own id is the account's email address, which never
1541
+ * equals the literal the schema declares.
1542
+ *
1543
+ * A calendar absent from the list is NOT denied: it is fetched by id for its
1544
+ * timezone and its role left unknown. A 404 there means the calendar really is
1545
+ * unreachable, and propagates as a `failed` outcome. */
1546
+ async function liveCalendarMeta(accessToken, calendarId) {
1547
+ const key = canonicalCalendarId(calendarId);
1548
+ const calendars = await listCalendars(accessToken);
1549
+ const listed = key === "primary" ? calendars.find((calendar) => calendar.primary) : calendars.find((calendar) => calendar.id === key);
1550
+ if (listed) return {
1551
+ accessRole: listed.accessRole,
1552
+ timeZone: listed.timeZone
1553
+ };
1554
+ const direct = await getCalendar(accessToken, calendarId);
1555
+ log.info("google", "calendar is not in the user's list — pushing without an up-front role check", { calendarId: key });
1556
+ return {
1557
+ accessRole: null,
1558
+ timeZone: direct.timeZone
1559
+ };
1560
+ }
1561
+ async function findCalendarCollection(slug, workspaceRoot) {
1562
+ return (await require_discovery.discoverCollections({ workspaceRoot })).find((collection) => collection.slug === slug && collection.schema.googleCalendar) ?? null;
1563
+ }
1564
+ var liveDeps = {
1565
+ findCollection: findCalendarCollection,
1566
+ isLinked: async () => Boolean((await loadGoogleTokens())?.refresh_token),
1567
+ accessToken: getGoogleAccessToken,
1568
+ calendarMeta: liveCalendarMeta
1569
+ };
1570
+ var localValue = (ctx, record, source) => {
1571
+ const field = ctx.bySource[source];
1572
+ return field === void 0 ? void 0 : record[field];
1573
+ };
1574
+ /** One end of the span, rebuilt from the stored clock plus whatever the baseline
1575
+ * remembers about the original (zone, all-day). */
1576
+ function eventTime(ctx, record, source, previous) {
1577
+ const value = localValue(ctx, record, source);
1578
+ if (value === void 0) return {
1579
+ ok: false,
1580
+ reason: `no \`${source}\` field is mapped`
1581
+ };
1582
+ const time = toGoogleEventTime(value, previous, ctx.timeZone);
1583
+ if (time === null) return {
1584
+ ok: false,
1585
+ reason: `\`${source}\` is not a date-time Google accepts (got ${JSON.stringify(value)})`
1586
+ };
1587
+ if ("dateTime" in time && time.timeZone === "") return {
1588
+ ok: false,
1589
+ reason: `the calendar reports no timezone, so \`${source}\` cannot be sent without one`
1590
+ };
1591
+ return {
1592
+ ok: true,
1593
+ time
1594
+ };
1595
+ }
1596
+ async function createFromRecord(ctx, eventId, record) {
1597
+ if (!isClientSettableEventId(eventId)) return {
1598
+ kind: "skipped",
1599
+ message: `${eventId}: the record id cannot be used as a Google event id (needs 5-1024 characters from 0-9a-v)`
1600
+ };
1601
+ const start = eventTime(ctx, record, "start", void 0);
1602
+ const end = eventTime(ctx, record, "end", void 0);
1603
+ if (!start.ok) return {
1604
+ kind: "skipped",
1605
+ message: `${eventId}: ${start.reason}`
1606
+ };
1607
+ if (!end.ok) return {
1608
+ kind: "skipped",
1609
+ message: `${eventId}: ${end.reason}`
1610
+ };
1611
+ const colorId = fieldText(localValue(ctx, record, "colorId"));
1612
+ return {
1613
+ kind: "created",
1614
+ event: await createCalendarEvent(ctx.accessToken, {
1615
+ eventId,
1616
+ calendarId: ctx.calendarId,
1617
+ summary: fieldText(localValue(ctx, record, "summary")),
1618
+ start: start.time,
1619
+ end: end.time,
1620
+ ...colorId ? { colorId } : {}
1621
+ })
1622
+ };
1623
+ }
1624
+ /** Only the fields the local edit touched — PATCH semantics, so an untouched
1625
+ * attendee list / recurrence rule is left alone. */
1626
+ function buildPatch(ctx, record, changed, shadow) {
1627
+ const resolved = ["start", "end"].filter((source) => changed.includes(source)).map((source) => ({
1628
+ source,
1629
+ result: eventTime(ctx, record, source, shadow[source])
1630
+ }));
1631
+ const [failure] = resolved.flatMap((entry) => entry.result.ok ? [] : [entry.result.reason]);
1632
+ if (failure !== void 0) return {
1633
+ ok: false,
1634
+ reason: failure
1635
+ };
1636
+ const colorId = fieldText(localValue(ctx, record, "colorId"));
1637
+ if (changed.includes("colorId") && colorId === "") return {
1638
+ ok: false,
1639
+ reason: "Google cannot clear an event colour once set"
1640
+ };
1641
+ return {
1642
+ ok: true,
1643
+ patch: {
1644
+ ...changed.includes("summary") ? { summary: fieldText(localValue(ctx, record, "summary")) } : {},
1645
+ ...changed.includes("colorId") ? { colorId } : {},
1646
+ ...Object.fromEntries(resolved.flatMap((entry) => entry.result.ok ? [[entry.source, entry.result.time]] : []))
1647
+ }
1648
+ };
1649
+ }
1650
+ async function updateFromRecord(ctx, eventId, record, changed, shadow) {
1651
+ const fetched = await getCalendarEvent(ctx.accessToken, {
1652
+ calendarId: ctx.calendarId,
1653
+ eventId
1654
+ });
1655
+ if (fetched === null || fetched.event.status === "cancelled") return {
1656
+ kind: "skipped",
1657
+ message: `${eventId}: the event no longer exists in Google — sync first, then re-create it`
1658
+ };
1659
+ if (conflictingFields(shadow, toShadowEvent(fetched.event), changed).length > 0) return { kind: "conflict" };
1660
+ const built = buildPatch(ctx, record, changed, shadow);
1661
+ if (!built.ok) return {
1662
+ kind: "skipped",
1663
+ message: `${eventId}: ${built.reason}`
1664
+ };
1665
+ try {
1666
+ return {
1667
+ kind: "updated",
1668
+ event: await updateCalendarEvent(ctx.accessToken, {
1669
+ eventId,
1670
+ calendarId: ctx.calendarId,
1671
+ ifMatch: fetched.etag,
1672
+ ...built.patch
1673
+ })
1674
+ };
1675
+ } catch (error) {
1676
+ if (isGoogleApiError(error) && error.status === 412) return { kind: "conflict" };
1677
+ throw error;
1678
+ }
1679
+ }
1680
+ /** Create, recovering from the one 409 that is provably safe to recover from.
1681
+ *
1682
+ * A 409 means the id is taken, which is USUALLY a previous push whose baseline
1683
+ * never landed. It can also be an unrelated event that happens to hold this id,
1684
+ * so the recovery never writes: it adopts the baseline only when the remote
1685
+ * event already equals what this record would write, and otherwise reports the
1686
+ * fix. Patching whatever answered the 409 would let a push modify a stranger's
1687
+ * event (Codex review).
1688
+ *
1689
+ * Refusal is not a dead end — a Sync writes both the record and its baseline,
1690
+ * after which the record pushes normally. */
1691
+ async function createOrAdopt(ctx, eventId, record) {
1692
+ try {
1693
+ return await createFromRecord(ctx, eventId, record);
1694
+ } catch (error) {
1695
+ if (!isGoogleApiError(error) || error.status !== 409) throw error;
1696
+ const fetched = await getCalendarEvent(ctx.accessToken, {
1697
+ calendarId: ctx.calendarId,
1698
+ eventId
1699
+ });
1700
+ if (fetched === null) throw error;
1701
+ if (mayAdoptExisting(eventId, record, toShadowEvent(fetched.event), ctx.map, ctx.primaryKey, ctx.fields)) return {
1702
+ kind: "unchanged",
1703
+ event: fetched.event
1704
+ };
1705
+ return {
1706
+ kind: "skipped",
1707
+ message: `${eventId}: an event with this id already exists in Google and differs from this record — press Sync to adopt it, then push again`
1708
+ };
1709
+ }
1710
+ }
1711
+ /** A 403 on a write is a permissions answer, but `googleApiError` appends the
1712
+ * "is the API enabled for the Cloud project?" hint to every 403 — true for a
1713
+ * disabled API, misleading here. Reached when the up-front role check could not
1714
+ * run because the calendar is not in the user's list, which is exactly when the
1715
+ * user needs the real reason. */
1716
+ function writeFailure(eventId, error) {
1717
+ if (isGoogleApiError(error) && error.status === 403) return {
1718
+ kind: "skipped",
1719
+ message: `${eventId}: Google refused the write — you may not have permission to change events on this calendar`
1720
+ };
1721
+ return {
1722
+ kind: "error",
1723
+ message: `${eventId}: ${String(error)}`
1724
+ };
1725
+ }
1726
+ async function pushRecord(ctx, eventId, record) {
1727
+ try {
1728
+ const shadow = ctx.shadow[eventId];
1729
+ const plan = planRecord(eventId, record, shadow, ctx.map, ctx.primaryKey, ctx.fields);
1730
+ if (plan.kind === "unchanged") return { kind: "unchanged" };
1731
+ if (plan.kind === "create" || shadow === void 0) return await createOrAdopt(ctx, eventId, record);
1732
+ return await updateFromRecord(ctx, eventId, record, plan.fields, shadow);
1733
+ } catch (error) {
1734
+ return writeFailure(eventId, error);
1735
+ }
1736
+ }
1737
+ /** The baseline an outcome establishes, so the next push sees the record as
1738
+ * unchanged instead of pushing it again. Empty for anything that wrote nothing
1739
+ * and had no baseline to repair. */
1740
+ function pushedShadow(outcomes) {
1741
+ const written = outcomes.flatMap((outcome) => "event" in outcome && outcome.event !== void 0 ? [outcome.event] : []);
1742
+ return Object.fromEntries(written.map((event) => [event.id, toShadowEvent(event)]));
1743
+ }
1744
+ function tally(slug, outcomes, localDeletes) {
1745
+ const count = (kind) => outcomes.filter((outcome) => outcome.kind === kind).length;
1746
+ return {
1747
+ slug,
1748
+ created: count("created"),
1749
+ updated: count("updated"),
1750
+ conflicts: count("conflict"),
1751
+ localDeletes,
1752
+ skipped: outcomes.flatMap((outcome) => outcome.kind === "skipped" ? [outcome.message] : []),
1753
+ errors: outcomes.flatMap((outcome) => outcome.kind === "error" ? [outcome.message] : [])
1754
+ };
1755
+ }
1756
+ async function pushNow(collection, workspaceRoot, deps) {
1757
+ const { schema, slug } = collection;
1758
+ const calendarId = schema.googleCalendar?.calendarId;
1759
+ const accessToken = await deps.accessToken();
1760
+ const meta = await deps.calendarMeta(accessToken, calendarId);
1761
+ if (isDeniedAccessRole(meta.accessRole)) return {
1762
+ kind: "read-only",
1763
+ accessRole: meta.accessRole ?? ""
1764
+ };
1765
+ const map = pushableMap(schema.googleCalendar?.map ?? {});
1766
+ const shadow = await loadCalendarShadow(calendarId, workspaceRoot);
1767
+ const records = await require_discovery.storeFor(collection, { workspaceRoot }).list();
1768
+ const ctx = {
1769
+ accessToken,
1770
+ calendarId,
1771
+ timeZone: meta.timeZone,
1772
+ map,
1773
+ bySource: bySourceField(map),
1774
+ primaryKey: schema.primaryKey,
1775
+ fields: schema.fields,
1776
+ shadow
1777
+ };
1778
+ const outcomes = [];
1779
+ for (const record of records) {
1780
+ const outcome = await pushRecord(ctx, fieldText(record[schema.primaryKey]), record);
1781
+ outcomes.push(outcome);
1782
+ await saveCalendarShadow(calendarId, pushedShadow([outcome]), workspaceRoot);
1783
+ }
1784
+ const result = tally(slug, outcomes, locallyDeletedIds(shadow, records.map((record) => fieldText(record[schema.primaryKey]))).length);
1785
+ if (result.errors.length > 0) log.warn("google", "calendar push finished with errors", {
1786
+ slug,
1787
+ errors: result.errors.length
1788
+ });
1789
+ return {
1790
+ kind: "pushed",
1791
+ result
1792
+ };
1793
+ }
1794
+ /** Push ONE collection's records to the calendar it declares.
1795
+ *
1796
+ * "Does this collection sync at all" is answered before "is Google linked", for
1797
+ * the reason `syncCalendarForCollection` gives: telling someone to link their
1798
+ * account for a collection that never declared a calendar sends them fixing the
1799
+ * wrong thing. */
1800
+ async function pushCalendarForCollection(slug, workspaceRoot, deps = liveDeps) {
1801
+ try {
1802
+ const collection = await deps.findCollection(slug, workspaceRoot);
1803
+ if (collection === null) return { kind: "not-a-calendar" };
1804
+ if (!await deps.isLinked()) return { kind: "not-linked" };
1805
+ return await withCalendarLock(collection.schema.googleCalendar?.calendarId, () => pushNow(collection, workspaceRoot, deps));
1806
+ } catch (error) {
1807
+ log.warn("google", "calendar push could not start", {
1808
+ slug,
1809
+ error: String(error)
1810
+ });
1811
+ return {
1812
+ kind: "failed",
1813
+ message: String(error)
1814
+ };
1815
+ }
1816
+ }
1817
+ //#endregion
1177
1818
  //#region src/google/tasks.ts
1178
1819
  var TASKS_BASE_URL = "https://tasks.googleapis.com/tasks/v1";
1179
1820
  var TASKS_API_LABEL = "Google Tasks API";
@@ -1241,12 +1882,20 @@ async function updateTask(accessToken, input) {
1241
1882
  body: JSON.stringify(buildTaskPatch(input))
1242
1883
  }));
1243
1884
  }
1244
- async function completeTask(accessToken, input) {
1885
+ /** Shared body of the two status transitions. PATCH keeps the rest of the task
1886
+ * intact — a PUT would need the full body and would silently drop fields the
1887
+ * caller never read. Private on purpose: the exported `completeTask` /
1888
+ * `uncompleteTask` names are the API, so no caller can invent a third target
1889
+ * state by passing an arbitrary status through. */
1890
+ async function patchTaskStatus(accessToken, input, status) {
1245
1891
  return toTaskSummary(await googleRequest(TASKS_API_LABEL, accessToken, tasksUrl(input.taskListId, `/${encodeURIComponent(input.taskId)}`), {
1246
1892
  method: "PATCH",
1247
- body: JSON.stringify({ status: TASK_STATUS_COMPLETED })
1893
+ body: JSON.stringify({ status })
1248
1894
  }));
1249
1895
  }
1896
+ async function completeTask(accessToken, input) {
1897
+ return patchTaskStatus(accessToken, input, TASK_STATUS_COMPLETED);
1898
+ }
1250
1899
  /** Send a completed task back to the to-do list.
1251
1900
  *
1252
1901
  * Its own function rather than a flag on `completeTask`, and deliberately not
@@ -1261,10 +1910,7 @@ async function completeTask(accessToken, input) {
1261
1910
  * reopened task ever displays a completion date in Google's own UI, this is
1262
1911
  * the place to add `completed: null` to the patch. */
1263
1912
  async function uncompleteTask(accessToken, input) {
1264
- return toTaskSummary(await googleRequest(TASKS_API_LABEL, accessToken, tasksUrl(input.taskListId, `/${encodeURIComponent(input.taskId)}`), {
1265
- method: "PATCH",
1266
- body: JSON.stringify({ status: TASK_STATUS_NEEDS_ACTION })
1267
- }));
1913
+ return patchTaskStatus(accessToken, input, TASK_STATUS_NEEDS_ACTION);
1268
1914
  }
1269
1915
  async function deleteTask(accessToken, input) {
1270
1916
  await googleRequest(TASKS_API_LABEL, accessToken, tasksUrl(input.taskListId, `/${encodeURIComponent(input.taskId)}`), { method: "DELETE" });
@@ -1363,6 +2009,7 @@ async function deleteDriveFile(accessToken, input) {
1363
2009
  await googleRequest(DRIVE_API_LABEL, accessToken, `${DRIVE_FILES_URL}/${encodeURIComponent(input.fileId)}`, { method: "DELETE" });
1364
2010
  }
1365
2011
  //#endregion
2012
+ exports.CANCELLED_EVENT_STATUS = CANCELLED_EVENT_STATUS;
1366
2013
  exports.DEFAULT_LIST_MAX_RESULTS = DEFAULT_LIST_MAX_RESULTS;
1367
2014
  exports.GOOGLE_AUTH_CANCELLED = GOOGLE_AUTH_CANCELLED;
1368
2015
  exports.GOOGLE_CALENDARLIST_SCOPE = GOOGLE_CALENDARLIST_SCOPE;
@@ -1372,10 +2019,15 @@ exports.GOOGLE_DRIVE_FILE_SCOPE = GOOGLE_DRIVE_FILE_SCOPE;
1372
2019
  exports.GOOGLE_SCOPES = GOOGLE_SCOPES;
1373
2020
  exports.GOOGLE_TASKS_SCOPE = GOOGLE_TASKS_SCOPE;
1374
2021
  exports.GoogleApiError = GoogleApiError;
2022
+ exports.HTTP_CONFLICT = HTTP_CONFLICT;
2023
+ exports.HTTP_FORBIDDEN = HTTP_FORBIDDEN;
2024
+ exports.HTTP_PRECONDITION_FAILED = HTTP_PRECONDITION_FAILED;
1375
2025
  exports.MAX_LIST_RESULTS = MAX_LIST_RESULTS;
2026
+ exports.PUSHABLE_SOURCE_FIELDS = PUSHABLE_SOURCE_FIELDS;
1376
2027
  exports.anySyncedCollectionSurvives = anySyncedCollectionSurvives;
1377
2028
  exports.assertSafeMimeType = assertSafeMimeType;
1378
2029
  exports.authorizeGoogle = authorizeGoogle;
2030
+ exports.baselineRecord = baselineRecord;
1379
2031
  exports.brokerBaseUrl = brokerBaseUrl;
1380
2032
  exports.brokerExchange = brokerExchange;
1381
2033
  exports.brokerRefresh = brokerRefresh;
@@ -1383,18 +2035,22 @@ exports.brokerStart = brokerStart;
1383
2035
  exports.buildEventPatch = buildEventPatch;
1384
2036
  exports.buildMultipartBody = buildMultipartBody;
1385
2037
  exports.buildTaskPatch = buildTaskPatch;
2038
+ exports.bySourceField = bySourceField;
1386
2039
  exports.calendarApiError = calendarApiError;
2040
+ exports.calendarPushStatePath = calendarPushStatePath;
1387
2041
  exports.calendarSyncStatePath = calendarSyncStatePath;
1388
2042
  exports.canonicalCalendarId = canonicalCalendarId;
1389
2043
  exports.canonicalTaskListId = canonicalTaskListId;
1390
2044
  exports.classifyDelete = classifyDelete;
1391
2045
  exports.classifyWrite = classifyWrite;
2046
+ exports.clearCalendarShadow = clearCalendarShadow;
1392
2047
  exports.clearCalendarSyncToken = clearCalendarSyncToken;
1393
2048
  exports.clientSecretPresence = clientSecretPresence;
1394
2049
  exports.collectCalendarPages = collectCalendarPages;
1395
2050
  exports.commitLinkedTokens = commitLinkedTokens;
1396
2051
  exports.completeTask = completeTask;
1397
2052
  exports.configureGoogleHost = configureGoogleHost;
2053
+ exports.conflictingFields = conflictingFields;
1398
2054
  exports.createCalendarEvent = createCalendarEvent;
1399
2055
  exports.createDriveFile = createDriveFile;
1400
2056
  exports.createGoogleAuthFlow = createGoogleAuthFlow;
@@ -1403,8 +2059,11 @@ exports.deleteCalendarEvent = deleteCalendarEvent;
1403
2059
  exports.deleteDriveFile = deleteDriveFile;
1404
2060
  exports.deleteGoogleTokens = deleteGoogleTokens;
1405
2061
  exports.deleteTask = deleteTask;
2062
+ exports.fieldText = fieldText;
1406
2063
  exports.findClientSecretPath = findClientSecretPath;
2064
+ exports.getCalendar = getCalendar;
1407
2065
  exports.getCalendarColors = getCalendarColors;
2066
+ exports.getCalendarEvent = getCalendarEvent;
1408
2067
  exports.getGoogleAccessToken = getGoogleAccessToken;
1409
2068
  exports.googleApiError = googleApiError;
1410
2069
  exports.googleAuthFlow = googleAuthFlow;
@@ -1413,6 +2072,8 @@ exports.googleConfigDir = googleConfigDir;
1413
2072
  exports.googleSecretsDir = googleSecretsDir;
1414
2073
  exports.googleTokenPath = googleTokenPath;
1415
2074
  exports.groupByCalendar = groupByCalendar;
2075
+ exports.isClientSettableEventId = isClientSettableEventId;
2076
+ exports.isDeniedAccessRole = isDeniedAccessRole;
1416
2077
  exports.isGoogleApiError = isGoogleApiError;
1417
2078
  exports.isIsoDateTimeWithOffset = isIsoDateTimeWithOffset;
1418
2079
  exports.isTextMimeType = isTextMimeType;
@@ -1422,16 +2083,27 @@ exports.listCalendars = listCalendars;
1422
2083
  exports.listDriveFiles = listDriveFiles;
1423
2084
  exports.listTaskLists = listTaskLists;
1424
2085
  exports.listTasks = listTasks;
2086
+ exports.loadCalendarShadow = loadCalendarShadow;
1425
2087
  exports.loadCalendarSyncToken = loadCalendarSyncToken;
1426
2088
  exports.loadClientSecret = loadClientSecret;
1427
2089
  exports.loadGoogleTokens = loadGoogleTokens;
2090
+ exports.locallyChangedFields = locallyChangedFields;
2091
+ exports.locallyDeletedIds = locallyDeletedIds;
2092
+ exports.mayAdoptExisting = mayAdoptExisting;
1428
2093
  exports.mergeGoogleTokens = mergeGoogleTokens;
2094
+ exports.mergeShadow = mergeShadow;
1429
2095
  exports.orphanedCalendarId = orphanedCalendarId;
1430
2096
  exports.pickBoundary = pickBoundary;
2097
+ exports.planRecord = planRecord;
2098
+ exports.pushCalendarForCollection = pushCalendarForCollection;
2099
+ exports.pushableMap = pushableMap;
1431
2100
  exports.readDriveFile = readDriveFile;
1432
2101
  exports.releaseOrphanedCalendarToken = releaseOrphanedCalendarToken;
2102
+ exports.resolveEventSpan = resolveEventSpan;
2103
+ exports.saveCalendarShadow = saveCalendarShadow;
1433
2104
  exports.saveCalendarSyncToken = saveCalendarSyncToken;
1434
2105
  exports.saveGoogleTokens = saveGoogleTokens;
2106
+ exports.shadowUpdates = shadowUpdates;
1435
2107
  exports.syncCalendarEvents = syncCalendarEvents;
1436
2108
  exports.syncCalendarForCollection = syncCalendarForCollection;
1437
2109
  exports.syncCalendarGroup = syncCalendarGroup;
@@ -1442,6 +2114,8 @@ exports.toCollectionDateTime = toCollectionDateTime;
1442
2114
  exports.toCollectionRecord = toCollectionRecord;
1443
2115
  exports.toDriveFileSummary = toDriveFileSummary;
1444
2116
  exports.toEventSummary = toEventSummary;
2117
+ exports.toGoogleEventTime = toGoogleEventTime;
2118
+ exports.toShadowEvent = toShadowEvent;
1445
2119
  exports.toTaskListSummary = toTaskListSummary;
1446
2120
  exports.toTaskSummary = toTaskSummary;
1447
2121
  exports.uncompleteTask = uncompleteTask;
@@ -1450,6 +2124,8 @@ exports.unsyncedGroups = unsyncedGroups;
1450
2124
  exports.updateCalendarEvent = updateCalendarEvent;
1451
2125
  exports.updateTask = updateTask;
1452
2126
  exports.waitForAuthCode = waitForAuthCode;
2127
+ exports.withCalendarLock = withCalendarLock;
1453
2128
  exports.withKeyedLock = withKeyedLock;
2129
+ exports.zoneSuffixOf = zoneSuffixOf;
1454
2130
 
1455
2131
  //# sourceMappingURL=index.cjs.map