things-api 0.9.0 → 0.10.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 (214) hide show
  1. package/README.md +30 -11
  2. package/dist/audit/log.js +56 -2
  3. package/dist/audit/log.js.map +1 -1
  4. package/dist/audit/schema.d.ts +26 -8
  5. package/dist/audit/schema.js +42 -1
  6. package/dist/audit/schema.js.map +1 -1
  7. package/dist/cli/commands/area.d.ts +26 -3
  8. package/dist/cli/commands/area.js +125 -70
  9. package/dist/cli/commands/area.js.map +1 -1
  10. package/dist/cli/commands/doctor.d.ts +1 -1
  11. package/dist/cli/commands/doctor.js +44 -5
  12. package/dist/cli/commands/doctor.js.map +1 -1
  13. package/dist/cli/commands/mcp.js +23 -4
  14. package/dist/cli/commands/mcp.js.map +1 -1
  15. package/dist/cli/commands/project.d.ts +18 -1
  16. package/dist/cli/commands/project.js +69 -31
  17. package/dist/cli/commands/project.js.map +1 -1
  18. package/dist/cli/commands/reads.js +423 -157
  19. package/dist/cli/commands/reads.js.map +1 -1
  20. package/dist/cli/commands/repeat-flags.d.ts +20 -0
  21. package/dist/cli/commands/repeat-flags.js +76 -0
  22. package/dist/cli/commands/repeat-flags.js.map +1 -0
  23. package/dist/cli/commands/setup.js +2 -3
  24. package/dist/cli/commands/setup.js.map +1 -1
  25. package/dist/cli/commands/show.js +47 -24
  26. package/dist/cli/commands/show.js.map +1 -1
  27. package/dist/cli/commands/todo.d.ts +1 -1
  28. package/dist/cli/commands/todo.js +7 -6
  29. package/dist/cli/commands/todo.js.map +1 -1
  30. package/dist/cli/commands/writes.js +378 -148
  31. package/dist/cli/commands/writes.js.map +1 -1
  32. package/dist/cli/did-you-mean.d.ts +1 -1
  33. package/dist/cli/excess-args.d.ts +15 -0
  34. package/dist/cli/excess-args.js +51 -0
  35. package/dist/cli/excess-args.js.map +1 -0
  36. package/dist/cli/glyphs.d.ts +39 -2
  37. package/dist/cli/glyphs.js +91 -21
  38. package/dist/cli/glyphs.js.map +1 -1
  39. package/dist/cli/help.d.ts +54 -0
  40. package/dist/cli/help.js +355 -0
  41. package/dist/cli/help.js.map +1 -0
  42. package/dist/cli/main.js +32 -25
  43. package/dist/cli/main.js.map +1 -1
  44. package/dist/cli/period.d.ts +7 -0
  45. package/dist/cli/period.js +12 -0
  46. package/dist/cli/period.js.map +1 -1
  47. package/dist/cli/read-driver.d.ts +26 -19
  48. package/dist/cli/read-driver.js +67 -33
  49. package/dist/cli/read-driver.js.map +1 -1
  50. package/dist/cli/render.d.ts +92 -28
  51. package/dist/cli/render.js +318 -97
  52. package/dist/cli/render.js.map +1 -1
  53. package/dist/cli/resolve-invocation.d.ts +42 -4
  54. package/dist/cli/resolve-invocation.js +97 -14
  55. package/dist/cli/resolve-invocation.js.map +1 -1
  56. package/dist/cli/tag-filters.d.ts +59 -0
  57. package/dist/cli/tag-filters.js +57 -0
  58. package/dist/cli/tag-filters.js.map +1 -0
  59. package/dist/cli/verb-hint.d.ts +25 -0
  60. package/dist/cli/verb-hint.js +138 -0
  61. package/dist/cli/verb-hint.js.map +1 -0
  62. package/dist/cli/width.d.ts +135 -0
  63. package/dist/cli/width.js +313 -0
  64. package/dist/cli/width.js.map +1 -0
  65. package/dist/client.d.ts +137 -24
  66. package/dist/client.js +105 -16
  67. package/dist/client.js.map +1 -1
  68. package/dist/config.d.ts +11 -0
  69. package/dist/config.js +3 -0
  70. package/dist/config.js.map +1 -1
  71. package/dist/contracts.d.ts +87 -17
  72. package/dist/contracts.js +44 -1
  73. package/dist/contracts.js.map +1 -1
  74. package/dist/db/fingerprint.d.ts +12 -0
  75. package/dist/db/fingerprint.js +15 -1
  76. package/dist/db/fingerprint.js.map +1 -1
  77. package/dist/db/locate.js +1 -1
  78. package/dist/db/locate.js.map +1 -1
  79. package/dist/diagnose.d.ts +63 -0
  80. package/dist/diagnose.js +38 -1
  81. package/dist/diagnose.js.map +1 -1
  82. package/dist/index.d.ts +30 -4
  83. package/dist/index.js +35 -2
  84. package/dist/index.js.map +1 -1
  85. package/dist/mcp/server.d.ts +9 -1
  86. package/dist/mcp/server.js +691 -133
  87. package/dist/mcp/server.js.map +1 -1
  88. package/dist/model/entities.d.ts +31 -7
  89. package/dist/model/entities.js.map +1 -1
  90. package/dist/model/mappers.d.ts +7 -0
  91. package/dist/model/mappers.js +7 -2
  92. package/dist/model/mappers.js.map +1 -1
  93. package/dist/model/serialize.d.ts +28 -0
  94. package/dist/model/serialize.js +80 -0
  95. package/dist/model/serialize.js.map +1 -0
  96. package/dist/model/when-sugar.d.ts +47 -0
  97. package/dist/model/when-sugar.js +45 -0
  98. package/dist/model/when-sugar.js.map +1 -0
  99. package/dist/read/area-view.d.ts +8 -1
  100. package/dist/read/area-view.js +49 -13
  101. package/dist/read/area-view.js.map +1 -1
  102. package/dist/read/filter-contract.d.ts +117 -0
  103. package/dist/read/filter-contract.js +78 -0
  104. package/dist/read/filter-contract.js.map +1 -0
  105. package/dist/read/predicates.d.ts +30 -0
  106. package/dist/read/predicates.js +30 -0
  107. package/dist/read/predicates.js.map +1 -1
  108. package/dist/read/project-view.d.ts +12 -1
  109. package/dist/read/project-view.js +39 -8
  110. package/dist/read/project-view.js.map +1 -1
  111. package/dist/read/queries.d.ts +149 -6
  112. package/dist/read/queries.js +250 -31
  113. package/dist/read/queries.js.map +1 -1
  114. package/dist/read/sections.d.ts +51 -0
  115. package/dist/read/sections.js +37 -0
  116. package/dist/read/sections.js.map +1 -0
  117. package/dist/read/sidebar-order.js +2 -1
  118. package/dist/read/sidebar-order.js.map +1 -1
  119. package/dist/read/tags.d.ts +27 -3
  120. package/dist/read/tags.js +83 -12
  121. package/dist/read/tags.js.map +1 -1
  122. package/dist/read/truncation.d.ts +71 -0
  123. package/dist/read/{pagination.js → truncation.js} +103 -94
  124. package/dist/read/truncation.js.map +1 -0
  125. package/dist/read/views.d.ts +140 -16
  126. package/dist/read/views.js +264 -67
  127. package/dist/read/views.js.map +1 -1
  128. package/dist/surface-copy.d.ts +19 -0
  129. package/dist/surface-copy.js +32 -0
  130. package/dist/surface-copy.js.map +1 -1
  131. package/dist/sync-health.d.ts +78 -0
  132. package/dist/sync-health.js +312 -0
  133. package/dist/sync-health.js.map +1 -0
  134. package/dist/write/accessibility-probe.d.ts +12 -0
  135. package/dist/write/accessibility-probe.js +63 -0
  136. package/dist/write/accessibility-probe.js.map +1 -0
  137. package/dist/write/automation-probe.d.ts +7 -0
  138. package/dist/write/automation-probe.js +8 -1
  139. package/dist/write/automation-probe.js.map +1 -1
  140. package/dist/write/batch.js +4 -2
  141. package/dist/write/batch.js.map +1 -1
  142. package/dist/write/capabilities.d.ts +8 -0
  143. package/dist/write/capabilities.js +13 -6
  144. package/dist/write/capabilities.js.map +1 -1
  145. package/dist/write/commands.d.ts +2 -0
  146. package/dist/write/commands.js +562 -48
  147. package/dist/write/commands.js.map +1 -1
  148. package/dist/write/edit-checklist.js +3 -2
  149. package/dist/write/edit-checklist.js.map +1 -1
  150. package/dist/write/guards.d.ts +2 -7
  151. package/dist/write/guards.js +64 -6
  152. package/dist/write/guards.js.map +1 -1
  153. package/dist/write/heading.js +2 -0
  154. package/dist/write/heading.js.map +1 -1
  155. package/dist/write/lock.d.ts +40 -2
  156. package/dist/write/lock.js +91 -14
  157. package/dist/write/lock.js.map +1 -1
  158. package/dist/write/make-repeating-project.d.ts +4 -0
  159. package/dist/write/make-repeating-project.js +253 -0
  160. package/dist/write/make-repeating-project.js.map +1 -0
  161. package/dist/write/operations.d.ts +145 -1
  162. package/dist/write/operations.js +48 -0
  163. package/dist/write/operations.js.map +1 -1
  164. package/dist/write/pipeline.d.ts +19 -0
  165. package/dist/write/pipeline.js +109 -26
  166. package/dist/write/pipeline.js.map +1 -1
  167. package/dist/write/pre-state.d.ts +68 -3
  168. package/dist/write/pre-state.js +102 -3
  169. package/dist/write/pre-state.js.map +1 -1
  170. package/dist/write/reopen.js +3 -2
  171. package/dist/write/reopen.js.map +1 -1
  172. package/dist/write/reorder.js +27 -17
  173. package/dist/write/reorder.js.map +1 -1
  174. package/dist/write/repeat-rule.d.ts +22 -0
  175. package/dist/write/repeat-rule.js +258 -0
  176. package/dist/write/repeat-rule.js.map +1 -0
  177. package/dist/write/reversibility.js +49 -0
  178. package/dist/write/reversibility.js.map +1 -1
  179. package/dist/write/tag-refs.d.ts +47 -0
  180. package/dist/write/tag-refs.js +126 -0
  181. package/dist/write/tag-refs.js.map +1 -0
  182. package/dist/write/undo.d.ts +74 -5
  183. package/dist/write/undo.js +493 -80
  184. package/dist/write/undo.js.map +1 -1
  185. package/dist/write/vectors/registry.d.ts +17 -1
  186. package/dist/write/vectors/registry.js +18 -2
  187. package/dist/write/vectors/registry.js.map +1 -1
  188. package/dist/write/vectors/types.d.ts +119 -3
  189. package/dist/write/vectors/ui-certification.d.ts +48 -0
  190. package/dist/write/vectors/ui-certification.js +46 -0
  191. package/dist/write/vectors/ui-certification.js.map +1 -0
  192. package/dist/write/vectors/ui-drag.d.ts +188 -0
  193. package/dist/write/vectors/ui-drag.js +1095 -0
  194. package/dist/write/vectors/ui-drag.js.map +1 -0
  195. package/dist/write/vectors/ui-recipes.d.ts +70 -0
  196. package/dist/write/vectors/ui-recipes.js +558 -0
  197. package/dist/write/vectors/ui-recipes.js.map +1 -0
  198. package/dist/write/vectors/ui.d.ts +137 -0
  199. package/dist/write/vectors/ui.js +656 -0
  200. package/dist/write/vectors/ui.js.map +1 -0
  201. package/dist/write/verify/delta.d.ts +31 -3
  202. package/dist/write/verify/delta.js +22 -6
  203. package/dist/write/verify/delta.js.map +1 -1
  204. package/dist/write/verify/poller.js +1 -0
  205. package/dist/write/verify/poller.js.map +1 -1
  206. package/package.json +1 -1
  207. package/dist/cli/exit-codes.d.ts +0 -26
  208. package/dist/cli/exit-codes.js +0 -26
  209. package/dist/cli/exit-codes.js.map +0 -1
  210. package/dist/cli/output.d.ts +0 -42
  211. package/dist/cli/output.js +0 -16
  212. package/dist/cli/output.js.map +0 -1
  213. package/dist/read/pagination.d.ts +0 -104
  214. package/dist/read/pagination.js.map +0 -1
