things-api 0.10.0 → 0.12.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 (239) hide show
  1. package/README.md +50 -13
  2. package/dist/audit/schema.d.ts +7 -0
  3. package/dist/audit/schema.js.map +1 -1
  4. package/dist/cli/clock.d.ts +8 -0
  5. package/dist/cli/clock.js +24 -0
  6. package/dist/cli/clock.js.map +1 -0
  7. package/dist/cli/commands/area.d.ts +18 -11
  8. package/dist/cli/commands/area.js +61 -74
  9. package/dist/cli/commands/area.js.map +1 -1
  10. package/dist/cli/commands/doctor.d.ts +1 -2
  11. package/dist/cli/commands/doctor.js +11 -1
  12. package/dist/cli/commands/doctor.js.map +1 -1
  13. package/dist/cli/commands/install-skill.d.ts +60 -0
  14. package/dist/cli/commands/install-skill.js +256 -0
  15. package/dist/cli/commands/install-skill.js.map +1 -0
  16. package/dist/cli/commands/mcp.js +9 -0
  17. package/dist/cli/commands/mcp.js.map +1 -1
  18. package/dist/cli/commands/project.d.ts +1 -0
  19. package/dist/cli/commands/project.js +27 -34
  20. package/dist/cli/commands/project.js.map +1 -1
  21. package/dist/cli/commands/reads.d.ts +15 -2
  22. package/dist/cli/commands/reads.js +144 -44
  23. package/dist/cli/commands/reads.js.map +1 -1
  24. package/dist/cli/commands/setup.js +7 -2
  25. package/dist/cli/commands/setup.js.map +1 -1
  26. package/dist/cli/commands/show.js +38 -14
  27. package/dist/cli/commands/show.js.map +1 -1
  28. package/dist/cli/commands/todo.js +15 -9
  29. package/dist/cli/commands/todo.js.map +1 -1
  30. package/dist/cli/commands/writes.js +797 -174
  31. package/dist/cli/commands/writes.js.map +1 -1
  32. package/dist/cli/did-you-mean.d.ts +14 -3
  33. package/dist/cli/did-you-mean.js +26 -3
  34. package/dist/cli/did-you-mean.js.map +1 -1
  35. package/dist/cli/glyphs.d.ts +32 -2
  36. package/dist/cli/glyphs.js +83 -12
  37. package/dist/cli/glyphs.js.map +1 -1
  38. package/dist/cli/help.js +121 -16
  39. package/dist/cli/help.js.map +1 -1
  40. package/dist/cli/main.js +33 -2
  41. package/dist/cli/main.js.map +1 -1
  42. package/dist/cli/move-hint.d.ts +46 -0
  43. package/dist/cli/move-hint.js +196 -0
  44. package/dist/cli/move-hint.js.map +1 -0
  45. package/dist/cli/period.d.ts +2 -8
  46. package/dist/cli/period.js +44 -30
  47. package/dist/cli/period.js.map +1 -1
  48. package/dist/cli/read-driver.d.ts +43 -5
  49. package/dist/cli/read-driver.js +91 -15
  50. package/dist/cli/read-driver.js.map +1 -1
  51. package/dist/cli/render.d.ts +19 -8
  52. package/dist/cli/render.js +70 -27
  53. package/dist/cli/render.js.map +1 -1
  54. package/dist/cli/resolve-invocation.d.ts +2 -2
  55. package/dist/cli/resolve-invocation.js +3 -3
  56. package/dist/cli/resolve-invocation.js.map +1 -1
  57. package/dist/cli/skill-check.d.ts +27 -0
  58. package/dist/cli/skill-check.js +99 -0
  59. package/dist/cli/skill-check.js.map +1 -0
  60. package/dist/cli/skill.d.ts +70 -0
  61. package/dist/cli/skill.js +152 -0
  62. package/dist/cli/skill.js.map +1 -0
  63. package/dist/cli/tag-filters.d.ts +6 -2
  64. package/dist/cli/tag-filters.js +8 -6
  65. package/dist/cli/tag-filters.js.map +1 -1
  66. package/dist/cli/verb-hint.js +28 -3
  67. package/dist/cli/verb-hint.js.map +1 -1
  68. package/dist/cli/version.d.ts +4 -0
  69. package/dist/cli/version.js +18 -0
  70. package/dist/cli/version.js.map +1 -0
  71. package/dist/client.d.ts +171 -27
  72. package/dist/client.js +279 -40
  73. package/dist/client.js.map +1 -1
  74. package/dist/config.d.ts +66 -2
  75. package/dist/config.js +120 -12
  76. package/dist/config.js.map +1 -1
  77. package/dist/contracts.d.ts +186 -28
  78. package/dist/contracts.js +22 -1
  79. package/dist/contracts.js.map +1 -1
  80. package/dist/diagnose.d.ts +34 -2
  81. package/dist/diagnose.js +36 -1
  82. package/dist/diagnose.js.map +1 -1
  83. package/dist/index.d.ts +25 -9
  84. package/dist/index.js +22 -3
  85. package/dist/index.js.map +1 -1
  86. package/dist/mcp/server.d.ts +9 -0
  87. package/dist/mcp/server.js +1157 -684
  88. package/dist/mcp/server.js.map +1 -1
  89. package/dist/model/clock.d.ts +61 -0
  90. package/dist/model/clock.js +71 -0
  91. package/dist/model/clock.js.map +1 -0
  92. package/dist/model/dates.d.ts +26 -3
  93. package/dist/model/dates.js +84 -3
  94. package/dist/model/dates.js.map +1 -1
  95. package/dist/model/entities.d.ts +60 -3
  96. package/dist/model/entities.js.map +1 -1
  97. package/dist/model/mappers.d.ts +4 -2
  98. package/dist/model/mappers.js +66 -9
  99. package/dist/model/mappers.js.map +1 -1
  100. package/dist/model/recurrence.d.ts +8 -1
  101. package/dist/model/recurrence.js.map +1 -1
  102. package/dist/read/area-filter.d.ts +76 -0
  103. package/dist/read/area-filter.js +59 -0
  104. package/dist/read/area-filter.js.map +1 -0
  105. package/dist/read/area-view.d.ts +12 -12
  106. package/dist/read/area-view.js +58 -34
  107. package/dist/read/area-view.js.map +1 -1
  108. package/dist/read/detail.d.ts +1 -1
  109. package/dist/read/detail.js +26 -12
  110. package/dist/read/detail.js.map +1 -1
  111. package/dist/read/log-boundary.d.ts +1 -1
  112. package/dist/read/log-boundary.js +13 -3
  113. package/dist/read/log-boundary.js.map +1 -1
  114. package/dist/read/predicates.d.ts +18 -0
  115. package/dist/read/predicates.js +19 -0
  116. package/dist/read/predicates.js.map +1 -1
  117. package/dist/read/project-view.d.ts +32 -16
  118. package/dist/read/project-view.js +80 -45
  119. package/dist/read/project-view.js.map +1 -1
  120. package/dist/read/pseudo-area.d.ts +29 -0
  121. package/dist/read/pseudo-area.js +27 -0
  122. package/dist/read/pseudo-area.js.map +1 -0
  123. package/dist/read/queries.d.ts +108 -19
  124. package/dist/read/queries.js +212 -35
  125. package/dist/read/queries.js.map +1 -1
  126. package/dist/read/scope.d.ts +126 -0
  127. package/dist/read/scope.js +162 -0
  128. package/dist/read/scope.js.map +1 -0
  129. package/dist/read/search-rank.d.ts +2 -5
  130. package/dist/read/shape.d.ts +162 -0
  131. package/dist/read/shape.js +686 -0
  132. package/dist/read/shape.js.map +1 -0
  133. package/dist/read/show-target.d.ts +9 -1
  134. package/dist/read/show-target.js +45 -4
  135. package/dist/read/show-target.js.map +1 -1
  136. package/dist/read/snapshot.d.ts +1 -1
  137. package/dist/read/snapshot.js +9 -2
  138. package/dist/read/snapshot.js.map +1 -1
  139. package/dist/read/stage.d.ts +199 -0
  140. package/dist/read/stage.js +125 -0
  141. package/dist/read/stage.js.map +1 -0
  142. package/dist/read/truncation.d.ts +8 -6
  143. package/dist/read/truncation.js +35 -10
  144. package/dist/read/truncation.js.map +1 -1
  145. package/dist/read/views.d.ts +61 -20
  146. package/dist/read/views.js +240 -82
  147. package/dist/read/views.js.map +1 -1
  148. package/dist/surface-copy.d.ts +9 -0
  149. package/dist/surface-copy.js +9 -0
  150. package/dist/surface-copy.js.map +1 -1
  151. package/dist/write/accessibility-probe.d.ts +1 -1
  152. package/dist/write/accessibility-probe.js +8 -0
  153. package/dist/write/accessibility-probe.js.map +1 -1
  154. package/dist/write/automation-probe.d.ts +1 -1
  155. package/dist/write/automation-probe.js +8 -0
  156. package/dist/write/automation-probe.js.map +1 -1
  157. package/dist/write/batch.d.ts +49 -10
  158. package/dist/write/batch.js +423 -70
  159. package/dist/write/batch.js.map +1 -1
  160. package/dist/write/commands.d.ts +1 -1
  161. package/dist/write/commands.js +316 -66
  162. package/dist/write/commands.js.map +1 -1
  163. package/dist/write/guards.d.ts +1 -1
  164. package/dist/write/guards.js +94 -10
  165. package/dist/write/guards.js.map +1 -1
  166. package/dist/write/heading.d.ts +10 -1
  167. package/dist/write/heading.js +35 -5
  168. package/dist/write/heading.js.map +1 -1
  169. package/dist/write/make-repeating-project.d.ts +2 -2
  170. package/dist/write/make-repeating-project.js +9 -9
  171. package/dist/write/make-repeating-project.js.map +1 -1
  172. package/dist/write/move.d.ts +130 -0
  173. package/dist/write/move.js +1533 -0
  174. package/dist/write/move.js.map +1 -0
  175. package/dist/write/operations.d.ts +105 -19
  176. package/dist/write/operations.js +56 -7
  177. package/dist/write/operations.js.map +1 -1
  178. package/dist/write/opid.d.ts +31 -0
  179. package/dist/write/opid.js +30 -0
  180. package/dist/write/opid.js.map +1 -0
  181. package/dist/write/pipeline.d.ts +122 -3
  182. package/dist/write/pipeline.js +309 -52
  183. package/dist/write/pipeline.js.map +1 -1
  184. package/dist/write/planner.js +8 -1
  185. package/dist/write/planner.js.map +1 -1
  186. package/dist/write/pre-state.d.ts +197 -6
  187. package/dist/write/pre-state.js +427 -20
  188. package/dist/write/pre-state.js.map +1 -1
  189. package/dist/write/recurrence-rule-blob.d.ts +65 -0
  190. package/dist/write/recurrence-rule-blob.js +150 -0
  191. package/dist/write/recurrence-rule-blob.js.map +1 -0
  192. package/dist/write/reorder.d.ts +23 -3
  193. package/dist/write/reorder.js +1871 -85
  194. package/dist/write/reorder.js.map +1 -1
  195. package/dist/write/reversibility.js +19 -7
  196. package/dist/write/reversibility.js.map +1 -1
  197. package/dist/write/scope-guard.d.ts +31 -0
  198. package/dist/write/scope-guard.js +162 -0
  199. package/dist/write/scope-guard.js.map +1 -0
  200. package/dist/write/undo.js +120 -13
  201. package/dist/write/undo.js.map +1 -1
  202. package/dist/write/vectors/applescript.js +16 -7
  203. package/dist/write/vectors/applescript.js.map +1 -1
  204. package/dist/write/vectors/registry.d.ts +5 -7
  205. package/dist/write/vectors/registry.js +49 -2
  206. package/dist/write/vectors/registry.js.map +1 -1
  207. package/dist/write/vectors/shortcuts.js +1 -1
  208. package/dist/write/vectors/shortcuts.js.map +1 -1
  209. package/dist/write/vectors/simulator.d.ts +44 -0
  210. package/dist/write/vectors/simulator.js +1192 -0
  211. package/dist/write/vectors/simulator.js.map +1 -0
  212. package/dist/write/vectors/types.d.ts +46 -0
  213. package/dist/write/vectors/ui-certification.d.ts +10 -4
  214. package/dist/write/vectors/ui-certification.js +43 -10
  215. package/dist/write/vectors/ui-certification.js.map +1 -1
  216. package/dist/write/vectors/ui-drag.d.ts +21 -1
  217. package/dist/write/vectors/ui-drag.js +138 -27
  218. package/dist/write/vectors/ui-drag.js.map +1 -1
  219. package/dist/write/vectors/ui-recipes.d.ts +34 -1
  220. package/dist/write/vectors/ui-recipes.js +217 -6
  221. package/dist/write/vectors/ui-recipes.js.map +1 -1
  222. package/dist/write/vectors/ui.d.ts +40 -1
  223. package/dist/write/vectors/ui.js +196 -39
  224. package/dist/write/vectors/ui.js.map +1 -1
  225. package/dist/write/verify/delta.d.ts +127 -2
  226. package/dist/write/verify/delta.js +255 -16
  227. package/dist/write/verify/delta.js.map +1 -1
  228. package/dist/write/verify/poller.d.ts +7 -1
  229. package/dist/write/verify/poller.js +20 -1
  230. package/dist/write/verify/poller.js.map +1 -1
  231. package/package.json +24 -3
  232. package/schema/envelope.schema.json +383 -0
  233. package/skills/things-cli/SKILL.md +70 -0
  234. package/skills/things-cli/references/banner.md +35 -0
  235. package/skills/things-cli/references/contracts.md +62 -0
  236. package/skills/things-cli/references/errors.md +49 -0
  237. package/skills/things-cli/references/gui.md +11 -0
  238. package/skills/things-cli/references/model.md +60 -0
  239. package/skills/things-cli/references/ordering.md +71 -0
