@mulmoclaude/core 1.2.1 → 1.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.
@@ -48,6 +48,10 @@ export declare function classifyDelete(eventId: string, kind: DeleteItemResult["
48
48
  * those events forever. Fetch once, apply to all, then advance the token.
49
49
  * (Codex + CodeRabbit review on #2184.) */
50
50
  export declare function syncCalendarGroup(calendarId: string | undefined, collections: readonly LoadedCollection[], workspaceRoot: string): Promise<CalendarCollectionSyncResult[]>;
51
+ /** Liveness of the collections a sync just wrote to, checked against the skill
52
+ * dir `deleteCollection` removes. `exists` is injected so the rule is testable
53
+ * without a filesystem. An empty group has no survivor by definition. */
54
+ export declare function anySyncedCollectionSurvives(collections: readonly Pick<LoadedCollection, "skillDir">[], exists?: (absPath: string) => Promise<boolean>): Promise<boolean>;
51
55
  /** Group the declaring collections by the calendar they read, so each calendar
52
56
  * is fetched exactly once.
53
57
  *
@@ -57,6 +61,32 @@ export declare function syncCalendarGroup(calendarId: string | undefined, collec
57
61
  * from under the other — the very loss this grouping exists to prevent
58
62
  * (Codex review #2184). */
59
63
  export declare function groupByCalendar(collections: readonly LoadedCollection[]): Map<string, LoadedCollection[]>;
64
+ /** The minimum a value needs for the orphan check: just the calendar it reads.
65
+ * Structural so the rule can be exercised without building a LoadedCollection. */
66
+ export interface CalendarDeclaring {
67
+ googleCalendar?: {
68
+ calendarId?: string;
69
+ };
70
+ }
71
+ /** The canonical calendar whose sync token nothing needs any more, or null.
72
+ *
73
+ * Sync tokens are keyed by calendar, NOT by collection, so a deleted
74
+ * collection's token outlives it. Recreating a collection on the same calendar
75
+ * then resumes from that token and receives only the delta — the new
76
+ * collection never gets the history (#2428).
77
+ *
78
+ * Returns null while ANY remaining collection still reads that calendar:
79
+ * clearing a live calendar's token costs a full re-walk on the next sync. The
80
+ * comparison is on the CANONICAL id for the same reason `groupByCalendar` is —
81
+ * an omitted `calendarId` and an explicit `"primary"` are one calendar. */
82
+ export declare function orphanedCalendarId(deleted: CalendarDeclaring, remaining: readonly CalendarDeclaring[]): string | null;
83
+ /** Drop the sync token of a just-deleted collection's calendar, unless another
84
+ * collection still reads it. Call AFTER the delete lands — the check reads the
85
+ * collections that survive it.
86
+ *
87
+ * Returns the cleared calendar id, or null when nothing was cleared. Never
88
+ * throws: a failed cleanup must not fail the delete it follows. */
89
+ export declare function releaseOrphanedCalendarToken(deleted: CalendarDeclaring, workspaceRoot: string): Promise<string | null>;
60
90
  /** Sync every collection that declares `googleCalendar`. Failures are isolated
61
91
  * per calendar — one unreachable calendar (or a revoked grant) must not stop
62
92
  * the others. */
@@ -906,13 +906,47 @@ async function syncCalendarGroup(calendarId, collections, workspaceRoot) {
906
906
  unwritable
907
907
  });
908
908
  const failed = results.flatMap((entry) => entry.errors);
909
- if (result.nextSyncToken && failed.length === 0) await saveCalendarSyncToken(calendarId, result.nextSyncToken, workspaceRoot);
910
- else if (failed.length > 0) log.warn("google", "holding back calendar sync token after failed writes", {
911
- calendarId,
912
- failed: failed.length
913
- });
909
+ if (failed.length > 0) {
910
+ log.warn("google", "holding back calendar sync token after failed writes", {
911
+ calendarId,
912
+ failed: failed.length
913
+ });
914
+ return results;
915
+ }
916
+ if (result.nextSyncToken) await advanceToken(calendarId, result.nextSyncToken, collections, workspaceRoot);
914
917
  return results;
915
918
  }
919
+ /** Save the window's token unless every collection that consumed it was deleted
920
+ * while the sync was in flight.
921
+ *
922
+ * The sync opens with a `discoverCollections()` snapshot, so a delete landing
923
+ * mid-run has already cleared this calendar's token by the time we get here
924
+ * (`releaseOrphanedCalendarToken`). Saving anyway would resurrect exactly the
925
+ * orphan the delete removed, and the next collection on this calendar would
926
+ * resume from a token describing records it never received (#2428).
927
+ *
928
+ * One survivor is enough: it still needs the incremental position. */
929
+ async function advanceToken(calendarId, nextSyncToken, collections, workspaceRoot) {
930
+ if (!await anySyncedCollectionSurvives(collections)) {
931
+ log.info("google", "not advancing the sync token — every collection on this calendar was deleted mid-sync", { calendarId });
932
+ return;
933
+ }
934
+ await saveCalendarSyncToken(calendarId, nextSyncToken, workspaceRoot);
935
+ }
936
+ async function pathExists(absPath) {
937
+ try {
938
+ await (0, node_fs_promises.stat)(absPath);
939
+ return true;
940
+ } catch {
941
+ return false;
942
+ }
943
+ }
944
+ /** Liveness of the collections a sync just wrote to, checked against the skill
945
+ * dir `deleteCollection` removes. `exists` is injected so the rule is testable
946
+ * without a filesystem. An empty group has no survivor by definition. */
947
+ async function anySyncedCollectionSurvives(collections, exists = pathExists) {
948
+ return (await Promise.all(collections.map((collection) => exists(collection.skillDir)))).some(Boolean);
949
+ }
916
950
  async function applyEventsToCollection(collection, events, workspaceRoot) {
917
951
  const outcomes = [];
918
952
  for (const event of events) outcomes.push(await applyEvent(collection, event, workspaceRoot));
@@ -945,6 +979,41 @@ function groupByCalendar(collections) {
945
979
  }
946
980
  return groups;
947
981
  }
982
+ /** The canonical calendar whose sync token nothing needs any more, or null.
983
+ *
984
+ * Sync tokens are keyed by calendar, NOT by collection, so a deleted
985
+ * collection's token outlives it. Recreating a collection on the same calendar
986
+ * then resumes from that token and receives only the delta — the new
987
+ * collection never gets the history (#2428).
988
+ *
989
+ * Returns null while ANY remaining collection still reads that calendar:
990
+ * clearing a live calendar's token costs a full re-walk on the next sync. The
991
+ * comparison is on the CANONICAL id for the same reason `groupByCalendar` is —
992
+ * an omitted `calendarId` and an explicit `"primary"` are one calendar. */
993
+ function orphanedCalendarId(deleted, remaining) {
994
+ if (!deleted.googleCalendar) return null;
995
+ const key = canonicalCalendarId(deleted.googleCalendar.calendarId);
996
+ return remaining.some((other) => other.googleCalendar && canonicalCalendarId(other.googleCalendar.calendarId) === key) ? null : key;
997
+ }
998
+ /** Drop the sync token of a just-deleted collection's calendar, unless another
999
+ * collection still reads it. Call AFTER the delete lands — the check reads the
1000
+ * collections that survive it.
1001
+ *
1002
+ * Returns the cleared calendar id, or null when nothing was cleared. Never
1003
+ * throws: a failed cleanup must not fail the delete it follows. */
1004
+ async function releaseOrphanedCalendarToken(deleted, workspaceRoot) {
1005
+ try {
1006
+ if (!deleted.googleCalendar) return null;
1007
+ const orphaned = orphanedCalendarId(deleted, (await require_discovery.discoverCollections({ workspaceRoot })).map((collection) => collection.schema));
1008
+ if (orphaned === null) return null;
1009
+ await clearCalendarSyncToken(orphaned, workspaceRoot);
1010
+ log.info("google", "cleared the sync token of a calendar no collection reads any more", { calendarId: orphaned });
1011
+ return orphaned;
1012
+ } catch (error) {
1013
+ log.warn("google", "could not release the deleted collection's calendar sync token", { error: String(error) });
1014
+ return null;
1015
+ }
1016
+ }
948
1017
  /** Sync every collection that declares `googleCalendar`. Failures are isolated
949
1018
  * per calendar — one unreachable calendar (or a revoked grant) must not stop
950
1019
  * the others. */
@@ -1147,6 +1216,7 @@ exports.GOOGLE_SCOPES = GOOGLE_SCOPES;
1147
1216
  exports.GOOGLE_TASKS_SCOPE = GOOGLE_TASKS_SCOPE;
1148
1217
  exports.GoogleApiError = GoogleApiError;
1149
1218
  exports.MAX_LIST_RESULTS = MAX_LIST_RESULTS;
1219
+ exports.anySyncedCollectionSurvives = anySyncedCollectionSurvives;
1150
1220
  exports.assertSafeMimeType = assertSafeMimeType;
1151
1221
  exports.authorizeGoogle = authorizeGoogle;
1152
1222
  exports.brokerBaseUrl = brokerBaseUrl;
@@ -1195,8 +1265,10 @@ exports.loadCalendarSyncToken = loadCalendarSyncToken;
1195
1265
  exports.loadClientSecret = loadClientSecret;
1196
1266
  exports.loadGoogleTokens = loadGoogleTokens;
1197
1267
  exports.mergeGoogleTokens = mergeGoogleTokens;
1268
+ exports.orphanedCalendarId = orphanedCalendarId;
1198
1269
  exports.pickBoundary = pickBoundary;
1199
1270
  exports.readDriveFile = readDriveFile;
1271
+ exports.releaseOrphanedCalendarToken = releaseOrphanedCalendarToken;
1200
1272
  exports.saveCalendarSyncToken = saveCalendarSyncToken;
1201
1273
  exports.saveGoogleTokens = saveGoogleTokens;
1202
1274
  exports.syncCalendarEvents = syncCalendarEvents;