@@ -3,19 +3,84 @@ import { logBoundary, markLogged } from "./log-boundary.js";
3
3
  import { mapProject, mapTodo } from "../model/mappers.js";
4
4
  import { projectOccurrences } from "../model/occurrences.js";
5
5
  import { decodeRecurrenceRule } from "../model/recurrence.js";
6
- import { fetchTagsForTasks, fetchTaskRows, makeHeadingProjectResolver, makeRefResolver, NOT_TEMPLATE, resolveAreaUuid, resolveProjectUuid, resolveTagUuid, tagScopeBinds, tagScopeSql, tagWithDescendants, } from "./queries.js";
7
- import { ANYTIME_SELF, CONTAINER_UNTRASHED, EFF_PROJECT, LIVE, OPEN, PROJECT_ANYTIME_ACTIVE, } from "./predicates.js";
6
+ import { directTagScopeSql, directUntaggedScopeSql, fetchTagsForTasks, fetchTaskRows, makeHeadingProjectResolver, makeRefResolver, NOT_TEMPLATE, resolveAreaUuid, resolveProjectUuid, resolveTagUuid, tagScopeBinds, tagScopeSql, tagWithDescendants, untaggedScopeSql, } from "./queries.js";
7
+ import { ANYTIME_SELF, CONTAINER_UNTRASHED, EFF_PROJECT, LIVE, OPEN, OPEN_OR_UNSWEPT, OVERDUE, PROJECT_ANYTIME_ACTIVE, } from "./predicates.js";
8
8
  import { groupBySidebar } from "./sidebar-order.js";
