@plinth-music/cli 0.23.0 → 0.23.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,45 @@
2
2
 
3
3
  Notable changes to `@plinth-music/cli`. Grouped by what a user notices, not by PR.
4
4
 
5
+ ## 0.23.1 - 2026-09-29
6
+
7
+ Two code PRs since `0.23.0` (`v0.23.0..0dd34dc`: #212, #213; #209, #210 and #211 docs).
8
+ A patch: fixes only, no new command or sync surface.
9
+
10
+ ⚠️ **A file you delete from the mirror stays deleted** (#213). A poll landing in the
11
+ second or so between your `rm` and the daemon's delete hold could write the file back,
12
+ and the delete was then dropped; four of seven documents came back this way on 26 Sept.
13
+ The daemon now leaves a path it saw you remove alone for five minutes, and waits one
14
+ poll before restoring a file that is missing for a reason it has not seen yet. A file
15
+ missing for any other reason is still restored, about one poll later than before.
16
+
17
+ ⚠️ **One slow calendar no longer freezes every calendar view** (#212). A calendar whose
18
+ read fails or times out now shows as not read, with no entries, until the next refresh.
19
+ Before, the whole refresh failed and every `views/calendar/` file kept its old contents.
20
+ A read that did not complete is described as incomplete, not as Google refusing access.
21
+
22
+ **The daemon stops re-downloading what has not changed** (#213). Since 0.22.0 every
23
+ poll fetched every memory fact in full, left `_freshness.md` reporting memory as
24
+ `unknown` / `incomplete_apply`, and re-fetched the extracted text of every PDF. Both now
25
+ happen once. Feeds ask from the server's high-water mark instead of re-reading their
26
+ last page; a full read from the cursor still runs at boot, on `plinth sync` and once an
27
+ hour, so nothing is missed. On the release commit, over 47 minutes after the first pass:
28
+ no full memory-fact downloads, no text re-fetches, no inconsistent trails.
29
+
30
+ **Each calendar view says what it covers** (#212). A view holds only its window and now
31
+ says so, naming the Plinth tool `list_calendar_events` for any other date. A truncated
32
+ read says why it was cut short.
33
+
34
+ **What is not proven.**
35
+
36
+ - The delete fix was driven live on the release commit (a delete inside a poll, and a
37
+ held batch left 13 minutes past the window: neither came back). A delete within about
38
+ 1.6 s of the lid closing has one exposed poll on wake.
39
+ - The calendar changes were not exercised live: the test workspace has no mapped
40
+ calendar. They are covered by tests and are checked on a real workspace after release.
41
+ - A sync request that hangs can still stall the poll loop for minutes (seen once, 4 min
42
+ 7 s, in the release check). Pre-existing; not fixed here.
43
+
5
44
  ## 0.23.0 - 2026-09-23
6
45
 
7
46
  One code PR since `0.22.0` (`v0.22.0..f3334fb`, #207; #204, #205 and #206 docs). A
@@ -1,5 +1,5 @@
1
1
  {
2
- "sha": "38edb48dcebc2bbe3f1609c939e93143a8946037",
2
+ "sha": "9dd9f6493dc707acce22c95ce564d8b189dbf27d",
3
3
  "dirty": false,
4
- "built_at": "2026-09-23T22:08:55.967Z"
4
+ "built_at": "2026-09-29T22:26:55.840Z"
5
5
  }
package/dist/cli.js CHANGED
@@ -7584,7 +7584,7 @@ function _getBuiltinFlags(long, short, userNames, userAliases) {
7584
7584
  // package.json
7585
7585
  var package_default = {
7586
7586
  name: "@plinth-music/cli",
7587
- version: "0.23.0",
7587
+ version: "0.23.1",
7588
7588
  description: "Plinth capstone sync daemon and CLI",
7589
7589
  type: "module",
7590
7590
  private: false,
@@ -22168,7 +22168,8 @@ var TombstoneSchema = exports_external.object({
22168
22168
  function makePageResponseSchema(responseKey, liveSchema) {
22169
22169
  return exports_external.object({
22170
22170
  [responseKey]: exports_external.array(exports_external.union([liveSchema, TombstoneSchema])),
22171
- next_cursor: exports_external.string().nullable()
22171
+ next_cursor: exports_external.string().nullable(),
22172
+ high_water: exports_external.string().nullable().optional()
22172
22173
  });
22173
22174
  }
22174
22175
  function makeParsePageResponse(schema, responseKey) {
@@ -22176,7 +22177,8 @@ function makeParsePageResponse(schema, responseKey) {
22176
22177
  const parsed = schema.parse(json2);
22177
22178
  return {
22178
22179
  rows: parsed[responseKey],
22179
- next_cursor: parsed.next_cursor
22180
+ next_cursor: parsed.next_cursor,
22181
+ high_water: parsed.high_water ?? null
22180
22182
  };
22181
22183
  };
22182
22184
  }
@@ -25345,7 +25347,8 @@ var WorkspaceEntrySchema = exports_external.object({
25345
25347
  last_synced_at: exports_external.string().datetime({ offset: true }).nullable(),
25346
25348
  workspace_name: exports_external.string().min(1).optional(),
25347
25349
  member: MemberPrincipalSchema.optional(),
25348
- sync_cursors: SyncCursorsSchema.optional()
25350
+ sync_cursors: SyncCursorsSchema.optional(),
25351
+ sync_high_water: SyncCursorsSchema.optional()
25349
25352
  });
25350
25353
  var ConfigFileSchema = exports_external.object({
25351
25354
  version: exports_external.literal(1),
@@ -25407,7 +25410,8 @@ function carryForwardSyncState(prior, entry) {
25407
25410
  return {
25408
25411
  ...entry,
25409
25412
  last_synced_at: prior.last_synced_at,
25410
- ...prior.sync_cursors !== undefined ? { sync_cursors: prior.sync_cursors } : {}
25413
+ ...prior.sync_cursors !== undefined ? { sync_cursors: prior.sync_cursors } : {},
25414
+ ...prior.sync_high_water !== undefined ? { sync_high_water: prior.sync_high_water } : {}
25411
25415
  };
25412
25416
  }
25413
25417
  function upsertWorkspace(slug, entry, options2 = {}) {
@@ -25448,6 +25452,23 @@ function updateSyncCursor(slug, entityType, cursor) {
25448
25452
  writeAtomic(next);
25449
25453
  return next;
25450
25454
  }
25455
+ function updateSyncHighWater(slug, entityType, mark) {
25456
+ const existing = loadConfig();
25457
+ if (!existing) {
25458
+ throw new Error(`No config. Run \`plinth login --workspace ${slug}\` first.`);
25459
+ }
25460
+ const entry = requireWorkspace(existing, slug);
25461
+ if ((entry.sync_high_water?.[entityType] ?? null) === mark)
25462
+ return;
25463
+ const nextEntry = {
25464
+ ...entry,
25465
+ sync_high_water: { ...entry.sync_high_water, [entityType]: mark }
25466
+ };
25467
+ writeAtomic({
25468
+ ...existing,
25469
+ workspaces: { ...existing.workspaces, [slug]: nextEntry }
25470
+ });
25471
+ }
25451
25472
  function setMemberPrincipal(slug, member) {
25452
25473
  const existing = loadConfig();
25453
25474
  if (!existing)
@@ -26735,7 +26756,7 @@ function allFileEntries(state) {
26735
26756
  return Object.values(state.files ?? {});
26736
26757
  }
26737
26758
  function fileEntriesEqual(a, b) {
26738
- return a.id === b.id && a.path === b.path && a.content_hash === b.content_hash && a.size_bytes === b.size_bytes && a.mime_type === b.mime_type && a.updated_at === b.updated_at && a.inode === b.inode;
26759
+ return a.id === b.id && a.path === b.path && a.content_hash === b.content_hash && a.size_bytes === b.size_bytes && a.mime_type === b.mime_type && a.updated_at === b.updated_at && a.inode === b.inode && a.text_hash === b.text_hash;
26739
26760
  }
26740
26761
  function clearFrontmatterIdBackfillMarker(state) {
26741
26762
  if (state.frontmatter_id_backfill_version === undefined) {
@@ -30195,7 +30216,8 @@ var FileTombstoneSchema = exports_external.object({
30195
30216
  var FileRowSchema = exports_external.union([FileLiveSchema, FileTombstoneSchema]);
30196
30217
  var FilesPageSchema = exports_external.object({
30197
30218
  files: exports_external.array(FileRowSchema),
30198
- next_cursor: exports_external.string().nullable()
30219
+ next_cursor: exports_external.string().nullable(),
30220
+ high_water: exports_external.string().nullable().optional()
30199
30221
  });
30200
30222
  var FileWithDownloadSchema = FileLiveSchema.extend({
30201
30223
  download_url: exports_external.string().url().nullable()
@@ -30248,7 +30270,11 @@ async function syncFilesPage(args) {
30248
30270
  if (!parsed.success) {
30249
30271
  throw new ApiError(res.status, `sync files response did not match the expected shape: ${parsed.error.message}`);
30250
30272
  }
30251
- return { rows: parsed.data.files, next_cursor: parsed.data.next_cursor };
30273
+ return {
30274
+ rows: parsed.data.files,
30275
+ next_cursor: parsed.data.next_cursor,
30276
+ high_water: parsed.data.high_water ?? null
30277
+ };
30252
30278
  }
30253
30279
  async function getFile(args) {
30254
30280
  const { baseUrl, pat, id, fetchImpl = fetch } = args;
@@ -30519,6 +30545,12 @@ function fileDeleteSuppressedLine(f) {
30519
30545
  function entityDeleteSuppressedLine(f) {
30520
30546
  return `[ENTITY_DELETE_SUPPRESSED entityType=${f.entityType} slug=${f.slug} reason=${f.reason}]`;
30521
30547
  }
30548
+ function entityRestoreDeferredLine(f) {
30549
+ return `[ENTITY_RESTORE_DEFERRED entityType=${f.entityType} slug=${f.slug} wait_s=${f.waitSeconds}]`;
30550
+ }
30551
+ function fileRestoreDeferredLine(f) {
30552
+ return `[FILE_RESTORE_DEFERRED path=${f.path} wait_s=${f.waitSeconds}]`;
30553
+ }
30522
30554
  function fileOverCapLine(f) {
30523
30555
  return `[FILE_OVER_CAP path=${f.path} size=${f.size} max=${f.max} max_from=${f.source}]`;
30524
30556
  }
@@ -38054,7 +38086,7 @@ function unknownBecause(reason) {
38054
38086
  case "no_watermark":
38055
38087
  return "The last download carried no freshness marker, so there is nothing to compare against.";
38056
38088
  case "incomplete_apply":
38057
- return "The last download did not fully land, so this copy is not the one the freshness marker describes.";
38089
+ return "The last download either did not fully land or carried a correction trail this copy could not use, so this copy is not one the freshness marker can vouch for.";
38058
38090
  case "check_failed":
38059
38091
  return "The freshness check did not complete.";
38060
38092
  default:
@@ -38211,6 +38243,9 @@ function resolveTrail(row, slugById) {
38211
38243
  consistent: true
38212
38244
  };
38213
38245
  }
38246
+ if (outcome === "current" && successor === row.id) {
38247
+ return { trail: "current", consistent: true };
38248
+ }
38214
38249
  if (successor !== null)
38215
38250
  return { trail: UNKNOWN, consistent: false };
38216
38251
  return { trail: outcome, consistent: true };
@@ -40675,7 +40710,12 @@ function startWatcher(opts, handler) {
40675
40710
  });
40676
40711
  watcher.on("add", (p) => classifier.onAdd(p));
40677
40712
  watcher.on("change", (p) => classifier.onChange(p));
40678
- watcher.on("unlink", (p) => classifier.onUnlink(p));
40713
+ watcher.on("unlink", (p) => {
40714
+ try {
40715
+ opts.onUnlink?.(p);
40716
+ } catch {}
40717
+ classifier.onUnlink(p);
40718
+ });
40679
40719
  return {
40680
40720
  async stop() {
40681
40721
  classifier.shutdown();
@@ -42799,6 +42839,109 @@ class PullLoop {
42799
42839
  }
42800
42840
  }
42801
42841
 
42842
+ // src/lib/high-water.ts
42843
+ var HIGH_WATER_BASE_READ_MS = 60 * 60 * 1000;
42844
+ function baseReadIntervalMs(env2 = process.env) {
42845
+ const raw = Number(env2.PLINTH_HIGH_WATER_BASE_READ_MS);
42846
+ return Number.isFinite(raw) && raw > 0 ? raw : HIGH_WATER_BASE_READ_MS;
42847
+ }
42848
+ function provenMark(args) {
42849
+ if (!args.clean)
42850
+ return null;
42851
+ return args.finalHighWater ?? args.advancedTo;
42852
+ }
42853
+ function createBaseReadSchedule(bootMs, intervalMs = HIGH_WATER_BASE_READ_MS) {
42854
+ let lastBaseReadMs = bootMs;
42855
+ let restoresWaiting = false;
42856
+ let lastUsedHighWater = false;
42857
+ return {
42858
+ useHighWater(nowMs) {
42859
+ if (restoresWaiting)
42860
+ return lastUsedHighWater;
42861
+ return nowMs - lastBaseReadMs < intervalMs;
42862
+ },
42863
+ completed(nowMs, usedHighWater, ok, restoresDeferred = 0) {
42864
+ if (!ok)
42865
+ return;
42866
+ if (!usedHighWater)
42867
+ lastBaseReadMs = nowMs;
42868
+ restoresWaiting = restoresDeferred > 0;
42869
+ lastUsedHighWater = usedHighWater;
42870
+ }
42871
+ };
42872
+ }
42873
+
42874
+ // src/lib/restore-deferral.ts
42875
+ var RESTORE_DEFERRAL_MS = 5 * 60000;
42876
+ var UNEXPLAINED_GRACE_MS = 5000;
42877
+ function keyOf(absolutePath) {
42878
+ return absolutePath.normalize("NFC");
42879
+ }
42880
+ function sightingKey(key, version2) {
42881
+ return `${key}\x00${version2}`;
42882
+ }
42883
+ function forgetSightings(recent, key) {
42884
+ const prefix = `${key}\x00`;
42885
+ for (const k of recent.keys())
42886
+ if (k.startsWith(prefix))
42887
+ recent.delete(k);
42888
+ }
42889
+ function noteUnlink(recent, absolutePath, nowMs) {
42890
+ forgetExpired(recent, nowMs);
42891
+ const key = keyOf(absolutePath);
42892
+ forgetSightings(recent, key);
42893
+ recent.set(key, { kind: "unlink", at: nowMs, logged: false });
42894
+ }
42895
+ function deferRestore(recent, absolutePath, version2, nowMs) {
42896
+ if (!recent)
42897
+ return { defer: false, first: false };
42898
+ forgetExpired(recent, nowMs);
42899
+ const key = keyOf(absolutePath);
42900
+ const unlinked = recent.get(key);
42901
+ if (unlinked?.kind === "unlink") {
42902
+ if (unlinked.logged)
42903
+ return { defer: true, first: false };
42904
+ recent.set(key, { ...unlinked, logged: true });
42905
+ return { defer: true, first: true };
42906
+ }
42907
+ const sighting = sightingKey(key, version2);
42908
+ const seen = recent.get(sighting);
42909
+ if (seen === undefined) {
42910
+ recent.set(sighting, { kind: "missing", at: nowMs });
42911
+ return { defer: true, first: false };
42912
+ }
42913
+ if (nowMs - seen.at < UNEXPLAINED_GRACE_MS)
42914
+ return { defer: true, first: false };
42915
+ recent.delete(sighting);
42916
+ return { defer: false, first: false };
42917
+ }
42918
+ function presentAgain(recent, absolutePath) {
42919
+ if (recent)
42920
+ forgetSightings(recent, keyOf(absolutePath));
42921
+ }
42922
+ function forgetExpired(recent, nowMs) {
42923
+ for (const [key, seen] of recent) {
42924
+ if (seen.kind === "unlink" && nowMs - seen.at >= RESTORE_DEFERRAL_MS)
42925
+ recent.delete(key);
42926
+ }
42927
+ }
42928
+
42929
+ // src/lib/ambient-poll.ts
42930
+ function createAmbientPollWiring(opts) {
42931
+ const recentUnlinks = new Map;
42932
+ const baseReads = createBaseReadSchedule(opts.now(), opts.baseReadIntervalMs);
42933
+ return {
42934
+ onUnlink: (absolutePath) => noteUnlink(recentUnlinks, absolutePath, opts.now()),
42935
+ async poll(run) {
42936
+ const startMs = opts.now();
42937
+ const useHighWater = baseReads.useHighWater(startMs);
42938
+ const result = await run({ useHighWater, recentUnlinks });
42939
+ baseReads.completed(startMs, useHighWater, result.exitCode === 0, result.restoresDeferred ?? 0);
42940
+ return result;
42941
+ }
42942
+ };
42943
+ }
42944
+
42802
42945
  // src/lib/calendar-loop.ts
42803
42946
  var DEFAULT_CALENDAR_INTERVAL_MS = 900000;
42804
42947
  var CALENDAR_BACKOFF_CAP_MS = 3600000;
@@ -42959,6 +43102,12 @@ var CALENDAR_STATUSES = [
42959
43102
  "token-expired",
42960
43103
  "google-error"
42961
43104
  ];
43105
+ var KNOWN_TRUNCATED_REASONS = ["page_limit", "time_limit"];
43106
+ var TruncatedReasonSchema = exports_external.string().nullish().transform((v) => {
43107
+ if (v === null || v === undefined)
43108
+ return null;
43109
+ return KNOWN_TRUNCATED_REASONS.includes(v) ? v : "other";
43110
+ });
42962
43111
  var CalendarRowSchema = exports_external.object({
42963
43112
  artist_id: exports_external.string().nullable(),
42964
43113
  artist_slug: exports_external.string().nullable(),
@@ -42967,8 +43116,13 @@ var CalendarRowSchema = exports_external.object({
42967
43116
  calendar_name: exports_external.string(),
42968
43117
  status: exports_external.enum(CALENDAR_STATUSES),
42969
43118
  events: exports_external.array(CalendarEventSchema).nullable(),
42970
- error: exports_external.object({ upstream_status: exports_external.number(), message: exports_external.string() }).nullish().transform((v) => v ?? null),
42971
- truncated: exports_external.boolean().optional().default(false)
43119
+ error: exports_external.object({
43120
+ upstream_status: exports_external.number().nullable(),
43121
+ message: exports_external.string()
43122
+ }).nullish().transform((v) => v ?? null),
43123
+ truncated: exports_external.boolean().optional().default(false),
43124
+ truncated_reason: TruncatedReasonSchema,
43125
+ resume_from: exports_external.string().nullish().transform((v) => v ?? null)
42972
43126
  });
42973
43127
  var CalendarResponseSchema = exports_external.object({
42974
43128
  generated_at: exports_external.string().min(1),
@@ -43156,6 +43310,62 @@ function readAsOf(text, today) {
43156
43310
  return { parts, yearInferred: first.yearInferred };
43157
43311
  }
43158
43312
 
43313
+ // src/lib/calendar/limits.ts
43314
+ var CALENDAR_RANGE_TOOL = "list_calendar_events";
43315
+ function spanPhrase(timeMin, timeMax) {
43316
+ const ms = Date.parse(timeMax) - Date.parse(timeMin);
43317
+ if (!Number.isFinite(ms) || ms <= 0)
43318
+ return null;
43319
+ const weeks = ms / (7 * DAY_MS);
43320
+ if (Number.isInteger(weeks))
43321
+ return `${weeks} ${weeks === 1 ? "week" : "weeks"}`;
43322
+ const days = ms / DAY_MS;
43323
+ if (Number.isInteger(days))
43324
+ return `${days} ${days === 1 ? "day" : "days"}`;
43325
+ return null;
43326
+ }
43327
+ function coverageLine(timeMin, timeMax) {
43328
+ const span = spanPhrase(timeMin, timeMax);
43329
+ return `**This file holds only the window above${span ? ` (${span})` : ""}.** ` + `When a question needs a date outside it, past or future, call the Plinth ` + `tool \`${CALENDAR_RANGE_TOOL}\` for just that range.`;
43330
+ }
43331
+ var TRUNCATION_CAUSE = {
43332
+ page_limit: "Google had more entries in this window than one refresh reads.",
43333
+ time_limit: "The read from Google ran out of time.",
43334
+ other: "The read stopped before the end of the window."
43335
+ };
43336
+ var TRUNCATION_SHORT = {
43337
+ page_limit: "stopped at the page limit",
43338
+ time_limit: "ran out of time",
43339
+ other: "stopped before the end of the window"
43340
+ };
43341
+ var OFFSET_TIMESTAMP_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})$/i;
43342
+ function resumeRange(resumeFrom, window2) {
43343
+ if (resumeFrom === null || !OFFSET_TIMESTAMP_RE.test(resumeFrom))
43344
+ return null;
43345
+ if (!OFFSET_TIMESTAMP_RE.test(window2.time_min) || !OFFSET_TIMESTAMP_RE.test(window2.time_max)) {
43346
+ return null;
43347
+ }
43348
+ const at = Date.parse(resumeFrom);
43349
+ const min = Date.parse(window2.time_min);
43350
+ const max = Date.parse(window2.time_max);
43351
+ if (!(at > min && at < max))
43352
+ return null;
43353
+ return { from: new Date(at).toISOString(), to: new Date(max).toISOString() };
43354
+ }
43355
+ function incompleteLine(args) {
43356
+ const { stamp: stamp2, eventCount, reason, resumeFrom, window: window2 } = args;
43357
+ const kept = eventCount === 0 ? "No entries were collected before the read stopped, so this is NOT a clear diary." : `${eventCount} ${eventCount === 1 ? "entry was" : "entries were"} collected before the read stopped.`;
43358
+ const range = resumeRange(resumeFrom, window2);
43359
+ const pointer = range ? ` For the rest, call the Plinth tool \`${CALENDAR_RANGE_TOOL}\` from ` + `\`${range.from}\` to \`${range.to}\` (a few entries repeat).` : ` For the rest, call the Plinth tool \`${CALENDAR_RANGE_TOOL}\` for a ` + `narrower range inside the window.`;
43360
+ return `**Read as of ${stamp2}, but this list is INCOMPLETE.** ` + `${TRUNCATION_CAUSE[reason ?? "other"]} ${kept} ` + `The rest of the window is not included: unknown, not absent.${pointer}`;
43361
+ }
43362
+ function sentence(text) {
43363
+ return /[.!?\u2026]$/.test(text) ? text : `${text}.`;
43364
+ }
43365
+ function truncationShort(reason) {
43366
+ return TRUNCATION_SHORT[reason ?? "other"];
43367
+ }
43368
+
43159
43369
  // src/lib/calendar/render.ts
43160
43370
  var CALENDAR_BODY_MARKER = "<!-- PLINTH:CALENDAR-BODY -->";
43161
43371
  var OWNERSHIP_NOTE2 = [
@@ -43259,37 +43469,61 @@ function windowLine(timeMin, timeMax) {
43259
43469
  return `${timeMin} to ${timeMax}`;
43260
43470
  return `${formatDay(from, -1)} to ${formatDay(to, -1)}`;
43261
43471
  }
43472
+ function isRefusal(error52) {
43473
+ return error52.upstream_status !== null && error52.upstream_status >= 400;
43474
+ }
43475
+ function googleErrorLine(error52) {
43476
+ if (error52 !== null && !isRefusal(error52)) {
43477
+ return `**NOT READ - the read from Google did not complete.** ` + `${sentence(oneLine2(error52.message, 300))} The next refresh retries; until then ` + `nothing below is current, so treat this calendar as unknown rather than empty. ` + `For a date on it you need now, call the Plinth tool \`${CALENDAR_RANGE_TOOL}\` ` + `for a narrower range.`;
43478
+ }
43479
+ return `**NOT READ - Google refused the request` + `${error52 ? ` (HTTP ${error52.upstream_status})` : ""}.** ` + `${error52 ? sentence(oneLine2(error52.message, 300)) : "No detail was returned."} ` + `A calendar that was unshared or deleted in Google reads exactly like this. ` + `Nothing below is current; treat this calendar as unknown rather than empty.`;
43480
+ }
43262
43481
  function statusLine(args) {
43263
43482
  const { status, eventCount, error: error52, truncated, stamp: stamp2, userLabel } = args;
43264
43483
  switch (status) {
43265
43484
  case "fetched": {
43485
+ if (truncated) {
43486
+ return incompleteLine({
43487
+ stamp: stamp2,
43488
+ eventCount,
43489
+ reason: args.truncatedReason,
43490
+ resumeFrom: args.resumeFrom,
43491
+ window: args.window
43492
+ });
43493
+ }
43266
43494
  if (eventCount === 0) {
43267
43495
  return `**Read fine as of ${stamp2}. Nothing in this window.** The diary really ` + `is clear across the dates above. This is a successful read of an empty ` + `calendar, not a failed read - if it had failed, this line would say so.`;
43268
43496
  }
43269
- const truncatedNote = truncated ? " **This list is INCOMPLETE**: Google had more entries than one page and the rest were not collected." : "";
43270
- return `**Read fine as of ${stamp2}.** ${eventCount} ` + `${eventCount === 1 ? "entry" : "entries"} in the window.${truncatedNote}`;
43497
+ return `**Read fine as of ${stamp2}.** ${eventCount} ` + `${eventCount === 1 ? "entry" : "entries"} in the window.`;
43271
43498
  }
43272
43499
  case "no-token":
43273
43500
  return `**NOT READ - Google is not connected for ${userLabel}.** Nothing is listed ` + `below because Plinth could not look, NOT because the diary is clear. ` + `Connect Google in Plinth to fill this in, and until then treat this ` + `calendar as unknown rather than empty.`;
43274
43501
  case "token-expired":
43275
43502
  return `**NOT READ - the Google connection for ${userLabel} has expired.** Plinth ` + `still holds a connection but could not refresh it, so nothing below is ` + `current. Reconnect Google in Plinth. Treat this calendar as unknown ` + `rather than empty.`;
43276
43503
  case "google-error":
43277
- return `**NOT READ - Google refused the request` + `${error52 ? ` (HTTP ${error52.upstream_status})` : ""}.** ` + `${error52 ? oneLine2(error52.message, 300) : "No detail was returned."} ` + `A calendar that was unshared or deleted in Google reads exactly like this. ` + `Nothing below is current; treat this calendar as unknown rather than empty.`;
43504
+ return googleErrorLine(error52);
43278
43505
  }
43279
43506
  }
43280
43507
  function shortStatus(row) {
43281
43508
  switch (row.status) {
43282
43509
  case "fetched": {
43283
43510
  const n = row.events?.length ?? 0;
43511
+ const count = `${n} ${n === 1 ? "entry" : "entries"}`;
43512
+ if (row.truncated) {
43513
+ return `read, ${count}, INCOMPLETE (${truncationShort(row.truncated_reason)}); ` + `the rest of the window is unknown, not empty`;
43514
+ }
43284
43515
  if (n === 0)
43285
43516
  return "read fine, nothing in this window";
43286
- return `read fine, ${n} ${n === 1 ? "entry" : "entries"}${row.truncated ? " (INCOMPLETE - more than one page)" : ""}`;
43517
+ return `read fine, ${count}`;
43287
43518
  }
43288
43519
  case "no-token":
43289
43520
  return "NOT READ - Google is not connected; unknown, not empty";
43290
43521
  case "token-expired":
43291
43522
  return "NOT READ - the Google connection has expired; unknown, not empty";
43292
43523
  case "google-error":
43524
+ if (row.error !== null && !isRefusal(row.error)) {
43525
+ return `NOT READ - the read from Google did not complete: ` + `${oneLine2(row.error.message, 120)}; the next refresh retries; unknown, not empty`;
43526
+ }
43293
43527
  return `NOT READ - Google refused` + `${row.error ? ` (HTTP ${row.error.upstream_status}): ${oneLine2(row.error.message, 120)}` : ""}` + `; unknown, not empty`;
43294
43528
  }
43295
43529
  }
@@ -43347,21 +43581,25 @@ function renderArtistCalendar(args) {
43347
43581
  eventCount: events.length,
43348
43582
  error: row.error,
43349
43583
  truncated: row.truncated,
43584
+ truncatedReason: row.truncated_reason,
43585
+ resumeFrom: row.resume_from,
43586
+ window: window2,
43350
43587
  stamp: stamp2,
43351
43588
  userLabel
43352
43589
  }),
43353
43590
  "",
43354
43591
  `**Window:** ${windowLine(window2.time_min, window2.time_max)}.`,
43355
43592
  "",
43593
+ ...row.status === "fetched" ? [coverageLine(window2.time_min, window2.time_max), ""] : [],
43356
43594
  "**Dates are read here and changed in Plinth's calendar view, by a person.** If something below needs moving, say what the change is and hand it over.",
43357
43595
  "",
43358
- renderBands(events, generatedAt, referenceYear, "")
43596
+ renderBands(events, generatedAt, referenceYear, "", row.truncated)
43359
43597
  ].join(`
43360
43598
  `);
43361
43599
  return `${header}${CALENDAR_BODY_MARKER}
43362
43600
  ${body}`;
43363
43601
  }
43364
- function renderBands(events, generatedAt, referenceYear, artistPrefix) {
43602
+ function renderBands(events, generatedAt, referenceYear, artistPrefix, truncated) {
43365
43603
  if (events.length === 0)
43366
43604
  return "";
43367
43605
  const { full, rest } = splitBands(events, generatedAt);
@@ -43375,7 +43613,7 @@ function renderBands(events, generatedAt, referenceYear, artistPrefix) {
43375
43613
  }
43376
43614
  out.push("", "## Rest of the window", "");
43377
43615
  if (rest.length === 0) {
43378
- out.push("_Nothing further in the window._");
43616
+ out.push(truncated ? "_Nothing further was collected: the read stopped before the end of the window._" : "_Nothing further in the window._");
43379
43617
  } else {
43380
43618
  for (const e of rest)
43381
43619
  out.push(compactLine(e, referenceYear, artistPrefix));
@@ -43384,6 +43622,9 @@ function renderBands(events, generatedAt, referenceYear, artistPrefix) {
43384
43622
  return out.join(`
43385
43623
  `);
43386
43624
  }
43625
+ function lacksConnection(row) {
43626
+ return row.status === "no-token" || row.status === "token-expired";
43627
+ }
43387
43628
  function renderUpcoming(args) {
43388
43629
  const {
43389
43630
  rows,
@@ -43399,6 +43640,7 @@ function renderUpcoming(args) {
43399
43640
  const fetched = rows.filter((r) => r.status === "fetched");
43400
43641
  const failed = rows.filter((r) => r.status !== "fetched");
43401
43642
  const truncatedAnywhere = rows.some((r) => r.truncated);
43643
+ const cutShort = fetched.filter((r) => r.truncated).length;
43402
43644
  const merged = fetched.flatMap((row) => (row.events ?? []).map((event) => ({ event, row }))).sort((a, b) => sortInstant(a.event) - sortInstant(b.event));
43403
43645
  const { full, rest } = splitBands(merged.map((m) => m.event), generatedAt);
43404
43646
  const fullSet = new Set(full);
@@ -43429,7 +43671,12 @@ function renderUpcoming(args) {
43429
43671
  "",
43430
43672
  `**As of ${stamp2}.** Window ${windowLine(window2.time_min, window2.time_max)}.`,
43431
43673
  "",
43674
+ ...rows.length > 0 && !rows.every(lacksConnection) ? [coverageLine(window2.time_min, window2.time_max), ""] : [],
43432
43675
  failed.length === 0 ? `**All ${fetched.length} mapped ${fetched.length === 1 ? "calendar was" : "calendars were"} read.**` : `**${fetched.length} of ${rows.length} mapped ${rows.length === 1 ? "calendar" : "calendars"} read; ${failed.length} could NOT be read.**` + ` Anything on an unread calendar is missing from the lists below - unknown, not absent.`,
43676
+ ...cutShort === 0 ? [] : [
43677
+ "",
43678
+ `**${cutShort} of the calendars read ${cutShort === 1 ? "was" : "were"} cut short** before the end ` + `of the window, so entries past the point where ${cutShort === 1 ? "it" : "each"} stopped are ` + `missing from the lists below - unknown, not absent. The status list says which.`
43679
+ ],
43433
43680
  "",
43434
43681
  `${artistCount} ${artistCount === 1 ? "artist" : "artists"} in the roster; ` + `${mappedArtistCount} ${mappedArtistCount === 1 ? "has" : "have"} a calendar mapped in Plinth.`,
43435
43682
  "",
@@ -43446,7 +43693,7 @@ function renderUpcoming(args) {
43446
43693
  body.push(`## Next ${FULL_BAND_DAYS} days`, "", "_Links are in the per-artist file named beside each calendar above._", "");
43447
43694
  const fullMerged = merged.filter((m) => fullSet.has(m.event));
43448
43695
  if (fullMerged.length === 0) {
43449
- body.push(fetched.length === 0 ? `_No calendar could be read, so nothing can be listed for the next ${FULL_BAND_DAYS} days._` : `_Nothing in the next ${FULL_BAND_DAYS} days on any calendar that was read._`);
43696
+ body.push(fetched.length === 0 ? `_No calendar could be read, so nothing can be listed for the next ${FULL_BAND_DAYS} days._` : cutShort > 0 ? `_Nothing collected for the next ${FULL_BAND_DAYS} days on any calendar that was read; the ones cut short may be missing entries._` : `_Nothing in the next ${FULL_BAND_DAYS} days on any calendar that was read._`);
43450
43697
  } else {
43451
43698
  for (const { event, row } of fullMerged) {
43452
43699
  body.push(fullLine(event, referenceYear, `**${rowDisplayName(row)}**`, false));
@@ -43455,7 +43702,7 @@ function renderUpcoming(args) {
43455
43702
  body.push("", "## Rest of the window", "");
43456
43703
  const restMerged = merged.filter((m) => restSet.has(m.event));
43457
43704
  if (restMerged.length === 0) {
43458
- body.push(fetched.length === 0 ? "_No calendar could be read, so nothing can be listed._" : "_Nothing further in the window on any calendar that was read._");
43705
+ body.push(fetched.length === 0 ? "_No calendar could be read, so nothing can be listed._" : cutShort > 0 ? "_Nothing further was collected on any calendar that was read; the ones cut short may be missing entries past where they stopped._" : "_Nothing further in the window on any calendar that was read._");
43459
43706
  } else {
43460
43707
  for (const { event, row } of restMerged) {
43461
43708
  body.push(compactLine(event, referenceYear, `**${rowDisplayName(row)}**`));
@@ -46061,7 +46308,8 @@ async function replayPermanentFiles(args, markers) {
46061
46308
  const now = args.now ?? (() => new Date);
46062
46309
  const rows = [];
46063
46310
  let cursor = null;
46064
- let resultingCursor = args.cursor;
46311
+ const savedCursor = args.baseCursor === undefined ? args.cursor : args.baseCursor;
46312
+ let resultingCursor = savedCursor;
46065
46313
  while (true) {
46066
46314
  const page = await syncFilesPage({
46067
46315
  baseUrl: args.baseUrl,
@@ -46084,6 +46332,7 @@ async function replayPermanentFiles(args, markers) {
46084
46332
  let pointerRows = 0;
46085
46333
  let symlinkRefused = 0;
46086
46334
  let stashFailed = 0;
46335
+ let restoresDeferred = 0;
46087
46336
  let completePass = true;
46088
46337
  let lastCommittedState = args.state;
46089
46338
  const completed = [];
@@ -46172,7 +46421,8 @@ async function replayPermanentFiles(args, markers) {
46172
46421
  isFileFrozen: args.isFileFrozen,
46173
46422
  loggedTombstoneSkips: args.loggedTombstoneSkips,
46174
46423
  forceTextRewalk: args.forceTextRewalk,
46175
- loggedStashNotices: args.loggedStashNotices
46424
+ loggedStashNotices: args.loggedStashNotices,
46425
+ recentUnlinks: args.recentUnlinks
46176
46426
  });
46177
46427
  state = outcome.state;
46178
46428
  if (outcome.action === "written")
@@ -46189,11 +46439,15 @@ async function replayPermanentFiles(args, markers) {
46189
46439
  symlinkRefused++;
46190
46440
  completePass = false;
46191
46441
  }
46192
- if (outcome.stashFailed || outcome.textFailed) {
46442
+ if (outcome.stashFailed || outcome.textFailed || outcome.integrityFailed) {
46193
46443
  if (outcome.stashFailed)
46194
46444
  stashFailed++;
46195
46445
  completePass = false;
46196
46446
  }
46447
+ if (outcome.restoreDeferred) {
46448
+ restoresDeferred++;
46449
+ completePass = false;
46450
+ }
46197
46451
  }
46198
46452
  if (args.commit && state !== lastCommittedState)
46199
46453
  state = args.commit(lastCommittedState, state);
@@ -46208,7 +46462,8 @@ async function replayPermanentFiles(args, markers) {
46208
46462
  pointerRows,
46209
46463
  symlinkRefused,
46210
46464
  stashFailed,
46211
- lastCursor: completePass ? resultingCursor : args.cursor,
46465
+ lastCursor: completePass ? resultingCursor : savedCursor,
46466
+ restoresDeferred,
46212
46467
  permanentRefreshes: completed,
46213
46468
  permanentReplayComplete: completePass
46214
46469
  };
@@ -46232,12 +46487,27 @@ async function pullFiles(args) {
46232
46487
  let pointerRows = 0;
46233
46488
  let symlinkRefused = 0;
46234
46489
  let stashFailed = 0;
46490
+ let restoresDeferred = 0;
46235
46491
  let permanentEncountered = false;
46492
+ const baseCursor = args.baseCursor === undefined ? args.cursor : args.baseCursor;
46236
46493
  let cursor = args.cursor;
46237
46494
  let lastCursor = args.cursor;
46495
+ let finalHighWater = null;
46496
+ let markRefused = false;
46238
46497
  let cursorBlocked = permanentJournalBlocked;
46239
46498
  while (true) {
46240
- const page = await syncFilesPage({ baseUrl, pat, cursor, fetchImpl });
46499
+ let page;
46500
+ try {
46501
+ page = await syncFilesPage({ baseUrl, pat, cursor, fetchImpl });
46502
+ } catch (err) {
46503
+ if (!markRefused && cursor === args.cursor && args.cursor !== baseCursor && err instanceof ApiError && err.status === 400) {
46504
+ markRefused = true;
46505
+ cursor = baseCursor;
46506
+ lastCursor = baseCursor;
46507
+ continue;
46508
+ }
46509
+ throw err;
46510
+ }
46241
46511
  const pageBase = state;
46242
46512
  let pageTextFailed = false;
46243
46513
  for (const row of page.rows) {
@@ -46271,7 +46541,8 @@ async function pullFiles(args) {
46271
46541
  isFileFrozen: args.isFileFrozen,
46272
46542
  loggedTombstoneSkips: args.loggedTombstoneSkips,
46273
46543
  forceTextRewalk: args.forceTextRewalk,
46274
- loggedStashNotices: args.loggedStashNotices
46544
+ loggedStashNotices: args.loggedStashNotices,
46545
+ recentUnlinks: args.recentUnlinks
46275
46546
  });
46276
46547
  state = outcome.state;
46277
46548
  if (!outcome.symlinkRefused) {
@@ -46297,13 +46568,21 @@ async function pullFiles(args) {
46297
46568
  stashFailed++;
46298
46569
  pageTextFailed = true;
46299
46570
  }
46571
+ if (outcome.integrityFailed)
46572
+ pageTextFailed = true;
46573
+ if (outcome.restoreDeferred) {
46574
+ restoresDeferred++;
46575
+ pageTextFailed = true;
46576
+ }
46300
46577
  }
46301
46578
  if (args.commit && state !== pageBase)
46302
46579
  state = args.commit(pageBase, state);
46303
46580
  if (pageTextFailed)
46304
46581
  cursorBlocked = true;
46305
- if (page.next_cursor === null)
46582
+ if (page.next_cursor === null) {
46583
+ finalHighWater = page.high_water ?? null;
46306
46584
  break;
46585
+ }
46307
46586
  if (!cursorBlocked)
46308
46587
  lastCursor = page.next_cursor;
46309
46588
  cursor = page.next_cursor;
@@ -46340,7 +46619,14 @@ async function pullFiles(args) {
46340
46619
  pointerRows,
46341
46620
  symlinkRefused,
46342
46621
  stashFailed,
46343
- lastCursor
46622
+ lastCursor,
46623
+ highWater: provenMark({
46624
+ clean: !cursorBlocked && textUnknown === 0,
46625
+ finalHighWater,
46626
+ advancedTo: lastCursor !== (markRefused ? baseCursor : args.cursor) ? lastCursor : null
46627
+ }),
46628
+ markRefused,
46629
+ restoresDeferred
46344
46630
  };
46345
46631
  }
46346
46632
  async function applyExtractedText(args) {
@@ -46501,6 +46787,17 @@ async function applyFileRow(args) {
46501
46787
  output
46502
46788
  });
46503
46789
  }
46790
+ if (existing && existing.content_hash === row.content_hash && !existsSync8(abs)) {
46791
+ const verdict = deferRestore(args.recentUnlinks, abs, row.updated_at, now().getTime());
46792
+ if (verdict.defer) {
46793
+ if (verdict.first) {
46794
+ output?.log(fileRestoreDeferredLine({ path: relPath, waitSeconds: RESTORE_DEFERRAL_MS / 1000 }));
46795
+ }
46796
+ return { state, action: "noop", textUnknown, restoreDeferred: true };
46797
+ }
46798
+ } else {
46799
+ presentAgain(args.recentUnlinks, abs);
46800
+ }
46504
46801
  if (existing && existing.content_hash === row.content_hash && existsSync8(abs)) {
46505
46802
  if (!args.forceTextRewalk && textIsCurrent(existing, row, workspaceSlug, relPath)) {
46506
46803
  return { state, action: "noop", textUnknown };
@@ -46553,7 +46850,7 @@ async function applyFileRow(args) {
46553
46850
  const bytes = await downloadBytes(got.row.download_url, fetchImpl);
46554
46851
  if (sha256Bytes(bytes) !== row.content_hash) {
46555
46852
  output?.warn(`Integrity check failed for files/${relPath} (hash mismatch) — skipped this pull.`);
46556
- return { state, action: "skipped" };
46853
+ return { state, action: "skipped", integrityFailed: true };
46557
46854
  }
46558
46855
  const stashFailure = stashUnpushedLocalEdit({
46559
46856
  workspaceSlug,
@@ -46712,6 +47009,8 @@ function textIsCurrent(existing, row, workspaceSlug, relPath) {
46712
47009
  }
46713
47010
  if (existing.text_hash === undefined)
46714
47011
  return false;
47012
+ if (existing.updated_at !== row.updated_at)
47013
+ return false;
46715
47014
  const onDisk = readExtractedText(workspaceSlug, relPath);
46716
47015
  if (onDisk === null)
46717
47016
  return false;
@@ -47023,6 +47322,7 @@ async function replayPermanentDocuments(args) {
47023
47322
  let state = args.state;
47024
47323
  const completed = [];
47025
47324
  let completePass = true;
47325
+ let restoresDeferred = 0;
47026
47326
  for (const marker of args.markers) {
47027
47327
  const candidates = rows.filter((row) => row.deleted_at !== null && row.slug === marker.logical_key && row.permanent_delete === true && typeof row.purge_generation === "string" && typeof row.retirement_sequence === "number");
47028
47328
  candidates.sort((a, b) => b.retirement_sequence - a.retirement_sequence);
@@ -47092,7 +47392,7 @@ async function replayPermanentDocuments(args) {
47092
47392
  for (const row of rows) {
47093
47393
  if (row.deleted_at !== null && row.permanent_delete === true)
47094
47394
  continue;
47095
- const outcome = applyRow(args.workspaceSlug, args.strategy, row, state, args.output, args.suppressor, args.now, args.isFrozen, args.loggedTombstoneSkips);
47395
+ const outcome = applyRow(args.workspaceSlug, args.strategy, row, state, args.output, args.suppressor, args.now, args.isFrozen, args.loggedTombstoneSkips, args.recentUnlinks);
47096
47396
  state = outcome.state;
47097
47397
  if (outcome.action === "written")
47098
47398
  counters.written++;
@@ -47103,9 +47403,12 @@ async function replayPermanentDocuments(args) {
47103
47403
  else if (outcome.action === "slug_refused") {
47104
47404
  counters.refused = (counters.refused ?? 0) + 1;
47105
47405
  completePass = false;
47406
+ } else if (outcome.action === "restore_deferred") {
47407
+ restoresDeferred++;
47408
+ completePass = false;
47106
47409
  }
47107
47410
  }
47108
- return { state, counters, cursor: resultingCursor, completed, completePass };
47411
+ return { state, counters, cursor: resultingCursor, completed, completePass, restoresDeferred };
47109
47412
  }
47110
47413
  async function runSync(workspaceSlug, deps = {}) {
47111
47414
  const out = new Output({
@@ -47226,6 +47529,7 @@ async function runSync(workspaceSlug, deps = {}) {
47226
47529
  committed = state;
47227
47530
  };
47228
47531
  const counters = {};
47532
+ let restoresDeferred = 0;
47229
47533
  try {
47230
47534
  for (const entityType of allEntityTypes()) {
47231
47535
  const strategy = requireStrategy(entityType);
@@ -47247,6 +47551,7 @@ async function runSync(workspaceSlug, deps = {}) {
47247
47551
  now,
47248
47552
  isFrozen: deps.isFrozen,
47249
47553
  loggedTombstoneSkips: deps.loggedTombstoneSkips,
47554
+ recentUnlinks: deps.recentUnlinks,
47250
47555
  commit: (before, after) => {
47251
47556
  const next = commitState(before, after);
47252
47557
  committed = next;
@@ -47256,8 +47561,10 @@ async function runSync(workspaceSlug, deps = {}) {
47256
47561
  state = replay.state;
47257
47562
  commitPending();
47258
47563
  counters[entityType] = replay.counters;
47564
+ restoresDeferred += replay.restoresDeferred;
47259
47565
  if (replay.completePass) {
47260
47566
  updateSyncCursor(slug, entityType, replay.cursor);
47567
+ updateSyncHighWater(slug, entityType, null);
47261
47568
  for (const item of replay.completed) {
47262
47569
  clearFeedRefresh({
47263
47570
  workspaceSlug: slug,
@@ -47270,8 +47577,11 @@ async function runSync(workspaceSlug, deps = {}) {
47270
47577
  continue;
47271
47578
  }
47272
47579
  }
47273
- let cursor = startCursor;
47274
- let lastAdvancedCursor = startCursor;
47580
+ const storedMark = entry2.sync_high_water?.[entityType] ?? null;
47581
+ let walkStart = deps.useHighWater === true && storedMark !== null ? storedMark : startCursor;
47582
+ let cursor = walkStart;
47583
+ let lastAdvancedCursor = walkStart;
47584
+ let finalHighWater = null;
47275
47585
  let cursorBlocked = false;
47276
47586
  let permanentEncountered = false;
47277
47587
  const permanentJournalBlocked = entityType === "documents" && dueDocumentRefreshMarkers(slug, now()) === null;
@@ -47287,13 +47597,26 @@ async function runSync(workspaceSlug, deps = {}) {
47287
47597
  refused: 0
47288
47598
  };
47289
47599
  while (true) {
47290
- const page = await syncEntity({
47291
- strategy,
47292
- baseUrl: entry2.api_base_url,
47293
- pat,
47294
- cursor,
47295
- fetchImpl
47296
- });
47600
+ let page;
47601
+ try {
47602
+ page = await syncEntity({
47603
+ strategy,
47604
+ baseUrl: entry2.api_base_url,
47605
+ pat,
47606
+ cursor,
47607
+ fetchImpl
47608
+ });
47609
+ } catch (err) {
47610
+ if (cursor === walkStart && walkStart !== startCursor && err instanceof ApiError && err.status === 400) {
47611
+ updateSyncHighWater(slug, entityType, null);
47612
+ out.warn(`${entityType}: the stored high-water mark was refused; reading from the cursor instead.`);
47613
+ walkStart = startCursor;
47614
+ cursor = startCursor;
47615
+ lastAdvancedCursor = startCursor;
47616
+ continue;
47617
+ }
47618
+ throw err;
47619
+ }
47297
47620
  for (const row of page.rows) {
47298
47621
  ctr.fetched++;
47299
47622
  if (entityType === "documents" && row.deleted_at !== null && row.permanent_delete === true && row.purge_generation !== undefined) {
@@ -47314,7 +47637,7 @@ async function runSync(workspaceSlug, deps = {}) {
47314
47637
  }
47315
47638
  let outcome;
47316
47639
  try {
47317
- outcome = applyRow(slug, strategy, row, state, out, deps.suppressor, now, deps.isFrozen, deps.loggedTombstoneSkips);
47640
+ outcome = applyRow(slug, strategy, row, state, out, deps.suppressor, now, deps.isFrozen, deps.loggedTombstoneSkips, deps.recentUnlinks);
47318
47641
  } catch (err) {
47319
47642
  if (!(err instanceof MirrorSymlinkError))
47320
47643
  throw err;
@@ -47337,14 +47660,19 @@ async function runSync(workspaceSlug, deps = {}) {
47337
47660
  ctr.deleted++;
47338
47661
  else if (outcome.action === "moved")
47339
47662
  ctr.moved = (ctr.moved ?? 0) + 1;
47340
- else if (outcome.action === "slug_refused") {
47663
+ else if (outcome.action === "restore_deferred") {
47664
+ cursorBlocked = true;
47665
+ restoresDeferred++;
47666
+ } else if (outcome.action === "slug_refused") {
47341
47667
  ctr.refused = (ctr.refused ?? 0) + 1;
47342
47668
  cursorBlocked = true;
47343
47669
  }
47344
47670
  }
47345
47671
  commitPending();
47346
- if (page.next_cursor === null)
47672
+ if (page.next_cursor === null) {
47673
+ finalHighWater = page.high_water ?? null;
47347
47674
  break;
47675
+ }
47348
47676
  if (!cursorBlocked)
47349
47677
  lastAdvancedCursor = page.next_cursor;
47350
47678
  cursor = page.next_cursor;
@@ -47368,6 +47696,7 @@ async function runSync(workspaceSlug, deps = {}) {
47368
47696
  now,
47369
47697
  isFrozen: deps.isFrozen,
47370
47698
  loggedTombstoneSkips: deps.loggedTombstoneSkips,
47699
+ recentUnlinks: deps.recentUnlinks,
47371
47700
  commit: (before, after) => {
47372
47701
  const next = commitState(before, after);
47373
47702
  committed = next;
@@ -47377,8 +47706,10 @@ async function runSync(workspaceSlug, deps = {}) {
47377
47706
  state = replay.state;
47378
47707
  commitPending();
47379
47708
  counters[entityType] = replay.counters;
47709
+ restoresDeferred += replay.restoresDeferred;
47380
47710
  if (replay.completePass) {
47381
47711
  updateSyncCursor(slug, entityType, replay.cursor);
47712
+ updateSyncHighWater(slug, entityType, null);
47382
47713
  for (const item of replay.completed) {
47383
47714
  clearFeedRefresh({
47384
47715
  workspaceSlug: slug,
@@ -47390,12 +47721,21 @@ async function runSync(workspaceSlug, deps = {}) {
47390
47721
  }
47391
47722
  continue;
47392
47723
  }
47393
- if (lastAdvancedCursor !== startCursor) {
47724
+ if (walkStart === startCursor && lastAdvancedCursor !== walkStart) {
47394
47725
  updateSyncCursor(slug, entityType, lastAdvancedCursor);
47395
47726
  }
47727
+ const mark = provenMark({
47728
+ clean: !cursorBlocked,
47729
+ finalHighWater,
47730
+ advancedTo: lastAdvancedCursor !== walkStart ? lastAdvancedCursor : null
47731
+ });
47732
+ if (mark !== null)
47733
+ updateSyncHighWater(slug, entityType, mark);
47396
47734
  }
47397
47735
  const needsDerivedTextRewalk = state.derived_text_version !== DERIVED_TEXT_VERSION;
47398
- const filesStartCursor = needsDerivedTextRewalk ? null : entry2.sync_cursors?.["files"] ?? null;
47736
+ const filesBaseCursor = needsDerivedTextRewalk ? null : entry2.sync_cursors?.["files"] ?? null;
47737
+ const filesMark = entry2.sync_high_water?.["files"] ?? null;
47738
+ const filesStartCursor = !needsDerivedTextRewalk && deps.useHighWater === true && filesMark !== null ? filesMark : filesBaseCursor;
47399
47739
  if (needsDerivedTextRewalk) {
47400
47740
  out.info("Re-reading the file index once to refresh extracted text (template changed).");
47401
47741
  }
@@ -47405,6 +47745,8 @@ async function runSync(workspaceSlug, deps = {}) {
47405
47745
  pat,
47406
47746
  state,
47407
47747
  cursor: filesStartCursor,
47748
+ baseCursor: filesBaseCursor,
47749
+ recentUnlinks: deps.recentUnlinks,
47408
47750
  fetchImpl,
47409
47751
  now,
47410
47752
  output: out,
@@ -47418,7 +47760,8 @@ async function runSync(workspaceSlug, deps = {}) {
47418
47760
  if (filesResult.state !== state) {
47419
47761
  state = filesResult.state;
47420
47762
  }
47421
- if (needsDerivedTextRewalk && filesResult.textUnknown === 0) {
47763
+ restoresDeferred += filesResult.restoresDeferred ?? 0;
47764
+ if (needsDerivedTextRewalk && filesResult.textUnknown === 0 && (filesResult.restoresDeferred ?? 0) === 0) {
47422
47765
  state = { ...state, derived_text_version: DERIVED_TEXT_VERSION };
47423
47766
  }
47424
47767
  counters["files"] = {
@@ -47427,10 +47770,19 @@ async function runSync(workspaceSlug, deps = {}) {
47427
47770
  deleted: filesResult.deleted + filesResult.textDeleted
47428
47771
  };
47429
47772
  commitPending();
47430
- if (filesResult.lastCursor !== filesStartCursor) {
47773
+ const filesWalkStart = filesResult.markRefused ? filesBaseCursor : filesStartCursor;
47774
+ const filesWalkedFromCursor = filesWalkStart === filesBaseCursor || filesResult.permanentReplayComplete === true;
47775
+ if (filesWalkedFromCursor && filesResult.lastCursor !== filesBaseCursor) {
47431
47776
  updateSyncCursor(slug, "files", filesResult.lastCursor);
47432
47777
  }
47778
+ if (filesResult.markRefused) {
47779
+ updateSyncHighWater(slug, "files", null);
47780
+ out.warn("files: the stored high-water mark was refused; reading from the cursor instead.");
47781
+ }
47782
+ if (filesResult.highWater)
47783
+ updateSyncHighWater(slug, "files", filesResult.highWater);
47433
47784
  if (filesResult.permanentReplayComplete) {
47785
+ updateSyncHighWater(slug, "files", null);
47434
47786
  for (const item of filesResult.permanentRefreshes ?? []) {
47435
47787
  clearFeedRefresh({
47436
47788
  workspaceSlug: slug,
@@ -47618,7 +47970,7 @@ async function runSync(workspaceSlug, deps = {}) {
47618
47970
  const totalRefused = Object.values(counters).reduce((sum, c) => sum + (c.refused ?? 0), 0);
47619
47971
  if (totalWritten === 0 && totalDeleted === 0 && totalMoved === 0 && totalRefused === 0) {
47620
47972
  out.success("Already up to date.");
47621
- return { exitCode: 0, applied: 0 };
47973
+ return { exitCode: 0, applied: 0, restoresDeferred };
47622
47974
  }
47623
47975
  const elapsed = ((now().getTime() - startedAt.getTime()) / 1000).toFixed(1);
47624
47976
  for (const [type, c] of Object.entries(counters)) {
@@ -47636,9 +47988,9 @@ async function runSync(workspaceSlug, deps = {}) {
47636
47988
  out.info(` ${type}: ${parts.join(", ")}`);
47637
47989
  }
47638
47990
  out.success(`Done in ${elapsed}s.`);
47639
- return { exitCode: 0, applied: totalWritten + totalMoved + totalDeleted };
47991
+ return { exitCode: 0, applied: totalWritten + totalMoved + totalDeleted, restoresDeferred };
47640
47992
  }
47641
- function applyRow(workspaceSlug, strategy, row, state, out, suppressor, now = () => new Date, isFrozen, loggedTombstoneSkips) {
47993
+ function applyRow(workspaceSlug, strategy, row, state, out, suppressor, now = () => new Date, isFrozen, loggedTombstoneSkips, recentUnlinks) {
47642
47994
  if (isFrozen?.(strategy.type, row.id)) {
47643
47995
  return { state, action: "skipped" };
47644
47996
  }
@@ -47750,7 +48102,22 @@ function applyRow(workspaceSlug, strategy, row, state, out, suppressor, now = ()
47750
48102
  const writeSlug = relocation.writeSlug;
47751
48103
  if (existingEntry && existingEntry.updated_at === live.updated_at && relocation.kind === "not_needed") {
47752
48104
  const path5 = entityFilePath(workspaceSlug, strategy.type, live.slug);
47753
- if (existsSync9(path5)) {
48105
+ const present2 = existsSync9(path5);
48106
+ if (!present2) {
48107
+ const verdict = deferRestore(recentUnlinks, path5, live.updated_at, now().getTime());
48108
+ if (verdict.defer) {
48109
+ if (verdict.first) {
48110
+ out.log(entityRestoreDeferredLine({
48111
+ entityType: strategy.type,
48112
+ slug: live.slug,
48113
+ waitSeconds: RESTORE_DEFERRAL_MS / 1000
48114
+ }));
48115
+ }
48116
+ return { state, action: "restore_deferred" };
48117
+ }
48118
+ }
48119
+ if (present2) {
48120
+ presentAgain(recentUnlinks, path5);
47754
48121
  if (strategy.type === "artists") {
47755
48122
  const artistLive = live;
47756
48123
  state = applyArtistMemoryOnPull({
@@ -49187,13 +49554,18 @@ async function runStart(workspaceSlug, deps = {}) {
49187
49554
  return { exitCode: 0 };
49188
49555
  }
49189
49556
  writeDaemonPid(slug, process.pid, (deps.now ?? (() => new Date))());
49190
- const watcher = startWatcher({ workspaceSlug: slug, getStateEntry, classifyPath }, handler);
49557
+ const ambient = createAmbientPollWiring({
49558
+ now: () => (deps.now?.() ?? new Date).getTime(),
49559
+ baseReadIntervalMs: baseReadIntervalMs()
49560
+ });
49561
+ const watcher = startWatcher({ workspaceSlug: slug, getStateEntry, classifyPath, onUnlink: ambient.onUnlink }, handler);
49191
49562
  const quietSink = discardSink();
49192
49563
  let draining = false;
49193
49564
  let lastHeartbeatMs = 0;
49194
49565
  const pullLoop = new PullLoop({
49195
49566
  runOnce: async () => {
49196
- const result = await runSync(slug, {
49567
+ const result = await ambient.poll((sc2) => runSync(slug, {
49568
+ ...sc2,
49197
49569
  fetchImpl,
49198
49570
  keychain,
49199
49571
  now: deps.now,
@@ -49205,7 +49577,7 @@ async function runStart(workspaceSlug, deps = {}) {
49205
49577
  isFileFrozen,
49206
49578
  loggedTombstoneSkips,
49207
49579
  loggedStashNotices
49208
- });
49580
+ }));
49209
49581
  if (result.exitCode === 0) {
49210
49582
  const applied = result.applied ?? 0;
49211
49583
  if (applied > 0) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@plinth-music/cli",
3
- "version": "0.23.0",
3
+ "version": "0.23.1",
4
4
  "description": "Plinth capstone sync daemon and CLI",
5
5
  "type": "module",
6
6
  "private": false,