@@ -0,0 +1,1192 @@
1
+ /**
2
+ * The SIMULATOR write vector (bench harness, Phase 0). It presents to the
3
+ * pipeline exactly like the real `url-scheme` transport but, instead of handing
4
+ * a payload to the OS, applies the mutation with SQL against a SYNTHETIC fixture
5
+ * database — so the whole write pipeline (guards → plan → execute → verified
6
+ * read-after-write → audit) can run end-to-end with no Things app installed.
7
+ *
8
+ * It NEVER parses the compiled payload: every applier reads the STRUCTURED
9
+ * `invocation.op` / `invocation.opParams` the pipeline attaches after compile.
10
+ * Its own read-write `node:sqlite` connection is separate from the pipeline's
11
+ * (which stays read-only); both point at the same WAL file, so the pipeline's
12
+ * verification poller observes each committed applier write.
13
+ *
14
+ * SAFETY — a triple fence (checked at creation AND on first execute) keeps this
15
+ * pointed only at a disposable bench DB, never at a real Things database:
16
+ * 1. env `THINGS_SIM_WRITES=1`,
17
+ * 2. env `THINGS_DB` set and byte-equal to the dbPath in use (never a
18
+ * container-glob-located production path),
19
+ * 3. the DB's `Meta` table carries `benchFixture` = "1".
20
+ * When any check fails, {@link defaultVectors} omits the simulator and
21
+ * {@link createSimulatorVector} refuses (throws at creation; execute refuses
22
+ * defensively should the environment change underneath a live instance).
23
+ */
24
+ import { randomBytes } from "node:crypto";
25
+ import { DatabaseSync } from "node:sqlite";
26
+ import { readDatabaseVersion } from "../../db/fingerprint.js";
27
+ import { addDaysIso, decodePackedDate, encodePackedDate, encodeReminderTime, localToday, } from "../../model/dates.js";
28
+ import { decodeRecurrenceRule } from "../../model/recurrence.js";
29
+ import { resolveArea, resolveHeading, resolveProject, resolveTag } from "../pre-state.js";
30
+ import { composeRepeatRuleSpec, ruleXml } from "../recurrence-rule-blob.js";
31
+ import { resolveTagRefs } from "../tag-refs.js";
32
+ // Enum encodings mirror test/fixtures/seed.ts / docs/atlas/schema-v26.md.
33
+ const STATUS = { open: 0, canceled: 2, completed: 3 };
34
+ const START = { inbox: 0, active: 1, someday: 2 };
35
+ /** Substring signature of the production group container — never a bench path. */
36
+ const PROD_CONTAINER = "Group Containers/JLMPQHK86H.com.culturedcode.ThingsMac";
37
+ // --------------------------------------------------------------- fence
38
+ /**
39
+ * The fence reason, or null when the simulator may run against `dbPath`. Pure
40
+ * with respect to `env`; opens `dbPath` READ-ONLY only to read the marker.
41
+ */
42
+ export function simulatorFenceReason(dbPath, env = process.env) {
43
+ if (env["THINGS_SIM_WRITES"] !== "1") {
44
+ return "THINGS_SIM_WRITES is not set to 1";
45
+ }
46
+ const thingsDb = env["THINGS_DB"];
47
+ if (thingsDb === undefined || thingsDb.trim() === "") {
48
+ return "THINGS_DB is not set";
49
+ }
50
+ if (thingsDb !== dbPath) {
51
+ return `THINGS_DB (${thingsDb}) does not equal the simulator dbPath (${dbPath})`;
52
+ }
53
+ if (dbPath.includes(PROD_CONTAINER)) {
54
+ return "the dbPath points at the production Things group container";
55
+ }
56
+ if (!hasBenchMarker(dbPath)) {
57
+ return `the database at ${dbPath} carries no Meta.benchFixture marker`;
58
+ }
59
+ // Scratch state/config dirs are part of the fence (2026-07-17 incident): a
60
+ // simulated run without them appends bench audit records into the operator's
61
+ // real audit trail and reads their real config profile.
62
+ for (const key of ["THINGS_API_STATE_DIR", "THINGS_API_CONFIG_DIR"]) {
63
+ const dir = env[key];
64
+ if (dir === undefined || dir.trim() === "") {
65
+ return `${key} is not set — simulated runs must use scratch state/config dirs, never the operator's real ones`;
66
+ }
67
+ }
68
+ const version = fixtureDatabaseVersion(dbPath);
69
+ if (version !== SIMULATED_DATABASE_VERSION) {
70
+ return (`the fixture reports databaseVersion ${version ?? "unknown"} but the simulator's ` +
71
+ `appliers model version ${SIMULATED_DATABASE_VERSION} — a Things schema change must be ` +
72
+ `re-modeled in lockstep (atlas → seed builders → simulator appliers → bench world/corpus; ` +
73
+ `see docs/lab/drift-runbook.md) before simulated writes may resume`);
74
+ }
75
+ return null;
76
+ }
77
+ /**
78
+ * The Things schema generation the appliers are written against. When a
79
+ * Things update bumps the real database version, the fence refuses to
80
+ * simulate until the whole modeling chain is consciously re-verified —
81
+ * a schema tripwire, not a compatibility claim.
82
+ */
83
+ export const SIMULATED_DATABASE_VERSION = 26;
84
+ function fixtureDatabaseVersion(dbPath) {
85
+ let db;
86
+ try {
87
+ db = new DatabaseSync(dbPath, { readOnly: true, timeout: 2000 });
88
+ return readDatabaseVersion(db);
89
+ }
90
+ catch {
91
+ return null;
92
+ }
93
+ finally {
94
+ db?.close();
95
+ }
96
+ }
97
+ /** Whether the simulator's triple fence is satisfied for `dbPath`. */
98
+ export function simulatorFenceActive(dbPath, env = process.env) {
99
+ return simulatorFenceReason(dbPath, env) === null;
100
+ }
101
+ /**
102
+ * Whether the simulator fence is active for the ambient environment — the
103
+ * single signal every host-escaping code path (reveal `open`, setup install
104
+ * sheets, the live-app doctor probes, the pipeline's app-launch) consults so a
105
+ * bench run never touches the real Things/Shortcuts app. True only when
106
+ * THINGS_SIM_WRITES=1 AND THINGS_DB names a fenced bench fixture (marker
107
+ * present, not the production container); false — the ordinary path — otherwise.
108
+ */
109
+ export function simFenceActive(env = process.env) {
110
+ const dbPath = env["THINGS_DB"];
111
+ if (dbPath === undefined || dbPath.trim() === "")
112
+ return false;
113
+ return simulatorFenceActive(dbPath, env);
114
+ }
115
+ /**
116
+ * Whether the database at `dbPath` carries the bench-fixture marker — i.e. it
117
+ * is a synthetic bench DB by construction. Consulted by defaultVectors as a
118
+ * fail-closed backstop (a marked DB must never be paired with real write
119
+ * transports) and by the reveal gate. False on any read error.
120
+ */
121
+ export function dbCarriesBenchMarker(dbPath) {
122
+ return hasBenchMarker(dbPath);
123
+ }
124
+ function hasBenchMarker(dbPath) {
125
+ let db;
126
+ try {
127
+ db = new DatabaseSync(dbPath, { readOnly: true, timeout: 2000 });
128
+ const row = db.prepare("SELECT value FROM Meta WHERE key = 'benchFixture'").get();
129
+ return row?.value === "1";
130
+ }
131
+ catch {
132
+ return false;
133
+ }
134
+ finally {
135
+ db?.close();
136
+ }
137
+ }
138
+ // ------------------------------------------------------------- uuids
139
+ const BASE62 = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
140
+ /** A fresh 22-char base62 id — the shape of a real Things object uuid. */
141
+ function genUuid() {
142
+ const bytes = randomBytes(22);
143
+ let out = "";
144
+ for (let i = 0; i < 22; i++)
145
+ out += BASE62[(bytes[i] ?? 0) % 62];
146
+ return out;
147
+ }
148
+ // ---------------------------------------------------- shared appliers
149
+ /** Full TMTask row insert, mirroring test/fixtures/seed.ts insertTask columns. */
150
+ function insertTask(sim, type, ctx, opts) {
151
+ sim
152
+ .prepare(`INSERT INTO TMTask (
153
+ uuid, type, status, stopDate, trashed, title, notes,
154
+ creationDate, userModificationDate,
155
+ start, startDate, startBucket, reminderTime, deadline, deadlineSuppressionDate,
156
+ "index", todayIndex, todayIndexReferenceDate, area, project, heading,
157
+ untrashedLeafActionsCount, openUntrashedLeafActionsCount,
158
+ checklistItemsCount, openChecklistItemsCount,
159
+ rt1_repeatingTemplate, rt1_recurrenceRule,
160
+ rt1_nextInstanceStartDate, rt1_instanceCreationPaused, repeater
161
+ ) VALUES (?, ?, ?, NULL, 0, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, 0, 0, NULL, ?, ?, ?, 0, 0, ?, ?, NULL, NULL, NULL, 0, NULL)`)
162
+ .run(opts.uuid, type, STATUS[opts.status ?? "open"], opts.title, opts.notes ?? "", ctx.nowEpoch, ctx.nowEpoch, opts.start ?? START.active, opts.startDate ?? null, opts.startBucket ?? 0, opts.reminderTime ?? null, opts.deadline ?? null, opts.area ?? null, opts.project ?? null, opts.heading ?? null, opts.checklistItemsCount ?? 0, opts.openChecklistItemsCount ?? 0);
163
+ }
164
+ /** The (start, startDate, startBucket) triple a `when` value materializes. */
165
+ function scheduleColumns(when, todayIso, hasContainer) {
166
+ const today = encodePackedDate(todayIso);
167
+ switch (when) {
168
+ case undefined:
169
+ // No schedule requested — Inbox for a bare add, the container's Anytime
170
+ // otherwise (only startDate is ever asserted, so this is plausibility).
171
+ return { start: hasContainer ? START.active : START.inbox, startDate: null, startBucket: 0 };
172
+ case "today":
173
+ return { start: START.active, startDate: today, startBucket: 0 };
174
+ case "evening":
175
+ return { start: START.active, startDate: today, startBucket: 1 };
176
+ case "anytime":
177
+ return { start: START.active, startDate: null, startBucket: 0 };
178
+ case "someday":
179
+ return { start: START.someday, startDate: null, startBucket: 0 };
180
+ default:
181
+ // A concrete YYYY-MM-DD: only the date is invariant (start-state semantics
182
+ // differ for past/future dates and are not asserted).
183
+ return { start: START.active, startDate: encodePackedDate(when), startBucket: 0 };
184
+ }
185
+ }
186
+ function containerUuid(sim, ref, kind) {
187
+ if (ref === undefined || (ref.uuid === undefined && ref.title === undefined))
188
+ return null;
189
+ const res = kind === "project" ? resolveProject(sim, ref) : resolveArea(sim, ref);
190
+ if (res.resolved === null)
191
+ throw new Error(`simulator: unresolved ${kind} reference`);
192
+ return res.resolved.uuid;
193
+ }
194
+ /** Resolve each tag ref to a TMTag uuid (leaf title match), skipping unknowns. */
195
+ function tagUuids(sim, refs) {
196
+ const { titles } = resolveTagRefs(sim, refs);
197
+ const uuids = [];
198
+ for (const title of titles) {
199
+ const row = sim
200
+ .prepare("SELECT uuid FROM TMTag WHERE title = ? COLLATE NOCASE LIMIT 1")
201
+ .get(title);
202
+ if (row !== undefined)
203
+ uuids.push(row.uuid);
204
+ }
205
+ return uuids;
206
+ }
207
+ function setTaskTags(sim, taskUuid, refs) {
208
+ sim.prepare("DELETE FROM TMTaskTag WHERE tasks = ?").run(taskUuid);
209
+ for (const tagUuid of tagUuids(sim, refs)) {
210
+ sim.prepare("INSERT INTO TMTaskTag (tasks, tags) VALUES (?, ?)").run(taskUuid, tagUuid);
211
+ }
212
+ }
213
+ /** Recreate a to-do's checklist wholesale (all items open unless flagged). */
214
+ function replaceChecklist(sim, taskUuid, items, ctx) {
215
+ sim.prepare("DELETE FROM TMChecklistItem WHERE task = ?").run(taskUuid);
216
+ let open = 0;
217
+ items.forEach((item, i) => {
218
+ const title = typeof item === "string" ? item : item.title;
219
+ const completed = typeof item !== "string" && item.completed === true;
220
+ if (!completed)
221
+ open++;
222
+ sim
223
+ .prepare(`INSERT INTO TMChecklistItem (uuid, userModificationDate, creationDate, title, status, stopDate, "index", task, leavesTombstone)
224
+ VALUES (?, ?, ?, ?, ?, NULL, ?, ?, 0)`)
225
+ .run(genUuid(), ctx.nowEpoch, ctx.nowEpoch, title, completed ? STATUS.completed : STATUS.open, i, taskUuid);
226
+ });
227
+ sim
228
+ .prepare("UPDATE TMTask SET checklistItemsCount = ?, openChecklistItemsCount = ?, userModificationDate = ? WHERE uuid = ?")
229
+ .run(items.length, open, ctx.nowEpoch, taskUuid);
230
+ }
231
+ /** The joined notes an append/prepend update should leave (mirrors expectedNotes). */
232
+ function joinedNotes(sim, uuid, params) {
233
+ if (params.appendNotes === undefined && params.prependNotes === undefined)
234
+ return undefined;
235
+ const row = sim.prepare("SELECT notes FROM TMTask WHERE uuid = ?").get(uuid);
236
+ const current = row?.notes ?? "";
237
+ if (params.appendNotes !== undefined) {
238
+ return current === "" ? params.appendNotes : `${current}\n${params.appendNotes}`;
239
+ }
240
+ const prepend = params.prependNotes ?? "";
241
+ return current === "" ? prepend : `${prepend}\n${current}`;
242
+ }
243
+ /** The reminderTime packed value an update leaves (mirrors effectiveReminder). */
244
+ function effectiveReminderValue(sim, uuid, params) {
245
+ if (params.reminder !== undefined) {
246
+ return params.reminder === null ? null : encodeReminderTime(params.reminder);
247
+ }
248
+ const when = params.when;
249
+ const schedulable = when === "today" ||
250
+ when === "evening" ||
251
+ (typeof when === "string" && /^\d{4}-\d{2}-\d{2}$/.test(when));
252
+ if (!schedulable)
253
+ return null;
254
+ // Preserve the existing reminder across a bare re-schedule.
255
+ const row = sim.prepare("SELECT reminderTime FROM TMTask WHERE uuid = ?").get(uuid);
256
+ return row?.reminderTime ?? null;
257
+ }
258
+ /** Shared to-do/project field update (title/notes/when+reminder/deadline). */
259
+ function applyEntityUpdate(sim, params, ctx) {
260
+ const sets = [];
261
+ const binds = [];
262
+ if (params.title !== undefined) {
263
+ sets.push("title = ?");
264
+ binds.push(params.title);
265
+ }
266
+ if (params.notes !== undefined) {
267
+ sets.push("notes = ?");
268
+ binds.push(params.notes);
269
+ }
270
+ else {
271
+ const joined = joinedNotes(sim, params.uuid, params);
272
+ if (joined !== undefined) {
273
+ sets.push("notes = ?");
274
+ binds.push(joined);
275
+ }
276
+ }
277
+ if (params.when !== undefined) {
278
+ const s = scheduleColumns(params.when, ctx.todayIso, true);
279
+ sets.push("start = ?", "startDate = ?", "startBucket = ?");
280
+ binds.push(s.start, s.startDate, s.startBucket);
281
+ sets.push("reminderTime = ?");
282
+ binds.push(effectiveReminderValue(sim, params.uuid, params));
283
+ }
284
+ else if (params.reminder !== undefined) {
285
+ sets.push("reminderTime = ?");
286
+ binds.push(params.reminder === null ? null : encodeReminderTime(params.reminder));
287
+ }
288
+ if (params.deadline !== undefined) {
289
+ sets.push("deadline = ?");
290
+ binds.push(params.deadline === null ? null : encodePackedDate(params.deadline));
291
+ }
292
+ sets.push("userModificationDate = ?");
293
+ binds.push(ctx.nowEpoch);
294
+ sim.prepare(`UPDATE TMTask SET ${sets.join(", ")} WHERE uuid = ?`).run(...binds, params.uuid);
295
+ }
296
+ function setStatus(sim, uuid, status, stopDate, ctx) {
297
+ sim
298
+ .prepare("UPDATE TMTask SET status = ?, stopDate = ?, userModificationDate = ? WHERE uuid = ?")
299
+ .run(status, stopDate, ctx.nowEpoch, uuid);
300
+ }
301
+ // ------------------------------------------------- recurrence appliers
302
+ //
303
+ // The row-level shapes below reproduce the RSIM campaign verdicts
304
+ // (docs/lab/rsim-results.md, RSIM1–6): making a to-do/project repeat, the
305
+ // fixed-vs-after-completion identity asymmetry, in-place reschedule, and the
306
+ // completion-side stamping that schedules the next after-completion occurrence
307
+ // without materializing it. Rule blobs come from the SHARED composer
308
+ // (recurrence-rule-blob.ts), so every emitted template decodes with the real
309
+ // read-path decoder.
310
+ /** A deadlined template's own `deadline` column carries this far-future sentinel (§8a). */
311
+ const DEADLINE_SENTINEL_ISO = "4001-01-01";
312
+ /** Add whole recurrence units to an ISO date (day/week/month/year). */
313
+ function addUnitsIso(iso, frequency, interval) {
314
+ switch (frequency) {
315
+ case "daily":
316
+ return addDaysIso(iso, interval);
317
+ case "weekly":
318
+ return addDaysIso(iso, 7 * interval);
319
+ case "monthly":
320
+ return addMonthsIso(iso, interval);
321
+ case "yearly":
322
+ return addMonthsIso(iso, 12 * interval);
323
+ }
324
+ }
325
+ /** Add whole months to an ISO date, clamping the day to the target month's length. */
326
+ function addMonthsIso(iso, months) {
327
+ const [y, m, d] = iso.split("-").map(Number);
328
+ const anchor = new Date(Date.UTC(y, m - 1 + months, 1));
329
+ const year = anchor.getUTCFullYear();
330
+ const month0 = anchor.getUTCMonth();
331
+ const lastDay = new Date(Date.UTC(year, month0 + 1, 0)).getUTCDate();
332
+ const day = Math.min(d, lastDay);
333
+ return (`${String(year).padStart(4, "0")}-` +
334
+ `${String(month0 + 1).padStart(2, "0")}-` +
335
+ `${String(day).padStart(2, "0")}`);
336
+ }
337
+ /** Epoch seconds at UTC-noon of an ISO date — the (decoder-ignored) rule anchor. */
338
+ function epochOfIso(iso) {
339
+ return Math.floor(Date.parse(`${iso}T12:00:00Z`) / 1000);
340
+ }
341
+ /** Insert a template or instance row, covering the full rt1_* recurrence column set. */
342
+ function insertRecurrenceRow(sim, ctx, o) {
343
+ sim
344
+ .prepare(`INSERT INTO TMTask (
345
+ uuid, type, status, stopDate, trashed, title, notes,
346
+ creationDate, userModificationDate,
347
+ start, startDate, startBucket, reminderTime, deadline, deadlineSuppressionDate,
348
+ "index", todayIndex, todayIndexReferenceDate, area, project, heading,
349
+ untrashedLeafActionsCount, openUntrashedLeafActionsCount,
350
+ checklistItemsCount, openChecklistItemsCount,
351
+ rt1_repeatingTemplate, rt1_recurrenceRule, rt1_instanceCreationStartDate,
352
+ rt1_instanceCreationPaused, rt1_instanceCreationCount,
353
+ rt1_afterCompletionReferenceDate, rt1_nextInstanceStartDate, repeater
354
+ ) VALUES (?, ?, 0, NULL, 0, ?, ?, ?, ?, ?, ?, 0, NULL, ?, NULL, 0, 0, NULL, ?, NULL, NULL,
355
+ 0, 0, 0, 0, ?, ?, ?, 0, ?, ?, ?, NULL)`)
356
+ .run(o.uuid, o.type, o.title, o.notes, ctx.nowEpoch, ctx.nowEpoch, o.start, o.startDate, o.deadline, o.area, o.repeatingTemplate ?? null, o.recurrenceRuleXml !== undefined ? new TextEncoder().encode(o.recurrenceRuleXml) : null, o.instanceCreationStartDate ?? null, o.instanceCreationCount ?? 0, o.afterCompletionReferenceDate ?? null, o.nextInstanceStartDate ?? null);
357
+ }
358
+ /** Copy a task's direct tag links onto another task (title/notes/tags/area copy from source). */
359
+ function copyTaskTags(sim, fromUuid, toUuid) {
360
+ const rows = sim.prepare("SELECT tags FROM TMTaskTag WHERE tasks = ?").all(fromUuid);
361
+ for (const r of rows) {
362
+ sim.prepare("INSERT INTO TMTaskTag (tasks, tags) VALUES (?, ?)").run(toUuid, r.tags);
363
+ }
364
+ }
365
+ function loadRepeatSource(sim, uuid) {
366
+ const src = sim
367
+ .prepare("SELECT title, notes, area, startDate, deadline FROM TMTask WHERE uuid = ?")
368
+ .get(uuid);
369
+ if (src === undefined)
370
+ throw new Error("simulator: make-repeating target not found");
371
+ return {
372
+ title: src.title ?? "",
373
+ notes: src.notes ?? "",
374
+ area: src.area,
375
+ startDate: src.startDate,
376
+ // §RSIM-T: a non-NULL source deadline is the sole to-do fixed-preserve trigger.
377
+ deadline: src.deadline,
378
+ };
379
+ }
380
+ /**
381
+ * Read a project's full containment subtree (headings + direct/headed to-dos,
382
+ * with tags + checklist) before any mutation.
383
+ *
384
+ * RSIM-S (§RSIM-S S-R1): trashed rows are INCLUDED (each tagged `trashed`) so a
385
+ * source-DELETE conversion can hard-delete the WHOLE subtree — reality destroys
386
+ * a pre-trashed child along with the source, leaving no orphan. Copy paths
387
+ * (`materializeSubtreeCopy`) filter these back out — a spawned/template copy
388
+ * never carries trashed rows (RSIM-P2 A3, probe-verified).
389
+ */
390
+ function readProjectSubtree(sim, projectUuid) {
391
+ const headingRows = sim
392
+ .prepare(`SELECT uuid, title, notes, status, stopDate, trashed, "index" AS idx
393
+ FROM TMTask WHERE type = 2 AND project = ? ORDER BY "index"`)
394
+ .all(projectUuid);
395
+ const headings = headingRows.map((h) => ({
396
+ uuid: h.uuid,
397
+ title: h.title ?? "",
398
+ notes: h.notes ?? "",
399
+ status: h.status,
400
+ stopDate: h.stopDate,
401
+ index: h.idx,
402
+ trashed: h.trashed === 1,
403
+ }));
404
+ const headingUuids = headings.map((h) => h.uuid);
405
+ // A direct child has project=<projectUuid>; a headed child has project=NULL
406
+ // and heading=<one of this project's headings> — this union captures both.
407
+ const placeholders = headingUuids.map(() => "?").join(", ");
408
+ const todoRows = sim
409
+ .prepare(`SELECT uuid, title, notes, status, stopDate, deadline, heading, trashed,
410
+ rt1_recurrenceRule AS recurrenceRule, rt1_repeatingTemplate AS repeatingTemplate,
411
+ "index" AS idx
412
+ FROM TMTask WHERE type = 0
413
+ AND (project = ?${headingUuids.length > 0 ? ` OR heading IN (${placeholders})` : ""})
414
+ ORDER BY "index"`)
415
+ .all(projectUuid, ...headingUuids);
416
+ const todos = todoRows.map((t) => ({
417
+ uuid: t.uuid,
418
+ title: t.title ?? "",
419
+ notes: t.notes ?? "",
420
+ status: t.status,
421
+ stopDate: t.stopDate,
422
+ deadline: t.deadline,
423
+ index: t.idx,
424
+ trashed: t.trashed === 1,
425
+ headingUuid: t.heading,
426
+ recurrenceRule: t.recurrenceRule,
427
+ repeatingTemplate: t.repeatingTemplate,
428
+ tags: sim.prepare("SELECT tags FROM TMTaskTag WHERE tasks = ?").all(t.uuid).map((r) => r.tags),
429
+ checklist: sim
430
+ .prepare(`SELECT title, status, "index" AS idx FROM TMChecklistItem WHERE task = ? ORDER BY "index"`)
431
+ .all(t.uuid).map((c) => ({ title: c.title ?? "", status: c.status, index: c.idx })),
432
+ }));
433
+ return { headings, todos };
434
+ }
435
+ /** UPDATE a row's list index (insertTask hardcodes 0; the copy preserves source order). */
436
+ function setRowIndex(sim, uuid, index) {
437
+ sim.prepare(`UPDATE TMTask SET "index" = ? WHERE uuid = ?`).run(index, uuid);
438
+ }
439
+ /**
440
+ * Materialize a PLAIN copy of `subtree` under `newProjectUuid` (fresh uuids for
441
+ * every heading/to-do; tags + checklist duplicated; index order + heading
442
+ * membership preserved; children `start=1`, `rt1_recurrenceRule`/
443
+ * `rt1_repeatingTemplate` NULL).
444
+ *
445
+ * RSIM-R: copied children are ALWAYS plain — both fixed and after-completion
446
+ * instance/template sides. (RSIM-P P4's per-child instance→template links are a
447
+ * non-reproducible anomaly; A5/R7/R8 = 3/3 plain — so no linkMap parameter.)
448
+ */
449
+ function materializeSubtreeCopy(sim, ctx, subtree, newProjectUuid) {
450
+ const headingIdMap = new Map();
451
+ for (const h of subtree.headings) {
452
+ if (h.trashed)
453
+ continue; // RSIM-S S-R1: copies exclude trashed rows (RSIM-P2 A3).
454
+ const newUuid = genUuid();
455
+ insertTask(sim, 2, ctx, {
456
+ uuid: newUuid,
457
+ title: h.title,
458
+ notes: h.notes,
459
+ start: START.active, // RSIM-P P1: copied children are start=1
460
+ project: newProjectUuid,
461
+ });
462
+ setRowIndex(sim, newUuid, h.index);
463
+ if (h.status !== STATUS.open)
464
+ setStatus(sim, newUuid, h.status, h.stopDate ?? ctx.nowEpoch, ctx);
465
+ headingIdMap.set(h.uuid, newUuid);
466
+ }
467
+ for (const t of subtree.todos) {
468
+ if (t.trashed)
469
+ continue; // RSIM-S S-R1: copies exclude trashed rows (RSIM-P2 A3).
470
+ const newUuid = genUuid();
471
+ // Preserve the containment invariant: a headed child points at the COPIED
472
+ // heading with project NULL; a direct child points at the new project.
473
+ const heading = t.headingUuid !== null ? (headingIdMap.get(t.headingUuid) ?? null) : null;
474
+ const project = heading !== null ? null : newProjectUuid;
475
+ const openChecklist = t.checklist.filter((c) => c.status === STATUS.open).length;
476
+ insertTask(sim, 0, ctx, {
477
+ uuid: newUuid,
478
+ title: t.title,
479
+ notes: t.notes,
480
+ start: START.active, // RSIM-P P1: start=1
481
+ startDate: null,
482
+ deadline: t.deadline,
483
+ project,
484
+ heading,
485
+ checklistItemsCount: t.checklist.length,
486
+ openChecklistItemsCount: openChecklist,
487
+ });
488
+ setRowIndex(sim, newUuid, t.index);
489
+ if (t.status !== STATUS.open)
490
+ setStatus(sim, newUuid, t.status, t.stopDate ?? ctx.nowEpoch, ctx);
491
+ for (const tag of t.tags) {
492
+ sim.prepare("INSERT INTO TMTaskTag (tasks, tags) VALUES (?, ?)").run(newUuid, tag);
493
+ }
494
+ for (const c of t.checklist) {
495
+ sim
496
+ .prepare(`INSERT INTO TMChecklistItem (uuid, userModificationDate, creationDate, title, status, stopDate, "index", task, leavesTombstone)
497
+ VALUES (?, ?, ?, ?, ?, NULL, ?, ?, 0)`)
498
+ .run(genUuid(), ctx.nowEpoch, ctx.nowEpoch, c.title, c.status, c.index, newUuid);
499
+ }
500
+ }
501
+ }
502
+ /**
503
+ * Hard-DELETE a source subtree: each to-do's tags + checklist + row, then the
504
+ * heading rows.
505
+ *
506
+ * RSIM-S (§RSIM-S S-R1): the subtree includes already-trashed rows, and they are
507
+ * destroyed here too — a source-DELETE conversion hard-deletes the WHOLE source
508
+ * subtree, so a pre-trashed child does NOT survive as a dangling row pointing at
509
+ * the deleted source (it is neither copied nor left in the Trash).
510
+ */
511
+ function deleteProjectSubtree(sim, subtree) {
512
+ for (const t of subtree.todos) {
513
+ sim.prepare("DELETE FROM TMTaskTag WHERE tasks = ?").run(t.uuid);
514
+ sim.prepare("DELETE FROM TMChecklistItem WHERE task = ?").run(t.uuid);
515
+ sim.prepare("DELETE FROM TMTask WHERE uuid = ?").run(t.uuid);
516
+ }
517
+ for (const h of subtree.headings) {
518
+ sim.prepare("DELETE FROM TMTaskTag WHERE tasks = ?").run(h.uuid);
519
+ sim.prepare("DELETE FROM TMTask WHERE uuid = ?").run(h.uuid);
520
+ }
521
+ }
522
+ /**
523
+ * RSIM-R C1: a NESTED repeater in a project's subtree is a to-do carrying either
524
+ * `rt1_recurrenceRule` (the nested hidden template) or `rt1_repeatingTemplate`
525
+ * (the nested repeater's instance). Its presence flips the fixed project
526
+ * make-repeating from delete-and-mint-both to the source-preserving path.
527
+ */
528
+ function subtreeHasNestedRepeater(subtree) {
529
+ // RSIM-S S-R1: consider only live rows — a trashed nested repeater does not
530
+ // flip source-fate (a trashed child alone leaves the delete-default intact).
531
+ return subtree.todos.some((t) => !t.trashed && (t.recurrenceRule !== null || t.repeatingTemplate !== null));
532
+ }
533
+ /**
534
+ * RSIM-U (2026-07-22): the SECOND fixed-project source-preserve trigger — a plain
535
+ * project (no nested repeater) PRESERVES its source when EVERY live child to-do is
536
+ * TERMINAL (completed/canceled); a single OPEN child DELETES it (U-open). An empty
537
+ * subtree also deletes (needs ≥1 terminal child). Trashed children do not count —
538
+ * a trashed child alone leaves the delete-default intact (RSIM-S SR Proj). Headed
539
+ * children ride in `subtree.todos`, so headed terminal children count too.
540
+ */
541
+ function subtreeAllChildrenTerminal(subtree) {
542
+ const live = subtree.todos.filter((t) => !t.trashed);
543
+ return (live.length > 0 &&
544
+ live.every((t) => t.status === STATUS.completed || t.status === STATUS.canceled));
545
+ }
546
+ /**
547
+ * The two INDEPENDENT fixed-project source-PRESERVE triggers (RSIM-R C1 + RSIM-U):
548
+ * a nested repeater in the subtree, OR every child terminal (no open child).
549
+ * Either one preserves the source (relink in place, mint only the template);
550
+ * otherwise the source is deleted and both template + instance are minted fresh.
551
+ */
552
+ function fixedProjectPreservesSource(subtree) {
553
+ return subtreeHasNestedRepeater(subtree) || subtreeAllChildrenTerminal(subtree);
554
+ }
555
+ /**
556
+ * FIXED make-repeating (RSIM1 to-do / RSIM6 project / RSIM3 create leg): the
557
+ * source is DESTROYED (identity replacement) and replaced by a hidden template
558
+ * (start=someday, rule tp=0, next-occurrence dates) plus EXACTLY ONE instance
559
+ * dated at the current occurrence. Area/title/notes/tags copy to both. For a
560
+ * PROJECT (type=1) with children, the source subtree is hard-deleted and a plain
561
+ * copy is minted under BOTH new projects (RSIM-P P1).
562
+ *
563
+ * Source-PRESERVE branches (probe-verified — the source is relinked in place as the
564
+ * current-occurrence instance and ONLY the hidden template is minted, no separate
565
+ * instance):
566
+ * - TO-DO (type=0, §RSIM-T): preserve IFF the source carries a non-NULL `deadline`
567
+ * (deadline-only preserved 1/1; bare/notes-only/tag-only/checklist-only each
568
+ * DELETE 4/4). The preserved source keeps its own deadline; the template drops it.
569
+ * - PROJECT (type=1, §RSIM-R C1 + §RSIM-U): preserve IFF the subtree contains a
570
+ * NESTED repeater (flatten path) OR every child is TERMINAL (completed/canceled,
571
+ * no open child). An open child, an empty subtree, plain-open children, area, and
572
+ * schedule all DELETE. See `applyMakeRepeatingFixedProjectPreserve`.
573
+ * Every other case deletes-and-remints (identity replacement).
574
+ */
575
+ function applyMakeRepeatingFixed(sim, type, params, ctx) {
576
+ const src = loadRepeatSource(sim, params.uuid);
577
+ // RSIM-P P1: a project's child subtree is captured BEFORE any mutation so the
578
+ // hidden template and the instance each receive a plain copy. To-dos have none.
579
+ const subtree = type === 1 ? readProjectSubtree(sim, params.uuid) : null;
580
+ const todayIso = ctx.todayIso;
581
+ const nextIso = addUnitsIso(todayIso, params.frequency, params.interval);
582
+ const deadlined = params.deadline === true || (params.startDaysEarlier ?? 0) > 0;
583
+ const startEarlier = params.startDaysEarlier ?? 0;
584
+ const spec = composeRepeatRuleSpec(params, todayIso, epochOfIso(nextIso));
585
+ const templateUuid = genUuid();
586
+ // The hidden template row is identical in both fixed-project fates.
587
+ insertRecurrenceRow(sim, ctx, {
588
+ uuid: templateUuid,
589
+ type,
590
+ title: src.title,
591
+ notes: src.notes,
592
+ area: src.area,
593
+ start: START.someday,
594
+ startDate: null,
595
+ deadline: deadlined ? encodePackedDate(DEADLINE_SENTINEL_ISO) : null,
596
+ recurrenceRuleXml: ruleXml(spec),
597
+ instanceCreationCount: 1,
598
+ instanceCreationStartDate: encodePackedDate(nextIso),
599
+ nextInstanceStartDate: encodePackedDate(nextIso),
600
+ });
601
+ copyTaskTags(sim, params.uuid, templateUuid);
602
+ // The current occurrence's start (deadlined series back off `startDaysEarlier`;
603
+ // decode identity deadline = startDate − ts; deadlined creation is not itself
604
+ // RSIM-drive-proven).
605
+ const instStartIso = deadlined ? addDaysIso(todayIso, -startEarlier) : todayIso;
606
+ // §RSIM-T: a fixed TO-DO carrying a deadline PRESERVES its source — relink it in
607
+ // place as the current-occurrence instance; only the template was minted above.
608
+ // The source keeps its own deadline (the template row already dropped it).
609
+ if (type === 0 && src.deadline !== null) {
610
+ sim
611
+ .prepare(`UPDATE TMTask SET start = ?, startDate = ?, rt1_repeatingTemplate = ?, userModificationDate = ? WHERE uuid = ?`)
612
+ .run(START.someday, encodePackedDate(instStartIso), templateUuid, ctx.nowEpoch, params.uuid);
613
+ return;
614
+ }
615
+ // RSIM-R C1 + RSIM-U: a fixed PROJECT with a nested repeater OR all-terminal
616
+ // children PRESERVES the source (relink in place; flatten any nested repeater).
617
+ if (subtree !== null && fixedProjectPreservesSource(subtree)) {
618
+ applyMakeRepeatingFixedProjectPreserve(sim, ctx, params.uuid, subtree, templateUuid, instStartIso);
619
+ return;
620
+ }
621
+ // RSIM-P P1: template-side children are completely PLAIN (no per-child link).
622
+ if (subtree !== null)
623
+ materializeSubtreeCopy(sim, ctx, subtree, templateUuid);
624
+ // ONE instance at the current occurrence (start=someday pending maintenance
625
+ // promotion). A deadlined series dates the instance's deadline at the occurrence.
626
+ const instanceUuid = genUuid();
627
+ insertRecurrenceRow(sim, ctx, {
628
+ uuid: instanceUuid,
629
+ type,
630
+ title: src.title,
631
+ notes: src.notes,
632
+ area: src.area,
633
+ start: START.someday,
634
+ startDate: encodePackedDate(instStartIso),
635
+ deadline: deadlined ? encodePackedDate(todayIso) : null,
636
+ repeatingTemplate: templateUuid,
637
+ });
638
+ copyTaskTags(sim, params.uuid, instanceUuid);
639
+ // RSIM-P P1: FIXED instance-side children are ALSO plain — no per-child link.
640
+ if (subtree !== null)
641
+ materializeSubtreeCopy(sim, ctx, subtree, instanceUuid);
642
+ // RSIM-P P1: hard-delete the whole source subtree, then the source project.
643
+ if (subtree !== null)
644
+ deleteProjectSubtree(sim, subtree);
645
+ sim.prepare("DELETE FROM TMTaskTag WHERE tasks = ?").run(params.uuid);
646
+ sim.prepare("DELETE FROM TMTask WHERE uuid = ?").run(params.uuid);
647
+ }
648
+ /**
649
+ * Fixed PROJECT source-PRESERVE path — either trigger (RSIM-R C1 nested repeater,
650
+ * A1/A2/A3/S2/S2b; or RSIM-U all-terminal children, U-comp/U-canc/U-both). The
651
+ * source project is PRESERVED and relinked as the current-occurrence instance
652
+ * rather than deleted; any nested repeater is FLATTENED in place (a no-op for the
653
+ * all-terminal trigger, which has none); and only the (already-minted) hidden
654
+ * template project is populated — there is NO separate instance project. Row-level
655
+ * shape (S2/U-comp, re-derived uuid-by-uuid):
656
+ * - source project CHANGED: `start → 2` (someday), `startDate → currentOccurrence`,
657
+ * `rt1_repeatingTemplate → template`. All other source columns (title, notes,
658
+ * area, deadline) are left untouched, and the source's children ride along
659
+ * UNCHANGED under the preserved instance (RSIM-U: childrenReplaced=0).
660
+ * - nested template row hard-deleted; nested instance demoted to PLAIN
661
+ * (`rt1_repeatingTemplate → NULL`).
662
+ * - the template project receives a PLAIN copy of the FLATTENED subtree (the
663
+ * nested template rows excluded; the demoted instance copied as a plain to-do;
664
+ * terminal children copy with their status preserved).
665
+ */
666
+ function applyMakeRepeatingFixedProjectPreserve(sim, ctx, sourceUuid, subtree, templateUuid, instStartIso) {
667
+ // The template project gets a plain copy of the FLATTENED subtree — nested
668
+ // template rows are dropped; every surviving to-do copies plain via
669
+ // materializeSubtreeCopy (start=1, no recurrence/template links).
670
+ // RSIM-S S-R1: the copy excludes trashed rows; materializeSubtreeCopy also
671
+ // skips them, but keep the flattened set clean for clarity.
672
+ const flattened = {
673
+ headings: subtree.headings.filter((h) => !h.trashed),
674
+ todos: subtree.todos.filter((t) => t.recurrenceRule === null && !t.trashed),
675
+ };
676
+ materializeSubtreeCopy(sim, ctx, flattened, templateUuid);
677
+ // Flatten the nested repeater IN the preserved source subtree. Trashed rows
678
+ // ride along untouched under the preserved source (RSIM-S S-R2, inferred).
679
+ for (const t of subtree.todos) {
680
+ if (t.trashed)
681
+ continue;
682
+ if (t.recurrenceRule !== null) {
683
+ // Nested hidden template: hard-delete its tags + checklist + row.
684
+ sim.prepare("DELETE FROM TMTaskTag WHERE tasks = ?").run(t.uuid);
685
+ sim.prepare("DELETE FROM TMChecklistItem WHERE task = ?").run(t.uuid);
686
+ sim.prepare("DELETE FROM TMTask WHERE uuid = ?").run(t.uuid);
687
+ }
688
+ else if (t.repeatingTemplate !== null) {
689
+ // Nested instance: demote to a plain to-do.
690
+ sim.prepare("UPDATE TMTask SET rt1_repeatingTemplate = NULL WHERE uuid = ?").run(t.uuid);
691
+ }
692
+ }
693
+ // Preserve the source project AS the current-occurrence instance.
694
+ sim
695
+ .prepare(`UPDATE TMTask SET start = ?, startDate = ?, rt1_repeatingTemplate = ?, userModificationDate = ? WHERE uuid = ?`)
696
+ .run(START.someday, encodePackedDate(instStartIso), templateUuid, ctx.nowEpoch, sourceUuid);
697
+ }
698
+ /**
699
+ * AFTER-COMPLETION make-repeating.
700
+ *
701
+ * TO-DO (type=0, RSIM2): the source is PRESERVED and relinked as the sole first
702
+ * instance (identity kept — §8g: identity replacement is fixed-only). A new tp=1
703
+ * template is created with NO next/reference dates (unknown until a completion).
704
+ * No fresh instance row is minted.
705
+ *
706
+ * PROJECT (type=1, RSIM-P P4): the type-0 path does NOT hold — the source is
707
+ * DELETED (like the fixed case) and BOTH a template and a fresh instance are
708
+ * minted, each with a full plain child copy, and each instance-side to-do child
709
+ * carries a per-child rt1_repeatingTemplate. Split out to a dedicated applier.
710
+ */
711
+ function applyMakeRepeatingAfterCompletion(sim, type, params, ctx) {
712
+ // RSIM-P P4: the after-completion PROJECT path is NOT the to-do path.
713
+ if (type === 1) {
714
+ applyMakeRepeatingAfterCompletionProject(sim, params, ctx);
715
+ return;
716
+ }
717
+ const src = loadRepeatSource(sim, params.uuid);
718
+ const refIso = decodePackedDate(src.startDate) ?? ctx.todayIso;
719
+ const deadlined = params.deadline === true || (params.startDaysEarlier ?? 0) > 0;
720
+ const spec = composeRepeatRuleSpec(params, refIso, epochOfIso(refIso));
721
+ const templateUuid = genUuid();
722
+ insertRecurrenceRow(sim, ctx, {
723
+ uuid: templateUuid,
724
+ type,
725
+ title: src.title,
726
+ notes: src.notes,
727
+ area: src.area,
728
+ start: START.someday,
729
+ startDate: null,
730
+ deadline: deadlined ? encodePackedDate(DEADLINE_SENTINEL_ISO) : null,
731
+ recurrenceRuleXml: ruleXml(spec),
732
+ instanceCreationCount: 0,
733
+ });
734
+ copyTaskTags(sim, params.uuid, templateUuid);
735
+ // Relink the preserved source as the instance; startDate/start unchanged.
736
+ sim
737
+ .prepare("UPDATE TMTask SET rt1_repeatingTemplate = ?, userModificationDate = ? WHERE uuid = ?")
738
+ .run(templateUuid, ctx.nowEpoch, params.uuid);
739
+ }
740
+ /**
741
+ * AFTER-COMPLETION make-repeating for a PROJECT (RSIM-P P4). Unlike the to-do
742
+ * path (RSIM2, source preserved), the source project is DELETED and both a
743
+ * template and a fresh instance are minted — the same delete+duplicate shape as
744
+ * the fixed project case, but with:
745
+ * - a tp=1 (after-completion) rule and NO next/reference dates
746
+ * (`rt1_nextInstanceStartDate` / `rt1_afterCompletionReferenceDate` NULL);
747
+ * - the template's `rt1_instanceCreationCount` = 1 (P4 observed, vs 0 for the
748
+ * after-completion TO-DO template);
749
+ * - the instance's `startDate` = the SOURCE project's startDate (not the
750
+ * current occurrence used by the fixed case).
751
+ *
752
+ * RSIM-R: instance-side children (to-dos AND headings) are PLAIN — no per-child
753
+ * `rt1_repeatingTemplate` link. RSIM-P P4's per-child links are a NON-reproducible
754
+ * anomaly (A5/R7/R8 = 3/3 plain); only the project row carries the instance→
755
+ * template link.
756
+ */
757
+ function applyMakeRepeatingAfterCompletionProject(sim, params, ctx) {
758
+ const src = loadRepeatSource(sim, params.uuid);
759
+ const subtree = readProjectSubtree(sim, params.uuid);
760
+ const refIso = decodePackedDate(src.startDate) ?? ctx.todayIso;
761
+ const deadlined = params.deadline === true || (params.startDaysEarlier ?? 0) > 0;
762
+ const spec = composeRepeatRuleSpec(params, refIso, epochOfIso(refIso));
763
+ const templateUuid = genUuid();
764
+ const instanceUuid = genUuid();
765
+ // Template: tp=1 rule, hidden, icCount=1, NO next/reference dates (P4).
766
+ insertRecurrenceRow(sim, ctx, {
767
+ uuid: templateUuid,
768
+ type: 1,
769
+ title: src.title,
770
+ notes: src.notes,
771
+ area: src.area,
772
+ start: START.someday,
773
+ startDate: null,
774
+ deadline: deadlined ? encodePackedDate(DEADLINE_SENTINEL_ISO) : null,
775
+ recurrenceRuleXml: ruleXml(spec),
776
+ instanceCreationCount: 1, // RSIM-P P4
777
+ });
778
+ copyTaskTags(sim, params.uuid, templateUuid);
779
+ // Template-side children are PLAIN.
780
+ materializeSubtreeCopy(sim, ctx, subtree, templateUuid);
781
+ // Instance: fresh row (source is deleted, unlike the to-do path), tmpl link,
782
+ // startDate = the SOURCE project's startDate (P4).
783
+ insertRecurrenceRow(sim, ctx, {
784
+ uuid: instanceUuid,
785
+ type: 1,
786
+ title: src.title,
787
+ notes: src.notes,
788
+ area: src.area,
789
+ start: START.someday,
790
+ startDate: src.startDate,
791
+ deadline: null,
792
+ repeatingTemplate: templateUuid,
793
+ });
794
+ copyTaskTags(sim, params.uuid, instanceUuid);
795
+ // RSIM-R: instance-side children are PLAIN — no per-child template link
796
+ // (P4's links do not reproduce; pass null exactly as the fixed case does).
797
+ materializeSubtreeCopy(sim, ctx, subtree, instanceUuid);
798
+ // Hard-delete the source subtree + the source project (P4 deletes the source).
799
+ deleteProjectSubtree(sim, subtree);
800
+ sim.prepare("DELETE FROM TMTaskTag WHERE tasks = ?").run(params.uuid);
801
+ sim.prepare("DELETE FROM TMTask WHERE uuid = ?").run(params.uuid);
802
+ }
803
+ /** Route a make-repeating to the fixed or after-completion applier by rule type. */
804
+ function applyMakeRepeating(sim, type, params, ctx) {
805
+ if (params.afterCompletion === true)
806
+ applyMakeRepeatingAfterCompletion(sim, type, params, ctx);
807
+ else
808
+ applyMakeRepeatingFixed(sim, type, params, ctx);
809
+ }
810
+ /**
811
+ * reschedule-repeat (RSIM5, to-do or project): identity PRESERVED, the rule
812
+ * rewritten in place to the target `{frequency, interval, anchors}` with the
813
+ * instance-creation date advanced to the new next occurrence. `tp` and the
814
+ * deadline-ness (ts + the template's deadline column) are preserved unless the
815
+ * reschedule explicitly changes them. (The shipped op's interval-entry app bug
816
+ * — RSIM5 caveat — is NOT modeled here: the simulator applies the TARGET rule.)
817
+ */
818
+ function applyReschedule(sim, params, ctx) {
819
+ const row = sim
820
+ .prepare("SELECT rt1_recurrenceRule AS rule FROM TMTask WHERE uuid = ?")
821
+ .get(params.uuid);
822
+ if (row === undefined || row.rule === null) {
823
+ throw new Error("simulator: reschedule-repeat target is not a repeating template");
824
+ }
825
+ const existing = decodeRecurrenceRule(row.rule);
826
+ const paramsHasAnchor = params.weekdays !== undefined || params.monthly !== undefined || params.yearly !== undefined;
827
+ const preserveAfterCompletion = params.afterCompletion === undefined &&
828
+ !paramsHasAnchor &&
829
+ existing.type === "after-completion";
830
+ const effective = { ...params };
831
+ if (preserveAfterCompletion)
832
+ effective.afterCompletion = true;
833
+ const todayIso = ctx.todayIso;
834
+ const nextIso = addUnitsIso(todayIso, params.frequency, params.interval);
835
+ const spec = composeRepeatRuleSpec(effective, todayIso, epochOfIso(nextIso));
836
+ const setsDeadline = params.deadline !== undefined || params.startDaysEarlier !== undefined;
837
+ if (!setsDeadline)
838
+ spec.ts = existing.startOffsetDays; // preserve the prior start offset
839
+ const sets = [
840
+ "rt1_recurrenceRule = ?",
841
+ "rt1_instanceCreationStartDate = ?",
842
+ "rt1_nextInstanceStartDate = ?",
843
+ "userModificationDate = ?",
844
+ ];
845
+ const binds = [
846
+ new TextEncoder().encode(ruleXml(spec)),
847
+ encodePackedDate(nextIso),
848
+ encodePackedDate(nextIso),
849
+ ctx.nowEpoch,
850
+ ];
851
+ if (setsDeadline) {
852
+ const deadlined = params.deadline === true || (params.startDaysEarlier ?? 0) > 0;
853
+ sets.push("deadline = ?");
854
+ binds.push(deadlined ? encodePackedDate(DEADLINE_SENTINEL_ISO) : null);
855
+ }
856
+ sim.prepare(`UPDATE TMTask SET ${sets.join(", ")} WHERE uuid = ?`).run(...binds, params.uuid);
857
+ }
858
+ /**
859
+ * Completion-side scheduling (RSIM4): completing an INSTANCE of an
860
+ * after-completion (tp=1) template stamps the template's reference + next-start
861
+ * dates (completion date, completion date + interval) WITHOUT materializing the
862
+ * next instance — it stays pending until its future start date arrives. A
863
+ * fixed-template instance (or a non-instance) is untouched here.
864
+ */
865
+ function stampAfterCompletionTemplate(sim, instanceUuid, ctx) {
866
+ const inst = sim
867
+ .prepare("SELECT rt1_repeatingTemplate AS tmpl FROM TMTask WHERE uuid = ?")
868
+ .get(instanceUuid);
869
+ const templateUuid = inst?.tmpl;
870
+ if (templateUuid === null || templateUuid === undefined)
871
+ return;
872
+ const tpl = sim
873
+ .prepare("SELECT rt1_recurrenceRule AS rule FROM TMTask WHERE uuid = ?")
874
+ .get(templateUuid);
875
+ if (tpl === undefined || tpl.rule === null)
876
+ return;
877
+ let rule;
878
+ try {
879
+ rule = decodeRecurrenceRule(tpl.rule);
880
+ }
881
+ catch {
882
+ return; // undecodable template — leave it untouched
883
+ }
884
+ if (rule.type !== "after-completion")
885
+ return;
886
+ const completionIso = ctx.todayIso;
887
+ const nextIso = addUnitsIso(completionIso, rule.unit, rule.interval);
888
+ sim
889
+ .prepare("UPDATE TMTask SET rt1_afterCompletionReferenceDate = ?, rt1_nextInstanceStartDate = ?, " +
890
+ "userModificationDate = ? WHERE uuid = ?")
891
+ .run(encodePackedDate(completionIso), encodePackedDate(nextIso), ctx.nowEpoch, templateUuid);
892
+ }
893
+ /** Typed applier helper — narrows the params for one operation kind. */
894
+ function op(fn) {
895
+ return (sim, params, ctx) => fn(sim, params, ctx);
896
+ }
897
+ const APPLIERS = {
898
+ "todo.add": op((sim, params, ctx) => {
899
+ let project = null;
900
+ let heading = null;
901
+ let area = null;
902
+ if (params.heading !== undefined && params.project !== undefined) {
903
+ const projUuid = containerUuid(sim, params.project, "project");
904
+ if (projUuid === null)
905
+ throw new Error("simulator: to-do heading needs a project");
906
+ const h = resolveHeading(sim, projUuid, params.heading);
907
+ if (h.resolved === null)
908
+ throw new Error("simulator: unresolved heading");
909
+ heading = h.resolved.uuid; // project reached via the heading (project col NULL)
910
+ }
911
+ else if (params.project !== undefined) {
912
+ project = containerUuid(sim, params.project, "project");
913
+ }
914
+ else if (params.area !== undefined) {
915
+ area = containerUuid(sim, params.area, "area");
916
+ }
917
+ const hasContainer = project !== null || heading !== null || area !== null;
918
+ const s = scheduleColumns(params.when, ctx.todayIso, hasContainer);
919
+ const uuid = genUuid();
920
+ insertTask(sim, 0, ctx, {
921
+ uuid,
922
+ title: params.title,
923
+ notes: params.notes ?? "",
924
+ start: s.start,
925
+ startDate: s.startDate,
926
+ startBucket: s.startBucket,
927
+ reminderTime: params.reminder !== undefined ? encodeReminderTime(params.reminder) : null,
928
+ deadline: params.deadline !== undefined ? encodePackedDate(params.deadline) : null,
929
+ area,
930
+ project,
931
+ heading,
932
+ });
933
+ if (params.tags !== undefined)
934
+ setTaskTags(sim, uuid, params.tags);
935
+ if (params.checklistItems !== undefined)
936
+ replaceChecklist(sim, uuid, params.checklistItems, ctx);
937
+ }),
938
+ "todo.update": op((sim, params, ctx) => applyEntityUpdate(sim, params, ctx)),
939
+ "todo.complete": op((sim, params, ctx) => {
940
+ setStatus(sim, params.uuid, STATUS.completed, ctx.nowEpoch, ctx);
941
+ // RSIM4: completing an after-completion instance schedules the next
942
+ // occurrence on its template without materializing it.
943
+ stampAfterCompletionTemplate(sim, params.uuid, ctx);
944
+ }),
945
+ "todo.cancel": op((sim, params, ctx) => setStatus(sim, params.uuid, STATUS.canceled, ctx.nowEpoch, ctx)),
946
+ "todo.reopen": op((sim, params, ctx) => setStatus(sim, params.uuid, STATUS.open, null, ctx)),
947
+ "todo.delete": op((sim, params, ctx) => sim
948
+ .prepare("UPDATE TMTask SET trashed = 1, userModificationDate = ? WHERE uuid = ?")
949
+ .run(ctx.nowEpoch, params.uuid)),
950
+ "todo.restore": op((sim, params, ctx) =>
951
+ // Put Back → un-trash into the Inbox, de-scheduled (E15).
952
+ sim
953
+ .prepare("UPDATE TMTask SET trashed = 0, start = 0, startDate = NULL, startBucket = 0, userModificationDate = ? WHERE uuid = ?")
954
+ .run(ctx.nowEpoch, params.uuid)),
955
+ "todo.move": op((sim, params, ctx) => {
956
+ if (params.inbox === true) {
957
+ sim
958
+ .prepare("UPDATE TMTask SET start = 0, startDate = NULL, startBucket = 0, project = NULL, area = NULL, heading = NULL, userModificationDate = ? WHERE uuid = ?")
959
+ .run(ctx.nowEpoch, params.uuid);
960
+ return;
961
+ }
962
+ if (params.loose === true) {
963
+ sim
964
+ .prepare("UPDATE TMTask SET project = NULL, area = NULL, heading = NULL, userModificationDate = ? WHERE uuid = ?")
965
+ .run(ctx.nowEpoch, params.uuid);
966
+ return;
967
+ }
968
+ if (params.noHeading === true) {
969
+ // Leave the heading, keep the current project (unheaded block). Resolve
970
+ // the current project (direct, or via the heading) then re-assert it.
971
+ const row = sim
972
+ .prepare("SELECT project, heading FROM TMTask WHERE uuid = ?")
973
+ .get(params.uuid);
974
+ const current = row?.project ??
975
+ (row?.heading != null
976
+ ? (sim.prepare("SELECT project FROM TMTask WHERE uuid = ?").get(row.heading)?.project ?? null)
977
+ : null);
978
+ sim
979
+ .prepare("UPDATE TMTask SET heading = NULL, project = ?, area = NULL, userModificationDate = ? WHERE uuid = ?")
980
+ .run(current, ctx.nowEpoch, params.uuid);
981
+ return;
982
+ }
983
+ if (params.heading !== undefined && params.project !== undefined) {
984
+ const projUuid = containerUuid(sim, params.project, "project");
985
+ if (projUuid === null)
986
+ throw new Error("simulator: move heading needs a project");
987
+ const h = resolveHeading(sim, projUuid, params.heading);
988
+ if (h.resolved === null)
989
+ throw new Error("simulator: unresolved heading");
990
+ sim
991
+ .prepare("UPDATE TMTask SET heading = ?, project = NULL, area = NULL, start = CASE WHEN start = 0 THEN 1 ELSE start END, userModificationDate = ? WHERE uuid = ?")
992
+ .run(h.resolved.uuid, ctx.nowEpoch, params.uuid);
993
+ return;
994
+ }
995
+ if (params.project !== undefined) {
996
+ const projUuid = containerUuid(sim, params.project, "project");
997
+ sim
998
+ .prepare("UPDATE TMTask SET project = ?, heading = NULL, area = NULL, start = CASE WHEN start = 0 THEN 1 ELSE start END, userModificationDate = ? WHERE uuid = ?")
999
+ .run(projUuid, ctx.nowEpoch, params.uuid);
1000
+ return;
1001
+ }
1002
+ if (params.area !== undefined) {
1003
+ const areaUuid = containerUuid(sim, params.area, "area");
1004
+ // Filing an inbox item into a container promotes it to Anytime (start
1005
+ // 0→1), matching the app; someday (2) and already-active items keep
1006
+ // their start. Same promotion on the project/heading branches above.
1007
+ sim
1008
+ .prepare("UPDATE TMTask SET area = ?, project = NULL, heading = NULL, start = CASE WHEN start = 0 THEN 1 ELSE start END, userModificationDate = ? WHERE uuid = ?")
1009
+ .run(areaUuid, ctx.nowEpoch, params.uuid);
1010
+ }
1011
+ }),
1012
+ "todo.set-tags": op((sim, params, ctx) => {
1013
+ setTaskTags(sim, params.uuid, params.tags);
1014
+ sim
1015
+ .prepare("UPDATE TMTask SET userModificationDate = ? WHERE uuid = ?")
1016
+ .run(ctx.nowEpoch, params.uuid);
1017
+ }),
1018
+ "todo.replace-checklist": op((sim, params, ctx) => replaceChecklist(sim, params.uuid, params.items, ctx)),
1019
+ "project.add": op((sim, params, ctx) => {
1020
+ const area = containerUuid(sim, params.area, "area");
1021
+ const s = scheduleColumns(params.when, ctx.todayIso, area !== null);
1022
+ const uuid = genUuid();
1023
+ insertTask(sim, 1, ctx, {
1024
+ uuid,
1025
+ title: params.title,
1026
+ notes: params.notes ?? "",
1027
+ start: s.start,
1028
+ startDate: s.startDate,
1029
+ startBucket: s.startBucket,
1030
+ deadline: params.deadline !== undefined ? encodePackedDate(params.deadline) : null,
1031
+ area,
1032
+ });
1033
+ // `todos` seed children are not asserted by the delta and are omitted.
1034
+ }),
1035
+ "project.update": op((sim, params, ctx) => applyEntityUpdate(sim, params, ctx)),
1036
+ "project.complete": op((sim, params, ctx) => {
1037
+ // Cascade open children to completed; canceled children are untouched (T08).
1038
+ // RSIM-P P2: completion also flips the containing HEADING rows (type=2)
1039
+ // status 0→3, not just the to-dos (type=0).
1040
+ const children = sim
1041
+ .prepare(`SELECT uuid FROM TMTask WHERE trashed = 0 AND status = 0
1042
+ AND (
1043
+ (type = 0 AND (project = ? OR heading IN (SELECT uuid FROM TMTask WHERE type = 2 AND project = ?)))
1044
+ OR (type = 2 AND project = ?)
1045
+ )`)
1046
+ .all(params.uuid, params.uuid, params.uuid);
1047
+ for (const c of children)
1048
+ setStatus(sim, c.uuid, STATUS.completed, ctx.nowEpoch, ctx);
1049
+ setStatus(sim, params.uuid, STATUS.completed, ctx.nowEpoch, ctx);
1050
+ // RSIM-P P2: completing an INSTANCE project also promotes its own start 2→1
1051
+ // (observed only for instance projects — a plain project is left untouched).
1052
+ sim
1053
+ .prepare("UPDATE TMTask SET start = 1 WHERE uuid = ? AND rt1_repeatingTemplate IS NOT NULL")
1054
+ .run(params.uuid);
1055
+ }),
1056
+ "area.add": op((sim, params) => {
1057
+ const uuid = genUuid();
1058
+ sim
1059
+ .prepare(`INSERT INTO TMArea (uuid, title, visible, "index") VALUES (?, ?, 1, 0)`)
1060
+ .run(uuid, params.title);
1061
+ if (params.tags !== undefined) {
1062
+ for (const tagUuid of tagUuids(sim, params.tags)) {
1063
+ sim.prepare("INSERT INTO TMAreaTag (areas, tags) VALUES (?, ?)").run(uuid, tagUuid);
1064
+ }
1065
+ }
1066
+ }),
1067
+ "area.update": op((sim, params) => {
1068
+ const res = resolveArea(sim, { uuid: params.target, title: params.target });
1069
+ if (res.resolved === null)
1070
+ throw new Error("simulator: unresolved area target");
1071
+ const uuid = res.resolved.uuid;
1072
+ if (params.title !== undefined) {
1073
+ sim.prepare("UPDATE TMArea SET title = ? WHERE uuid = ?").run(params.title, uuid);
1074
+ }
1075
+ if (params.tags !== undefined) {
1076
+ sim.prepare("DELETE FROM TMAreaTag WHERE areas = ?").run(uuid);
1077
+ for (const tagUuid of tagUuids(sim, params.tags)) {
1078
+ sim.prepare("INSERT INTO TMAreaTag (areas, tags) VALUES (?, ?)").run(uuid, tagUuid);
1079
+ }
1080
+ }
1081
+ }),
1082
+ "tag.add": op((sim, params) => {
1083
+ let parent = null;
1084
+ if (params.parent !== undefined) {
1085
+ const res = resolveTag(sim, params.parent);
1086
+ if (res.resolved === null)
1087
+ throw new Error("simulator: unresolved parent tag");
1088
+ parent = res.resolved.uuid;
1089
+ }
1090
+ sim
1091
+ .prepare(`INSERT INTO TMTag (uuid, title, shortcut, usedDate, parent, "index") VALUES (?, ?, NULL, NULL, ?, 0)`)
1092
+ .run(genUuid(), params.title, parent);
1093
+ }),
1094
+ "project.add-heading": op((sim, params, ctx) => {
1095
+ const projUuid = containerUuid(sim, params.project, "project");
1096
+ if (projUuid === null)
1097
+ throw new Error("simulator: project.add-heading needs a project");
1098
+ insertTask(sim, 2, ctx, { uuid: genUuid(), title: params.title, project: projUuid });
1099
+ }),
1100
+ // Recurrence ops (RSIM1–6). project.add-repeating is delivered by the
1101
+ // runAddRepeatingProject orchestrator over project.add + project.make-repeating
1102
+ // (both covered here), so it needs no direct applier. pause/resume/stop-repeat
1103
+ // are deliberately OMITTED — no RSIM shape proves their delta, and unsupported
1104
+ // beats guessed.
1105
+ "todo.make-repeating": op((sim, params, ctx) => applyMakeRepeating(sim, 0, params, ctx)),
1106
+ "todo.reschedule-repeat": op((sim, params, ctx) => applyReschedule(sim, params, ctx)),
1107
+ "project.make-repeating": op((sim, params, ctx) => applyMakeRepeating(sim, 1, params, ctx)),
1108
+ "project.reschedule-repeat": op((sim, params, ctx) => applyReschedule(sim, params, ctx)),
1109
+ };
1110
+ /** The ops this simulator can apply — the ONLY entries in its honest matrix. */
1111
+ export const SIMULATOR_COVERAGE = Object.keys(APPLIERS);
1112
+ function simulatorMatrix() {
1113
+ const support = {
1114
+ support: "yes",
1115
+ disruption: 0,
1116
+ validation: "validated",
1117
+ notes: "simulated",
1118
+ };
1119
+ const matrix = {};
1120
+ for (const kind of SIMULATOR_COVERAGE)
1121
+ matrix[kind] = { ...support };
1122
+ return matrix;
1123
+ }
1124
+ // ------------------------------------------------------------- vector
1125
+ /**
1126
+ * Build the simulator vector for `dbPath`. Throws at creation if the fence is
1127
+ * not satisfied (so a misconfigured harness fails loud, never silently writing
1128
+ * to a wrong DB). `now` is injectable for tests; production reads the effective
1129
+ * clock from the environment (THINGS_NOW) so applier timestamps align with the
1130
+ * pipeline's, which the create-probe verification depends on.
1131
+ */
1132
+ export function createSimulatorVector(dbPath, opts = {}) {
1133
+ const reason = simulatorFenceReason(dbPath);
1134
+ if (reason !== null) {
1135
+ throw new Error(`simulator write vector refused: ${reason}`);
1136
+ }
1137
+ const now = opts.now ?? resolveEnvNow();
1138
+ let sim;
1139
+ return {
1140
+ id: "url-scheme",
1141
+ matrix: simulatorMatrix(),
1142
+ simulates: true,
1143
+ async execute(invocation) {
1144
+ // Defensive re-check: the environment could have changed underneath a
1145
+ // live instance. Refuse rather than write to a now-unfenced DB.
1146
+ const liveReason = simulatorFenceReason(dbPath);
1147
+ if (liveReason !== null) {
1148
+ return { exitCode: 1, stdout: "", stderr: `simulator fence inactive: ${liveReason}` };
1149
+ }
1150
+ const kind = invocation.op;
1151
+ if (kind === undefined) {
1152
+ return {
1153
+ exitCode: 1,
1154
+ stdout: "",
1155
+ stderr: "simulator: invocation carries no structured op",
1156
+ };
1157
+ }
1158
+ const applier = APPLIERS[kind];
1159
+ if (applier === undefined) {
1160
+ return { exitCode: 1, stdout: "", stderr: `simulator: no applier for op ${kind}` };
1161
+ }
1162
+ sim ??= new DatabaseSync(dbPath);
1163
+ const when = now();
1164
+ const ctx = {
1165
+ nowEpoch: Math.floor(when.getTime() / 1000),
1166
+ todayIso: localToday(when),
1167
+ };
1168
+ try {
1169
+ applier(sim, invocation.opParams, ctx);
1170
+ }
1171
+ catch (err) {
1172
+ return {
1173
+ exitCode: 1,
1174
+ stdout: "",
1175
+ stderr: `simulator applier for ${kind} failed: ${err.message}`,
1176
+ };
1177
+ }
1178
+ return { exitCode: 0, stdout: "", stderr: "" };
1179
+ },
1180
+ };
1181
+ }
1182
+ /** Read the pinned THINGS_NOW instant, if any; else real time per call. */
1183
+ function resolveEnvNow() {
1184
+ const raw = process.env["THINGS_NOW"];
1185
+ if (raw !== undefined && raw.trim() !== "") {
1186
+ const ms = new Date(raw).getTime();
1187
+ if (!Number.isNaN(ms))
1188
+ return () => new Date(ms);
1189
+ }
1190
+ return () => new Date();
1191
+ }
1192
+ //# sourceMappingURL=simulator.js.map