9
9
  import { areasView } from "./tags.js";
10
10
  import { compareSearchMatches } from "./search-rank.js";
11
- function tagFilter(db, filter) {
12
- if (filter?.tag === undefined)
11
+ /**
12
+ * Escape a user term for a SQL LIKE pattern so `%` and `_` match LITERALLY,
13
+ * not as wildcards (search "my_task" must not match "myXtask"; "50%" must not
14
+ * match "50"-anything). Backslash is the escape character — escape it first,
15
+ * then the two wildcard metacharacters. Every LIKE fed an escaped term must
16
+ * append `ESCAPE '\'` (see {@link LIKE_ESCAPE}).
17
+ */
18
+ function escapeLike(term) {
19
+ return term.replace(/[\\%_]/g, (c) => `\\${c}`);
20
+ }
21
+ /** SQL clause suffix pairing an escaped LIKE pattern with its escape char. */
22
+ const LIKE_ESCAPE = " ESCAPE '\\'";
23
+ /**
24
+ * Compose the tag scope for a filter into an AND-chained SQL fragment plus its
25
+ * binds. Every clause AND-combines: the `untagged` negation and each `--tag`
26
+ * ref (each ref independently resolved and descendant-expanded per `exactTag`).
27
+ * The mutually-exclusive combination (`untagged` with a tag-presence flag) is
28
+ * refused at the CLI/MCP surfaces; here it would simply AND to an empty result.
29
+ * Splices in before {@link overdueFilter} so the tag binds precede the overdue
30
+ * bind.
31
+ *
32
+ * `options.container` selects the CONTAINER semantics (`project show` /
33
+ * `area show` / the `projects` list): the view's own inheritance hop is dropped,
34
+ * so `--tag` matches a DIRECT assignment (still descendant-expanded) and
35
+ * `--untagged` means "no direct tag". Every child inherits its container's tags,
36
+ * so an inheritance-inclusive filter there is vacuous — direct-on-the-row is the
37
+ * useful, GUI-faithful behavior. FLAT views omit the option and keep the
38
+ * inheritance-inclusive relation.
39
+ */
40
+ export function tagFilter(db, filter, options) {
41
+ const container = options?.container === true;
42
+ const clauses = [];
43
+ const binds = [];
44
+ if (filter?.untagged === true)
45
+ clauses.push(container ? directUntaggedScopeSql() : untaggedScopeSql());
46
+ const exact = filter?.exactTag === true;
47
+ const tagRefs = [...(filter?.tags ?? []), ...(filter?.tag !== undefined ? [filter.tag] : [])];
48
+ const expand = (ref) => {
49
+ const target = resolveTagUuid(db, ref);
50
+ return exact ? [target] : tagWithDescendants(db, target);
51
+ };
52
+ for (const ref of tagRefs) {
53
+ const uuids = expand(ref);
54
+ if (container) {
55
+ clauses.push(directTagScopeSql(uuids.length));
56
+ binds.push(...uuids);
57
+ }
58
+ else {
59
+ clauses.push(tagScopeSql(uuids.length));
60
+ binds.push(...tagScopeBinds(uuids));
61
+ }
62
+ }
63
+ if (clauses.length === 0)
64
+ return { sql: "", binds: [] };
65
+ return { sql: ` AND ${clauses.join(" AND ")}`, binds };
66
+ }
67
+ /**
68
+ * The `--overdue` content scope as a spliceable SQL fragment (empty when off).
69
+ * Appended AFTER {@link tagFilter} in every host view so it intersects with the
70
+ * tag scope, and its single packed-today bind trails the tag binds in the same
71
+ * order. `packedToday` is encodePackedDate(localToday(now)) — the caller's
72
+ * injected clock — never a hardcoded date.
73
+ */
74
+ function overdueFilter(filter, packedToday) {
75
+ if (filter?.overdue !== true)
13
76
  return { sql: "", binds: [] };
14
- const target = resolveTagUuid(db, filter.tag);
15
- const uuids = filter.exactTag === true ? [target] : tagWithDescendants(db, target);
16
- return { sql: ` AND ${tagScopeSql(uuids.length)}`, binds: tagScopeBinds(uuids) };
77
+ return { sql: ` AND ${OVERDUE}`, binds: [packedToday] };
17
78
  }
18
- function materialize(db, rows) {
79
+ // `boundary` is REQUIRED (no wall-clock default): every caller threads the
80
+ // view's injected `now` via logBoundary(db, now) so the `logged` flag it stamps
81
+ // honors the same clock as the SQL membership filter (a pinned test clock, a
82
+ // lab run) — nothing here silently reaches for real time.
83
+ function materialize(db, rows, boundary) {
19
84
  const refs = makeRefResolver(db);
20
85
  const headingProject = makeHeadingProjectResolver(db);
21
86
  const tags = fetchTagsForTasks(db, rows.map((r) => r.uuid));
@@ -30,12 +95,18 @@ function materialize(db, rows) {
30
95
  }
31
96
  return todo;
32
97
  });
33
- return markLogged(items, logBoundary(db));
98
+ // The caller may pass the boundary it already used for the SQL membership
99
+ // filter so the `logged` flag and the row's presence agree (a checked-unswept
100
+ // row must materialize with logged=false).
101
+ return markLogged(items, boundary);
34
102
  }
103
+ /** Badge = OPEN members only (the GUI badge counts remaining work). */
104
+ const isOpen = (i) => i.status === "open";
35
105
  export function todayView(db, now, filter) {
36
106
  const todayIso = localToday(now);
37
107
  const packedToday = encodePackedDate(todayIso);
38
108
  const tf = tagFilter(db, filter);
109
+ const of = overdueFilter(filter, packedToday);
39
110
  // Membership + comparator per the UI-oracle research (lab, 2026-07-04;
40
111
  // docs/lab/today-order-research.md, two reproducing runs + exact live
41
112
  // reconciliation: 405 − 12 suppressed = 393 = the UI's own count):
@@ -47,59 +118,165 @@ export function todayView(db, now, filter) {
47
118
  // (the date the item ENTERED Today: its startDate, or its deadline for
48
119
  // deadline-driven members), then todayIndex ASC, then uuid (observed
49
120
  // stable tiebreak).
50
- const rows = fetchTaskRows(db, `${OPEN} AND ${CONTAINER_UNTRASHED} AND (
121
+ // Membership is OPEN_OR_UNSWEPT (not OPEN): a checked row the log-move sweep
122
+ // has not yet passed keeps its comparator slot in place (GUI-parity ruling
123
+ // 2026-07-14). The boundary is threaded into both the SQL filter and
124
+ // materialize so the row's presence and its `logged` flag agree.
125
+ const boundary = logBoundary(db, now);
126
+ const rows = fetchTaskRows(db, `${OPEN_OR_UNSWEPT} AND ${CONTAINER_UNTRASHED} AND (
51
127
  (t.startDate IS NOT NULL AND t.startDate <= ? AND t.start IN (1, 2))
52
128
  OR (t.deadline IS NOT NULL AND t.deadline <= ? AND t.startDate IS NULL
53
129
  AND (t.deadlineSuppressionDate IS NULL OR t.deadlineSuppressionDate < t.deadline))
54
- )${tf.sql}
130
+ )${tf.sql}${of.sql}
55
131
  ORDER BY t.startBucket ASC,
56
132
  COALESCE(t.todayIndexReferenceDate, t.startDate, t.deadline) DESC,
57
- t.todayIndex ASC, t.uuid ASC`, [packedToday, packedToday, ...tf.binds]);
58
- const items = materialize(db, rows);
133
+ t.todayIndex ASC, t.uuid ASC`, [boundary.getTime() / 1000, packedToday, packedToday, ...tf.binds, ...of.binds]);
134
+ const items = materialize(db, rows, boundary);
59
135
  // Evening membership expires daily: raw startBucket=1 counts only while
60
136
  // startDate is exactly today; stale evening items belong to Today proper.
61
137
  const isEvening = (i) => i.todaySection === "evening" && i.startDate === todayIso;
62
- const dueOrOverdue = items.filter((i) => i.deadline !== null && i.deadline <= todayIso).length;
138
+ const evening = items.filter(isEvening);
139
+ const dueIn = (list) => list.filter((i) => i.deadline !== null && i.deadline <= todayIso).length;
140
+ // Badge = OPEN members only (the GUI badge counts remaining work). A
141
+ // checked-but-unswept row is present in the list but never moves the badge —
142
+ // this preserves the exact live reconciliation computed under OPEN membership.
143
+ // The evening filter mirrors the tag filter's badge treatment: the badge
144
+ // reflects exactly the members the view now returns (here, evening only).
145
+ if (filter?.eveningOnly === true) {
146
+ const openEvening = evening.filter(isOpen);
147
+ const eveningDue = dueIn(openEvening);
148
+ return {
149
+ today: [],
150
+ evening,
151
+ badge: { dueOrOverdue: eveningDue, other: openEvening.length - eveningDue },
152
+ };
153
+ }
154
+ const openItems = items.filter(isOpen);
155
+ const dueOrOverdue = dueIn(openItems);
63
156
  return {
64
157
  today: items.filter((i) => !isEvening(i)),
65
- evening: items.filter(isEvening),
66
- badge: { dueOrOverdue, other: items.length - dueOrOverdue },
158
+ evening,
159
+ badge: { dueOrOverdue, other: openItems.length - dueOrOverdue },
67
160
  };
68
161
  }
69
- export function inboxView(db, filter) {
162
+ export function inboxView(db, now, filter) {
70
163
  const tf = tagFilter(db, filter);
71
- const rows = fetchTaskRows(db, `${OPEN} AND t.start = 0${tf.sql} ORDER BY t."index" ASC`, tf.binds);
72
- return materialize(db, rows);
164
+ // `now` is threaded only for the `--overdue` boundary (the inbox order and
165
+ // the since/until window key on epoch creationDate, not on today).
166
+ const of = overdueFilter(filter, encodePackedDate(localToday(now)));
167
+ const where = [OPEN, "t.start = 0"];
168
+ const binds = [];
169
+ // creationDate is an epoch REAL (Unix seconds) — mirror the changes/logbook
170
+ // comparison (getTime()/1000 against the raw column), never encodePackedDate.
171
+ if (filter?.since !== undefined) {
172
+ where.push("t.creationDate >= ?");
173
+ binds.push(filter.since.getTime() / 1000);
174
+ }
175
+ if (filter?.until !== undefined) {
176
+ where.push("t.creationDate <= ?");
177
+ binds.push(filter.until.getTime() / 1000);
178
+ }
179
+ const rows = fetchTaskRows(db, `${where.join(" AND ")}${tf.sql}${of.sql} ORDER BY t."index" ASC`, [...binds, ...tf.binds, ...of.binds]);
180
+ return materialize(db, rows, logBoundary(db, now));
73
181
  }
74
182
  export function anytimeView(db, now, filter) {
75
183
  const packedToday = encodePackedDate(localToday(now));
76
184
  const tf = tagFilter(db, filter);
185
+ const of = overdueFilter(filter, packedToday);
77
186
  // Mirrors UI membership: every active item, including Today members
78
187
  // (starred in the UI) and pending-promotion rows (start=2, past-dated) —
79
- // MINUS children of non-active containers (the project cascade).
80
- const rows = fetchTaskRows(db, `${OPEN} AND ${ANYTIME_SELF("t")}
81
- AND ${PROJECT_ANYTIME_ACTIVE}${tf.sql} ORDER BY t."index" ASC`, [packedToday, packedToday, packedToday, packedToday, ...tf.binds]);
82
- return groupBySidebar(db, materialize(db, rows));
188
+ // MINUS children of non-active containers (the project cascade). Membership
189
+ // is OPEN_OR_UNSWEPT (GUI-parity ruling 2026-07-14): a checked row the sweep
190
+ // has not passed keeps its index slot. PROJECT_ANYTIME_ACTIVE still requires
191
+ // the parent project be open, so a closed-but-unswept project's children stay
192
+ // cascade-excluded — the checked project row represents them. The boundary is
193
+ // threaded into the SQL filter and materialize so presence and `logged` agree.
194
+ const boundary = logBoundary(db, now);
195
+ const rows = fetchTaskRows(db, `${OPEN_OR_UNSWEPT} AND ${ANYTIME_SELF("t")}
196
+ AND ${PROJECT_ANYTIME_ACTIVE}${tf.sql}${of.sql} ORDER BY t."index" ASC`, [
197
+ boundary.getTime() / 1000,
198
+ packedToday,
199
+ packedToday,
200
+ packedToday,
201
+ packedToday,
202
+ ...tf.binds,
203
+ ...of.binds,
204
+ ]);
205
+ return groupBySidebar(db, materialize(db, rows, boundary));
83
206
  }
84
- /** The UI's star marker in Anytime: the item is also a Today member. */
207
+ /**
208
+ * The UI's star marker in Anytime: the item is also a Today member. Mirrors
209
+ * {@link todayView}'s two membership arms so an unscheduled item pulled into
210
+ * Today by a due deadline stars exactly like a scheduled one:
211
+ * - SCHEDULED — a When-date that has arrived (startDate <= today); or
212
+ * - DEADLINE-DRIVEN — no When-date, but a due/overdue deadline (deadline <=
213
+ * today) pulls it into Today (the todayView arm that lets a bare deadline
214
+ * surface an item). Only reached for anytime-visible rows (start=1,
215
+ * startDate NULL), so it never over-stars someday/inbox rows.
216
+ * KNOWN LIMITATION: todayView's deadline arm also drops a DISMISSED-nag
217
+ * deadline (deadlineSuppressionDate >= deadline); that column is not surfaced
218
+ * on the entity, so a suppressed unscheduled overdue row is over-starred here.
219
+ */
85
220
  export function isTodayMember(item, now) {
86
- return item.startDate !== null && item.startDate <= localToday(now);
221
+ const today = localToday(now);
222
+ if (item.startDate !== null)
223
+ return item.startDate <= today;
224
+ return item.deadline !== null && item.deadline <= today;
87
225
  }
226
+ /**
227
+ * The merged date-ordered stream keys on COALESCE(startDate, deadline) — a
228
+ * scheduled row groups under its when-date, a forecast row under its deadline.
229
+ */
230
+ const groupKey = (i) => i.startDate ?? i.deadline ?? "";
88
231
  export function upcomingView(db, now, filter) {
89
232
  const packedToday = encodePackedDate(localToday(now));
233
+ const boundary = logBoundary(db, now);
90
234
  const until = filter?.until;
91
235
  const since = filter?.since;
92
- const untilSql = until === undefined ? "" : " AND t.startDate <= ?";
93
236
  const untilBinds = until === undefined ? [] : [encodePackedDate(until)];
94
- const sinceSql = since === undefined ? "" : " AND t.startDate >= ?";
95
237
  const sinceBinds = since === undefined ? [] : [encodePackedDate(since)];
96
238
  const tf = tagFilter(db, filter);
239
+ // Cohort 1 — SCHEDULED: start=2 with a future startDate. The bounds clip the
240
+ // APPEARANCE date, which for these rows is the startDate (when-date).
241
+ const schedUntilSql = until === undefined ? "" : " AND t.startDate <= ?";
242
+ const schedSinceSql = since === undefined ? "" : " AND t.startDate >= ?";
97
243
  const rows = fetchTaskRows(db, `${OPEN} AND ${CONTAINER_UNTRASHED}
98
- AND t.start = 2 AND t.startDate IS NOT NULL AND t.startDate > ?${untilSql}${sinceSql}${tf.sql}
244
+ AND t.start = 2 AND t.startDate IS NOT NULL AND t.startDate > ?${schedUntilSql}${schedSinceSql}${tf.sql}
99
245
  ORDER BY t.startDate ASC, t."index" ASC`, [packedToday, ...untilBinds, ...sinceBinds, ...tf.binds]);
100
- const items = materialize(db, rows);
246
+ // Cohort 2 — DEADLINE-FORECAST (UPC1, GUI-verified): anytime/someday to-dos
247
+ // and someday projects carrying a FUTURE deadline and no when-date. Inbox
248
+ // (start=0) is excluded; a dismissed-nag deadline (deadlineSuppressionDate ==
249
+ // deadline) is dropped while a re-armed one (supp < deadline) survives — the
250
+ // todayView suppression clause one step earlier (deadline > today, not <=).
251
+ // These rows keep startDate NULL (JSON honesty) and APPEAR under their
252
+ // deadline, so the bounds clip on deadline, exactly as cohort 1 clips on
253
+ // startDate.
254
+ const fcUntilSql = until === undefined ? "" : " AND t.deadline <= ?";
255
+ const fcSinceSql = since === undefined ? "" : " AND t.deadline >= ?";
256
+ const forecastRows = fetchTaskRows(db, `${OPEN} AND ${CONTAINER_UNTRASHED}
257
+ AND t.startDate IS NULL AND t.start IN (1, 2)
258
+ AND t.deadline IS NOT NULL AND t.deadline > ?
259
+ AND (t.deadlineSuppressionDate IS NULL OR t.deadlineSuppressionDate < t.deadline)${fcUntilSql}${fcSinceSql}${tf.sql}
260
+ ORDER BY t.deadline ASC, t."index" ASC`, [packedToday, ...untilBinds, ...sinceBinds, ...tf.binds]);
261
+ const scheduled = materialize(db, rows, boundary);
262
+ const forecast = materialize(db, forecastRows, boundary);
263
+ // The merged date-ordered stream keys on COALESCE(startDate, deadline) — a
264
+ // scheduled row groups under its when-date, a forecast row under its deadline
265
+ // — then the UI's within-day drag order (todayIndex ASC; live-verified
266
+ // 2026-07-11 against the GUI — plain `index` disagrees), then a stable
267
+ // seed-order/uuid tiebreak.
268
+ const sortDated = (list, indexRows) => {
269
+ const todayIndexOf = new Map(indexRows.map((r) => [r.uuid, r.todayIndex ?? 0]));
270
+ return list
271
+ .map((item, pos) => ({ item, pos }))
272
+ .toSorted((a, b) => groupKey(a.item).localeCompare(groupKey(b.item)) ||
273
+ (todayIndexOf.get(a.item.uuid) ?? 0) - (todayIndexOf.get(b.item.uuid) ?? 0) ||
274
+ a.pos - b.pos ||
275
+ a.item.uuid.localeCompare(b.item.uuid))
276
+ .map((x) => x.item);
277
+ };
101
278
  if (filter?.repeats === false)
102
- return items;
279
+ return sortDated([...scheduled, ...forecast], [...rows, ...forecastRows]);
103
280
  // UI parity: repeating templates surface at their app-materialized next
104
281
  // occurrence (rt1_nextInstanceStartDate). Fixed rules only — after-
105
282
  // completion templates carry no next date until the prior instance
@@ -112,7 +289,7 @@ export function upcomingView(db, now, filter) {
112
289
  ORDER BY t.rt1_nextInstanceStartDate ASC, t."index" ASC`, [packedToday, ...untilBinds, ...sinceBinds, ...tf.binds]);
113
290
  const horizon = Math.max(1, Math.min(10, Math.trunc(filter?.horizon ?? 1)));
114
291
  const rawByUuid = new Map(templateRows.map((r) => [r.uuid, r.rt1_recurrenceRule]));
115
- const occurrences = materialize(db, templateRows).flatMap((template) => {
292
+ const occurrences = materialize(db, templateRows, boundary).flatMap((template) => {
116
293
  const startDate = template.repeating.nextOccurrence ?? null;
117
294
  if (startDate === null)
118
295
  return [];
@@ -153,17 +330,9 @@ export function upcomingView(db, now, filter) {
153
330
  deadline: o.deadline,
154
331
  })));
155
332
  });
156
- // Within a day the UI's drag order is todayIndex ASC (live-verified
157
- // 2026-07-11 against the GUI: plain `index` disagrees, todayIndex matches
158
- // exactly), so the sortable Upcoming order survives the CLI.
159
- const todayIndexOf = new Map([...rows, ...templateRows].map((r) => [r.uuid, r.todayIndex ?? 0]));
160
- const dated = [...items, ...occurrences]
161
- .map((item, pos) => ({ item, pos }))
162
- .toSorted((a, b) => (a.item.startDate ?? "").localeCompare(b.item.startDate ?? "") ||
163
- (todayIndexOf.get(a.item.uuid) ?? 0) - (todayIndexOf.get(b.item.uuid) ?? 0) ||
164
- a.pos - b.pos ||
165
- a.item.uuid.localeCompare(b.item.uuid))
166
- .map((x) => x.item);
333
+ // The scheduled + forecast + occurrence rows share one date-ordered stream
334
+ // (COALESCE(startDate, deadline), then the UI's within-day drag order).
335
+ const dated = sortDated([...scheduled, ...forecast, ...occurrences], [...rows, ...forecastRows, ...templateRows]);
167
336
  // The UI's trailing "Repeating To-Dos" section: templates with NO set
168
337
  // next occurrence (after-completion rules between instances, rules past
169
338
  // their end date) plus paused ones — startDate stays null, the decoded
@@ -173,7 +342,7 @@ export function upcomingView(db, now, filter) {
173
342
  AND (t.rt1_nextInstanceStartDate IS NULL OR t.rt1_nextInstanceStartDate <= ?
174
343
  OR t.rt1_instanceCreationPaused = 1)${tf.sql}
175
344
  ORDER BY t.todayIndex ASC, t."index" ASC`, [packedToday, ...tf.binds]);
176
- const resting = materialize(db, restingRows).map((item) => {
345
+ const resting = materialize(db, restingRows, boundary).map((item) => {
177
346
  const raw = restingRows.find((r) => r.uuid === item.uuid)?.rt1_recurrenceRule;
178
347
  if (raw !== null && raw !== undefined) {
179
348
  try {
@@ -187,9 +356,12 @@ export function upcomingView(db, now, filter) {
187
356
  });
188
357
  return [...dated, ...resting];
189
358
  }
359
+ /** A someday to-do nested under a project (directly or via a heading). */
360
+ const isChild = (i) => i.type === "to-do" && (i.project !== null || i.headingProject !== null);
190
361
  export function somedayView(db, now, filter) {
191
362
  const tf = tagFilter(db, filter);
192
363
  const packedToday = encodePackedDate(localToday(now));
364
+ const of = overdueFilter(filter, packedToday);
193
365
  // Default membership excludes ALL project children (the UI shows only
194
366
  // container-less someday to-dos, area members, and someday project rows);
195
367
  // the toggle adds someday children of anytime-ACTIVE projects only.
@@ -199,14 +371,13 @@ export function somedayView(db, now, filter) {
199
371
  AND p.trashed = 0 AND p.status = 0 AND ${ANYTIME_SELF("p")})`
200
372
  : "0";
201
373
  const rows = fetchTaskRows(db, `${OPEN} AND t.start = 2 AND t.startDate IS NULL
202
- AND (${EFF_PROJECT} IS NULL OR ${childArm})${tf.sql} ORDER BY t."index" ASC`, [...(withActiveChildren ? [packedToday, packedToday] : []), ...tf.binds]);
203
- const sections = groupBySidebar(db, materialize(db, rows));
374
+ AND (${EFF_PROJECT} IS NULL OR ${childArm})${tf.sql}${of.sql} ORDER BY t."index" ASC`, [...(withActiveChildren ? [packedToday, packedToday] : []), ...tf.binds, ...of.binds]);
375
+ const sections = groupBySidebar(db, materialize(db, rows, logBoundary(db, now)));
204
376
  // GUI order within a Someday group (live side-by-side, 2026-07-12):
205
377
  // PROJECT rows first (their sidebar order preserved), then direct to-dos in
206
378
  // drag order. Someday children of active projects (the activeProjectItems
207
379
  // toggle) trail the group, still clustered by their project — surfaces
208
380
  // present them as a separate "From active projects" section.
209
- const isChild = (i) => i.type === "to-do" && (i.project !== null || i.headingProject !== null);
210
381
  return sections.map((s) => ({
211
382
  area: s.area,
212
383
  items: [
@@ -216,7 +387,7 @@ export function somedayView(db, now, filter) {
216
387
  ],
217
388
  }));
218
389
  }
219
- export function logbookView(db, options) {
390
+ export function logbookView(db, now, options) {
220
391
  const cap = options?.limit === null ? null : (options?.limit ?? 100);
221
392
  const tf = tagFilter(db, options);
222
393
  const where = [];
@@ -240,21 +411,38 @@ export function logbookView(db, options) {
240
411
  }
241
412
  const extra = where.length > 0 ? ` AND ${where.join(" AND ")}` : "";
242
413
  // Completion ≠ logged: closed items past the log-move boundary still sit
243
- // checked in their original lists, exactly like the GUI's Logbook.
244
- const rows = fetchTaskRows(db, `${LIVE} AND t.status IN (2, 3) AND t.stopDate <= ?${extra}${tf.sql} ORDER BY t.stopDate DESC${cap === null ? "" : " LIMIT ?"}`, [logBoundary(db).getTime() / 1000, ...binds, ...tf.binds, ...(cap === null ? [] : [cap])]);
245
- return materialize(db, rows);
414
+ // checked in their original lists, exactly like the GUI's Logbook. The
415
+ // membership boundary rides the injected clock (threaded into both the SQL
416
+ // filter and materialize so presence and the `logged` flag agree).
417
+ const boundary = logBoundary(db, now);
418
+ const rows = fetchTaskRows(db, `${LIVE} AND t.status IN (2, 3) AND t.stopDate <= ?${extra}${tf.sql} ORDER BY t.stopDate DESC${cap === null ? "" : " LIMIT ?"}`, [boundary.getTime() / 1000, ...binds, ...tf.binds, ...(cap === null ? [] : [cap])]);
419
+ return materialize(db, rows, boundary);
246
420
  }
247
- export function trashView(db, options) {
421
+ export function trashView(db, now, options) {
248
422
  const cap = options?.limit === null ? null : (options?.limit ?? 200);
249
423
  const rows = fetchTaskRows(db, `t.type IN (0, 1) AND t.trashed = 1 AND ${NOT_TEMPLATE}
250
424
  ORDER BY t.userModificationDate DESC${cap === null ? "" : " LIMIT ?"}`, cap === null ? [] : [cap]);
251
- return materialize(db, rows);
425
+ return materialize(db, rows, logBoundary(db, now));
252
426
  }
253
427
  export function projectsView(db, options) {
254
428
  // areaUuid accepts a uuid OR a unique (case-insensitive) title; ambiguous
255
429
  // or unknown references throw like every other ref resolver.
256
430
  const area = options?.areaUuid === undefined ? null : resolveAreaUuid(db, options.areaUuid);
257
431
  const packedToday = encodePackedDate(localToday(options?.now));
432
+ // Tag scope (§9a): the projects LIST is a FLAT view (like `anytime` restricted
433
+ // to project rows), NOT a single-container view — projects sit in different
434
+ // areas with heterogeneous inheritance, there is no universally-inherited tag
435
+ // set, and inherited tags are opaque here (area headers don't show what they
436
+ // confer), so filtering by an inherited tag is useful. `--tag`/`--untagged`
437
+ // are INHERITANCE-INCLUSIVE (a project inherits its area's tags). Its binds sit
438
+ // in the WHERE, after the overdue bind and before the active-first binds.
439
+ const tf = tagFilter(db, options);
440
+ // OWN-DEADLINE UNIFORM: `--overdue` keeps only projects whose OWN deadline is
441
+ // overdue (open, strictly before today) — projects carry a `deadline` column
442
+ // exactly like to-dos, so the shared OVERDUE predicate applies to project
443
+ // rows verbatim. A content scope: it narrows the list, never lifts a limit.
444
+ const overdueSql = options?.overdue === true ? ` AND ${OVERDUE}` : "";
445
+ const overdueBinds = options?.overdue === true ? [packedToday] : [];
258
446
  // Sidebar default: LATER (someday + future-scheduled) projects are hidden —
259
447
  // active means ANYTIME_SELF (a scheduled project whose date has arrived
260
448
  // counts active, exactly the Anytime membership test). With later=true they
@@ -270,16 +458,18 @@ export function projectsView(db, options) {
270
458
  // above the areas — then each area by ITS sidebar rank (TMArea."index"),
271
459
  // projects within an area by their drag order.
272
460
  const where = area
273
- ? `${OPEN} AND t.type = 1 AND t.area = ?${laterSql}
461
+ ? `${OPEN} AND t.type = 1 AND t.area = ?${laterSql}${overdueSql}${tf.sql}
274
462
  ORDER BY ${activeFirst}t."index" ASC`
275
- : `${OPEN} AND t.type = 1${laterSql} ORDER BY (t.area IS NOT NULL) ASC,
463
+ : `${OPEN} AND t.type = 1${laterSql}${overdueSql}${tf.sql} ORDER BY (t.area IS NOT NULL) ASC,
276
464
  (SELECT a."index" FROM TMArea a WHERE a.uuid = t.area) ASC, ${activeFirst}t."index" ASC`;
277
465
  const rows = fetchTaskRows(db, where, [
278
466
  ...(area ? [area] : []),
279
467
  ...laterBinds,
468
+ ...overdueBinds,
469
+ ...tf.binds,
280
470
  ...activeFirstBinds,
281
471
  ]);
282
- const items = materialize(db, rows);
472
+ const items = materialize(db, rows, logBoundary(db, options?.now));
283
473
  if (options?.later !== true)
284
474
  return items;
285
475
  // Each group's later sub-block reads like Upcoming: SCHEDULED projects
@@ -333,13 +523,13 @@ export function projectsView(db, options) {
333
523
  * carry no modification dates, and checklist-item edits do not bump the
334
524
  * parent task — those changes are invisible here.
335
525
  */
336
- export function changesView(db, options) {
526
+ export function changesView(db, now, options) {
337
527
  const cap = options.limit === null ? null : (options.limit ?? 200);
338
528
  const sinceEpoch = options.since.getTime() / 1000;
339
529
  const rows = fetchTaskRows(db, `t.type IN (0, 1) AND t.userModificationDate > ?
340
530
  ORDER BY t.userModificationDate DESC${cap === null ? "" : " LIMIT ?"}`, [sinceEpoch, ...(cap === null ? [] : [cap])]);
341
531
  // oxlint-disable-next-line no-map-spread -- building fresh change objects, not mutating
342
- return materialize(db, rows).map((item, i) => ({
532
+ return materialize(db, rows, logBoundary(db, now)).map((item, i) => ({
343
533
  ...item,
344
534
  changeKind: (rows[i]?.creationDate ?? 0) > sinceEpoch ? "created" : "modified",
345
535
  }));
@@ -359,7 +549,7 @@ const somedayRank = (i) => (i.start === "someday" ? 1 : 0);
359
549
  function byStatusThenRecent(a, b) {
360
550
  return somedayRank(a) - somedayRank(b) || b.modified.getTime() - a.modified.getTime();
361
551
  }
362
- export function liteTitleSearch(db, query, options) {
552
+ export function liteTitleSearch(db, query, options, now) {
363
553
  const cap = options?.limit ?? 10;
364
554
  const type = options?.type;
365
555
  const needle = query.toLowerCase();
@@ -371,8 +561,8 @@ export function liteTitleSearch(db, query, options) {
371
561
  let tasks = [];
372
562
  if (type === undefined || type === "to-do" || type === "project") {
373
563
  const typeSql = type === "to-do" ? "t.type = 0" : type === "project" ? "t.type = 1" : "t.type IN (0, 1)";
374
- const rows = fetchTaskRows(db, `${OPEN} AND ${CONTAINER_UNTRASHED} AND ${typeSql} AND t.title LIKE ?`, [`%${query}%`]);
375
- tasks = materialize(db, rows);
564
+ const rows = fetchTaskRows(db, `${OPEN} AND ${CONTAINER_UNTRASHED} AND ${typeSql} AND t.title LIKE ?${LIKE_ESCAPE}`, [`%${escapeLike(query)}%`]);
565
+ tasks = materialize(db, rows, logBoundary(db, now));
376
566
  }
377
567
  const projects = tasks.filter((t) => t.type === "project").toSorted(byStatusThenRecent);
378
568
  const todos = tasks.filter((t) => t.type === "to-do").toSorted(byStatusThenRecent);
@@ -383,9 +573,16 @@ export function liteTitleSearch(db, query, options) {
383
573
  ];
384
574
  return { candidates: ordered.slice(0, cap), total: ordered.length };
385
575
  }
386
- export function searchView(db, query, options) {
576
+ export function searchView(db, query, options, now) {
387
577
  const cap = options?.limit === null ? null : (options?.limit ?? 50);
388
- const needle = `%${query}%`;
578
+ const needle = `%${escapeLike(query)}%`;
579
+ // `--overdue` narrows to OPEN, past-deadline matches. Its open-only predicate
580
+ // is contradictory with the status-widening flags (logged/trashed/all); the
581
+ // CLI/MCP surfaces reject that combination, so here it simply intersects.
582
+ const of = overdueFilter(options, encodePackedDate(localToday(now)));
583
+ // Threaded into every materialize so the `logged` flag (--logged widens to
584
+ // closed rows) honors the injected clock, not wall time.
585
+ const boundary = logBoundary(db, now);
389
586
  // The scope predicates (everything except the match needle), reused verbatim
390
587
  // for the needle query AND the heading-credited-project query so both honor
391
588
  // the same type/status/scope constraints.
@@ -418,10 +615,10 @@ export function searchView(db, query, options) {
418
615
  }
419
616
  const tf = tagFilter(db, options);
420
617
  // Needle matches: title OR notes. No SQL LIMIT — ranking runs before the cap.
421
- const rows = fetchTaskRows(db, `${scope.join(" AND ")} AND (t.title LIKE ? OR t.notes LIKE ?)${tf.sql}`, [...scopeBinds, needle, needle, ...tf.binds]);
618
+ const rows = fetchTaskRows(db, `${scope.join(" AND ")} AND (t.title LIKE ?${LIKE_ESCAPE} OR t.notes LIKE ?${LIKE_ESCAPE})${tf.sql}${of.sql}`, [...scopeBinds, needle, needle, ...tf.binds, ...of.binds]);
422
619
  const needleLower = query.toLowerCase();
423
620
  const matches = new Map();
424
- for (const item of materialize(db, rows)) {
621
+ for (const item of materialize(db, rows, boundary)) {
425
622
  // Field credit: title beats notes when both carry the substring.
426
623
  const field = item.title.toLowerCase().includes(needleLower) ? "title" : "notes";
427
624
  matches.set(item.uuid, { item, field });
@@ -433,7 +630,7 @@ export function searchView(db, query, options) {
433
630
  if (options?.type !== "to-do") {
434
631
  const headingRows = db
435
632
  .prepare(`SELECT project AS projectUuid, title FROM TMTask
436
- WHERE type = 2 AND project IS NOT NULL AND trashed = 0 AND title LIKE ?`)
633
+ WHERE type = 2 AND project IS NOT NULL AND trashed = 0 AND title LIKE ?${LIKE_ESCAPE}`)
437
634
  .all(needle);
438
635
  const headingTitleFor = new Map();
439
636
  for (const h of headingRows) {
@@ -446,8 +643,8 @@ export function searchView(db, query, options) {
446
643
  const wanted = [...headingTitleFor.keys()].filter((uuid) => !matches.has(uuid));
447
644
  if (wanted.length > 0) {
448
645
  const placeholders = wanted.map(() => "?").join(", ");
449
- const projectRows = fetchTaskRows(db, `${scope.join(" AND ")} AND t.type = 1 AND t.uuid IN (${placeholders})${tf.sql}`, [...scopeBinds, ...wanted, ...tf.binds]);
450
- for (const item of materialize(db, projectRows)) {
646
+ const projectRows = fetchTaskRows(db, `${scope.join(" AND ")} AND t.type = 1 AND t.uuid IN (${placeholders})${tf.sql}${of.sql}`, [...scopeBinds, ...wanted, ...tf.binds, ...of.binds]);
647
+ for (const item of materialize(db, projectRows, boundary)) {
451
648
  const matchedVia = {
452
649
  kind: "heading",
453
650
  title: headingTitleFor.get(item.uuid) ?? "",