things-api 0.9.0 → 0.11.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 (261) hide show
  1. package/README.md +57 -12
  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/clock.d.ts +8 -0
  8. package/dist/cli/clock.js +24 -0
  9. package/dist/cli/clock.js.map +1 -0
  10. package/dist/cli/commands/area.d.ts +26 -3
  11. package/dist/cli/commands/area.js +129 -75
  12. package/dist/cli/commands/area.js.map +1 -1
  13. package/dist/cli/commands/doctor.d.ts +1 -1
  14. package/dist/cli/commands/doctor.js +44 -5
  15. package/dist/cli/commands/doctor.js.map +1 -1
  16. package/dist/cli/commands/install-skill.d.ts +45 -0
  17. package/dist/cli/commands/install-skill.js +207 -0
  18. package/dist/cli/commands/install-skill.js.map +1 -0
  19. package/dist/cli/commands/mcp.js +23 -4
  20. package/dist/cli/commands/mcp.js.map +1 -1
  21. package/dist/cli/commands/project.d.ts +18 -1
  22. package/dist/cli/commands/project.js +74 -35
  23. package/dist/cli/commands/project.js.map +1 -1
  24. package/dist/cli/commands/reads.d.ts +15 -2
  25. package/dist/cli/commands/reads.js +459 -165
  26. package/dist/cli/commands/reads.js.map +1 -1
  27. package/dist/cli/commands/repeat-flags.d.ts +20 -0
  28. package/dist/cli/commands/repeat-flags.js +76 -0
  29. package/dist/cli/commands/repeat-flags.js.map +1 -0
  30. package/dist/cli/commands/setup.js +8 -4
  31. package/dist/cli/commands/setup.js.map +1 -1
  32. package/dist/cli/commands/show.js +53 -30
  33. package/dist/cli/commands/show.js.map +1 -1
  34. package/dist/cli/commands/todo.d.ts +1 -1
  35. package/dist/cli/commands/todo.js +20 -13
  36. package/dist/cli/commands/todo.js.map +1 -1
  37. package/dist/cli/commands/writes.js +388 -161
  38. package/dist/cli/commands/writes.js.map +1 -1
  39. package/dist/cli/did-you-mean.d.ts +1 -1
  40. package/dist/cli/excess-args.d.ts +15 -0
  41. package/dist/cli/excess-args.js +51 -0
  42. package/dist/cli/excess-args.js.map +1 -0
  43. package/dist/cli/glyphs.d.ts +55 -2
  44. package/dist/cli/glyphs.js +114 -21
  45. package/dist/cli/glyphs.js.map +1 -1
  46. package/dist/cli/help.d.ts +54 -0
  47. package/dist/cli/help.js +405 -0
  48. package/dist/cli/help.js.map +1 -0
  49. package/dist/cli/main.js +64 -26
  50. package/dist/cli/main.js.map +1 -1
  51. package/dist/cli/move-hint.d.ts +45 -0
  52. package/dist/cli/move-hint.js +196 -0
  53. package/dist/cli/move-hint.js.map +1 -0
  54. package/dist/cli/period.d.ts +9 -8
  55. package/dist/cli/period.js +56 -30
  56. package/dist/cli/period.js.map +1 -1
  57. package/dist/cli/read-driver.d.ts +26 -19
  58. package/dist/cli/read-driver.js +77 -33
  59. package/dist/cli/read-driver.js.map +1 -1
  60. package/dist/cli/render.d.ts +92 -28
  61. package/dist/cli/render.js +333 -103
  62. package/dist/cli/render.js.map +1 -1
  63. package/dist/cli/resolve-invocation.d.ts +42 -4
  64. package/dist/cli/resolve-invocation.js +97 -14
  65. package/dist/cli/resolve-invocation.js.map +1 -1
  66. package/dist/cli/skill-check.d.ts +21 -0
  67. package/dist/cli/skill-check.js +82 -0
  68. package/dist/cli/skill-check.js.map +1 -0
  69. package/dist/cli/skill.d.ts +62 -0
  70. package/dist/cli/skill.js +142 -0
  71. package/dist/cli/skill.js.map +1 -0
  72. package/dist/cli/tag-filters.d.ts +63 -0
  73. package/dist/cli/tag-filters.js +59 -0
  74. package/dist/cli/tag-filters.js.map +1 -0
  75. package/dist/cli/verb-hint.d.ts +25 -0
  76. package/dist/cli/verb-hint.js +163 -0
  77. package/dist/cli/verb-hint.js.map +1 -0
  78. package/dist/cli/version.d.ts +4 -0
  79. package/dist/cli/version.js +18 -0
  80. package/dist/cli/version.js.map +1 -0
  81. package/dist/cli/width.d.ts +135 -0
  82. package/dist/cli/width.js +313 -0
  83. package/dist/cli/width.js.map +1 -0
  84. package/dist/client.d.ts +160 -24
  85. package/dist/client.js +132 -19
  86. package/dist/client.js.map +1 -1
  87. package/dist/config.d.ts +11 -0
  88. package/dist/config.js +3 -0
  89. package/dist/config.js.map +1 -1
  90. package/dist/contracts.d.ts +97 -17
  91. package/dist/contracts.js +44 -1
  92. package/dist/contracts.js.map +1 -1
  93. package/dist/db/fingerprint.d.ts +12 -0
  94. package/dist/db/fingerprint.js +15 -1
  95. package/dist/db/fingerprint.js.map +1 -1
  96. package/dist/db/locate.js +1 -1
  97. package/dist/db/locate.js.map +1 -1
  98. package/dist/diagnose.d.ts +63 -0
  99. package/dist/diagnose.js +38 -1
  100. package/dist/diagnose.js.map +1 -1
  101. package/dist/index.d.ts +36 -5
  102. package/dist/index.js +38 -2
  103. package/dist/index.js.map +1 -1
  104. package/dist/mcp/server.d.ts +9 -1
  105. package/dist/mcp/server.js +1038 -436
  106. package/dist/mcp/server.js.map +1 -1
  107. package/dist/model/clock.d.ts +61 -0
  108. package/dist/model/clock.js +71 -0
  109. package/dist/model/clock.js.map +1 -0
  110. package/dist/model/dates.d.ts +26 -3
  111. package/dist/model/dates.js +84 -3
  112. package/dist/model/dates.js.map +1 -1
  113. package/dist/model/entities.d.ts +48 -10
  114. package/dist/model/entities.js.map +1 -1
  115. package/dist/model/mappers.d.ts +9 -2
  116. package/dist/model/mappers.js +32 -9
  117. package/dist/model/mappers.js.map +1 -1
  118. package/dist/model/serialize.d.ts +28 -0
  119. package/dist/model/serialize.js +80 -0
  120. package/dist/model/serialize.js.map +1 -0
  121. package/dist/model/when-sugar.d.ts +47 -0
  122. package/dist/model/when-sugar.js +45 -0
  123. package/dist/model/when-sugar.js.map +1 -0
  124. package/dist/read/area-view.d.ts +8 -1
  125. package/dist/read/area-view.js +55 -17
  126. package/dist/read/area-view.js.map +1 -1
  127. package/dist/read/detail.d.ts +1 -1
  128. package/dist/read/detail.js +11 -4
  129. package/dist/read/detail.js.map +1 -1
  130. package/dist/read/filter-contract.d.ts +117 -0
  131. package/dist/read/filter-contract.js +78 -0
  132. package/dist/read/filter-contract.js.map +1 -0
  133. package/dist/read/log-boundary.d.ts +1 -1
  134. package/dist/read/log-boundary.js +13 -3
  135. package/dist/read/log-boundary.js.map +1 -1
  136. package/dist/read/predicates.d.ts +30 -0
  137. package/dist/read/predicates.js +30 -0
  138. package/dist/read/predicates.js.map +1 -1
  139. package/dist/read/project-view.d.ts +12 -1
  140. package/dist/read/project-view.js +45 -12
  141. package/dist/read/project-view.js.map +1 -1
  142. package/dist/read/queries.d.ts +152 -7
  143. package/dist/read/queries.js +277 -36
  144. package/dist/read/queries.js.map +1 -1
  145. package/dist/read/sections.d.ts +51 -0
  146. package/dist/read/sections.js +37 -0
  147. package/dist/read/sections.js.map +1 -0
  148. package/dist/read/sidebar-order.js +2 -1
  149. package/dist/read/sidebar-order.js.map +1 -1
  150. package/dist/read/snapshot.d.ts +1 -1
  151. package/dist/read/snapshot.js +9 -2
  152. package/dist/read/snapshot.js.map +1 -1
  153. package/dist/read/tags.d.ts +27 -3
  154. package/dist/read/tags.js +83 -12
  155. package/dist/read/tags.js.map +1 -1
  156. package/dist/read/truncation.d.ts +71 -0
  157. package/dist/read/{pagination.js → truncation.js} +103 -94
  158. package/dist/read/truncation.js.map +1 -0
  159. package/dist/read/views.d.ts +147 -22
  160. package/dist/read/views.js +282 -80
  161. package/dist/read/views.js.map +1 -1
  162. package/dist/surface-copy.d.ts +19 -0
  163. package/dist/surface-copy.js +32 -0
  164. package/dist/surface-copy.js.map +1 -1
  165. package/dist/sync-health.d.ts +78 -0
  166. package/dist/sync-health.js +312 -0
  167. package/dist/sync-health.js.map +1 -0
  168. package/dist/write/accessibility-probe.d.ts +12 -0
  169. package/dist/write/accessibility-probe.js +71 -0
  170. package/dist/write/accessibility-probe.js.map +1 -0
  171. package/dist/write/automation-probe.d.ts +8 -1
  172. package/dist/write/automation-probe.js +16 -1
  173. package/dist/write/automation-probe.js.map +1 -1
  174. package/dist/write/batch.js +5 -2
  175. package/dist/write/batch.js.map +1 -1
  176. package/dist/write/capabilities.d.ts +8 -0
  177. package/dist/write/capabilities.js +13 -6
  178. package/dist/write/capabilities.js.map +1 -1
  179. package/dist/write/commands.d.ts +3 -1
  180. package/dist/write/commands.js +602 -49
  181. package/dist/write/commands.js.map +1 -1
  182. package/dist/write/edit-checklist.js +3 -2
  183. package/dist/write/edit-checklist.js.map +1 -1
  184. package/dist/write/guards.d.ts +2 -7
  185. package/dist/write/guards.js +87 -6
  186. package/dist/write/guards.js.map +1 -1
  187. package/dist/write/heading.js +2 -0
  188. package/dist/write/heading.js.map +1 -1
  189. package/dist/write/lock.d.ts +40 -2
  190. package/dist/write/lock.js +91 -14
  191. package/dist/write/lock.js.map +1 -1
  192. package/dist/write/make-repeating-project.d.ts +4 -0
  193. package/dist/write/make-repeating-project.js +253 -0
  194. package/dist/write/make-repeating-project.js.map +1 -0
  195. package/dist/write/operations.d.ts +145 -1
  196. package/dist/write/operations.js +48 -0
  197. package/dist/write/operations.js.map +1 -1
  198. package/dist/write/pipeline.d.ts +73 -2
  199. package/dist/write/pipeline.js +218 -43
  200. package/dist/write/pipeline.js.map +1 -1
  201. package/dist/write/planner.js +8 -1
  202. package/dist/write/planner.js.map +1 -1
  203. package/dist/write/pre-state.d.ts +105 -3
  204. package/dist/write/pre-state.js +144 -3
  205. package/dist/write/pre-state.js.map +1 -1
  206. package/dist/write/recurrence-rule-blob.d.ts +65 -0
  207. package/dist/write/recurrence-rule-blob.js +150 -0
  208. package/dist/write/recurrence-rule-blob.js.map +1 -0
  209. package/dist/write/reopen.js +3 -2
  210. package/dist/write/reopen.js.map +1 -1
  211. package/dist/write/reorder.js +27 -17
  212. package/dist/write/reorder.js.map +1 -1
  213. package/dist/write/repeat-rule.d.ts +22 -0
  214. package/dist/write/repeat-rule.js +258 -0
  215. package/dist/write/repeat-rule.js.map +1 -0
  216. package/dist/write/reversibility.js +49 -0
  217. package/dist/write/reversibility.js.map +1 -1
  218. package/dist/write/tag-refs.d.ts +47 -0
  219. package/dist/write/tag-refs.js +126 -0
  220. package/dist/write/tag-refs.js.map +1 -0
  221. package/dist/write/undo.d.ts +74 -5
  222. package/dist/write/undo.js +493 -80
  223. package/dist/write/undo.js.map +1 -1
  224. package/dist/write/vectors/registry.d.ts +15 -1
  225. package/dist/write/vectors/registry.js +65 -2
  226. package/dist/write/vectors/registry.js.map +1 -1
  227. package/dist/write/vectors/simulator.d.ts +44 -0
  228. package/dist/write/vectors/simulator.js +1136 -0
  229. package/dist/write/vectors/simulator.js.map +1 -0
  230. package/dist/write/vectors/types.d.ts +154 -3
  231. package/dist/write/vectors/ui-certification.d.ts +54 -0
  232. package/dist/write/vectors/ui-certification.js +43 -0
  233. package/dist/write/vectors/ui-certification.js.map +1 -0
  234. package/dist/write/vectors/ui-drag.d.ts +188 -0
  235. package/dist/write/vectors/ui-drag.js +1095 -0
  236. package/dist/write/vectors/ui-drag.js.map +1 -0
  237. package/dist/write/vectors/ui-recipes.d.ts +83 -0
  238. package/dist/write/vectors/ui-recipes.js +610 -0
  239. package/dist/write/vectors/ui-recipes.js.map +1 -0
  240. package/dist/write/vectors/ui.d.ts +152 -0
  241. package/dist/write/vectors/ui.js +704 -0
  242. package/dist/write/vectors/ui.js.map +1 -0
  243. package/dist/write/verify/delta.d.ts +141 -4
  244. package/dist/write/verify/delta.js +199 -21
  245. package/dist/write/verify/delta.js.map +1 -1
  246. package/dist/write/verify/poller.d.ts +7 -1
  247. package/dist/write/verify/poller.js +21 -1
  248. package/dist/write/verify/poller.js.map +1 -1
  249. package/package.json +18 -3
  250. package/skills/things-cli/SKILL.md +53 -0
  251. package/skills/things-cli/references/contracts.md +41 -0
  252. package/skills/things-cli/references/gui.md +11 -0
  253. package/skills/things-cli/references/model.md +51 -0
  254. package/dist/cli/exit-codes.d.ts +0 -26
  255. package/dist/cli/exit-codes.js +0 -26
  256. package/dist/cli/exit-codes.js.map +0 -1
  257. package/dist/cli/output.d.ts +0 -42
  258. package/dist/cli/output.js +0 -16
  259. package/dist/cli/output.js.map +0 -1
  260. package/dist/read/pagination.d.ts +0 -104
  261. package/dist/read/pagination.js.map +0 -1
@@ -0,0 +1,1136 @@
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 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
+ };
377
+ }
378
+ /**
379
+ * Read a project's full containment subtree (headings + direct/headed to-dos,
380
+ * with tags + checklist) before any mutation.
381
+ *
382
+ * RSIM-S (§RSIM-S S-R1): trashed rows are INCLUDED (each tagged `trashed`) so a
383
+ * source-DELETE conversion can hard-delete the WHOLE subtree — reality destroys
384
+ * a pre-trashed child along with the source, leaving no orphan. Copy paths
385
+ * (`materializeSubtreeCopy`) filter these back out — a spawned/template copy
386
+ * never carries trashed rows (RSIM-P2 A3, probe-verified).
387
+ */
388
+ function readProjectSubtree(sim, projectUuid) {
389
+ const headingRows = sim
390
+ .prepare(`SELECT uuid, title, notes, status, stopDate, trashed, "index" AS idx
391
+ FROM TMTask WHERE type = 2 AND project = ? ORDER BY "index"`)
392
+ .all(projectUuid);
393
+ const headings = headingRows.map((h) => ({
394
+ uuid: h.uuid,
395
+ title: h.title ?? "",
396
+ notes: h.notes ?? "",
397
+ status: h.status,
398
+ stopDate: h.stopDate,
399
+ index: h.idx,
400
+ trashed: h.trashed === 1,
401
+ }));
402
+ const headingUuids = headings.map((h) => h.uuid);
403
+ // A direct child has project=<projectUuid>; a headed child has project=NULL
404
+ // and heading=<one of this project's headings> — this union captures both.
405
+ const placeholders = headingUuids.map(() => "?").join(", ");
406
+ const todoRows = sim
407
+ .prepare(`SELECT uuid, title, notes, status, stopDate, deadline, heading, trashed,
408
+ rt1_recurrenceRule AS recurrenceRule, rt1_repeatingTemplate AS repeatingTemplate,
409
+ "index" AS idx
410
+ FROM TMTask WHERE type = 0
411
+ AND (project = ?${headingUuids.length > 0 ? ` OR heading IN (${placeholders})` : ""})
412
+ ORDER BY "index"`)
413
+ .all(projectUuid, ...headingUuids);
414
+ const todos = todoRows.map((t) => ({
415
+ uuid: t.uuid,
416
+ title: t.title ?? "",
417
+ notes: t.notes ?? "",
418
+ status: t.status,
419
+ stopDate: t.stopDate,
420
+ deadline: t.deadline,
421
+ index: t.idx,
422
+ trashed: t.trashed === 1,
423
+ headingUuid: t.heading,
424
+ recurrenceRule: t.recurrenceRule,
425
+ repeatingTemplate: t.repeatingTemplate,
426
+ tags: sim.prepare("SELECT tags FROM TMTaskTag WHERE tasks = ?").all(t.uuid).map((r) => r.tags),
427
+ checklist: sim
428
+ .prepare(`SELECT title, status, "index" AS idx FROM TMChecklistItem WHERE task = ? ORDER BY "index"`)
429
+ .all(t.uuid).map((c) => ({ title: c.title ?? "", status: c.status, index: c.idx })),
430
+ }));
431
+ return { headings, todos };
432
+ }
433
+ /** UPDATE a row's list index (insertTask hardcodes 0; the copy preserves source order). */
434
+ function setRowIndex(sim, uuid, index) {
435
+ sim.prepare(`UPDATE TMTask SET "index" = ? WHERE uuid = ?`).run(index, uuid);
436
+ }
437
+ /**
438
+ * Materialize a PLAIN copy of `subtree` under `newProjectUuid` (fresh uuids for
439
+ * every heading/to-do; tags + checklist duplicated; index order + heading
440
+ * membership preserved; children `start=1`, `rt1_recurrenceRule`/
441
+ * `rt1_repeatingTemplate` NULL).
442
+ *
443
+ * RSIM-R: copied children are ALWAYS plain — both fixed and after-completion
444
+ * instance/template sides. (RSIM-P P4's per-child instance→template links are a
445
+ * non-reproducible anomaly; A5/R7/R8 = 3/3 plain — so no linkMap parameter.)
446
+ */
447
+ function materializeSubtreeCopy(sim, ctx, subtree, newProjectUuid) {
448
+ const headingIdMap = new Map();
449
+ for (const h of subtree.headings) {
450
+ if (h.trashed)
451
+ continue; // RSIM-S S-R1: copies exclude trashed rows (RSIM-P2 A3).
452
+ const newUuid = genUuid();
453
+ insertTask(sim, 2, ctx, {
454
+ uuid: newUuid,
455
+ title: h.title,
456
+ notes: h.notes,
457
+ start: START.active, // RSIM-P P1: copied children are start=1
458
+ project: newProjectUuid,
459
+ });
460
+ setRowIndex(sim, newUuid, h.index);
461
+ if (h.status !== STATUS.open)
462
+ setStatus(sim, newUuid, h.status, h.stopDate ?? ctx.nowEpoch, ctx);
463
+ headingIdMap.set(h.uuid, newUuid);
464
+ }
465
+ for (const t of subtree.todos) {
466
+ if (t.trashed)
467
+ continue; // RSIM-S S-R1: copies exclude trashed rows (RSIM-P2 A3).
468
+ const newUuid = genUuid();
469
+ // Preserve the containment invariant: a headed child points at the COPIED
470
+ // heading with project NULL; a direct child points at the new project.
471
+ const heading = t.headingUuid !== null ? (headingIdMap.get(t.headingUuid) ?? null) : null;
472
+ const project = heading !== null ? null : newProjectUuid;
473
+ const openChecklist = t.checklist.filter((c) => c.status === STATUS.open).length;
474
+ insertTask(sim, 0, ctx, {
475
+ uuid: newUuid,
476
+ title: t.title,
477
+ notes: t.notes,
478
+ start: START.active, // RSIM-P P1: start=1
479
+ startDate: null,
480
+ deadline: t.deadline,
481
+ project,
482
+ heading,
483
+ checklistItemsCount: t.checklist.length,
484
+ openChecklistItemsCount: openChecklist,
485
+ });
486
+ setRowIndex(sim, newUuid, t.index);
487
+ if (t.status !== STATUS.open)
488
+ setStatus(sim, newUuid, t.status, t.stopDate ?? ctx.nowEpoch, ctx);
489
+ for (const tag of t.tags) {
490
+ sim.prepare("INSERT INTO TMTaskTag (tasks, tags) VALUES (?, ?)").run(newUuid, tag);
491
+ }
492
+ for (const c of t.checklist) {
493
+ sim
494
+ .prepare(`INSERT INTO TMChecklistItem (uuid, userModificationDate, creationDate, title, status, stopDate, "index", task, leavesTombstone)
495
+ VALUES (?, ?, ?, ?, ?, NULL, ?, ?, 0)`)
496
+ .run(genUuid(), ctx.nowEpoch, ctx.nowEpoch, c.title, c.status, c.index, newUuid);
497
+ }
498
+ }
499
+ }
500
+ /**
501
+ * Hard-DELETE a source subtree: each to-do's tags + checklist + row, then the
502
+ * heading rows.
503
+ *
504
+ * RSIM-S (§RSIM-S S-R1): the subtree includes already-trashed rows, and they are
505
+ * destroyed here too — a source-DELETE conversion hard-deletes the WHOLE source
506
+ * subtree, so a pre-trashed child does NOT survive as a dangling row pointing at
507
+ * the deleted source (it is neither copied nor left in the Trash).
508
+ */
509
+ function deleteProjectSubtree(sim, subtree) {
510
+ for (const t of subtree.todos) {
511
+ sim.prepare("DELETE FROM TMTaskTag WHERE tasks = ?").run(t.uuid);
512
+ sim.prepare("DELETE FROM TMChecklistItem WHERE task = ?").run(t.uuid);
513
+ sim.prepare("DELETE FROM TMTask WHERE uuid = ?").run(t.uuid);
514
+ }
515
+ for (const h of subtree.headings) {
516
+ sim.prepare("DELETE FROM TMTaskTag WHERE tasks = ?").run(h.uuid);
517
+ sim.prepare("DELETE FROM TMTask WHERE uuid = ?").run(h.uuid);
518
+ }
519
+ }
520
+ /**
521
+ * RSIM-R: a NESTED repeater in a project's subtree is a to-do carrying either
522
+ * `rt1_recurrenceRule` (the nested hidden template) or `rt1_repeatingTemplate`
523
+ * (the nested repeater's instance). Its presence flips the fixed project
524
+ * make-repeating from delete-and-mint-both to the source-preserving flatten path.
525
+ */
526
+ function subtreeHasNestedRepeater(subtree) {
527
+ // RSIM-S S-R1: consider only live rows — a trashed nested repeater does not
528
+ // flip source-fate (a trashed child alone leaves the delete-default intact).
529
+ return subtree.todos.some((t) => !t.trashed && (t.recurrenceRule !== null || t.repeatingTemplate !== null));
530
+ }
531
+ /**
532
+ * FIXED make-repeating (RSIM1 to-do / RSIM6 project / RSIM3 create leg): the
533
+ * source is DESTROYED (identity replacement) and replaced by a hidden template
534
+ * (start=someday, rule tp=0, next-occurrence dates) plus EXACTLY ONE instance
535
+ * dated at the current occurrence. Area/title/notes/tags copy to both. For a
536
+ * PROJECT (type=1) with children, the source subtree is hard-deleted and a plain
537
+ * copy is minted under BOTH new projects (RSIM-P P1).
538
+ *
539
+ * RSIM-R exception (fixed PROJECT only): if the source project's subtree contains
540
+ * a NESTED repeater, the source is instead PRESERVED and relinked as the current-
541
+ * occurrence instance, the nested repeater is FLATTENED in place, and ONLY the
542
+ * hidden template project is minted (no separate instance project). See
543
+ * `applyMakeRepeatingFixedProjectFlatten`. Area/When are irrelevant; the sole
544
+ * discriminator is the nested repeater. The to-do fixed-preserve anomaly (a
545
+ * content-rich to-do; §RSIM-R parked, unisolated trigger) is deliberately
546
+ * UNMODELED — to-dos always delete here.
547
+ */
548
+ function applyMakeRepeatingFixed(sim, type, params, ctx) {
549
+ const src = loadRepeatSource(sim, params.uuid);
550
+ // RSIM-P P1: a project's child subtree is captured BEFORE any mutation so the
551
+ // hidden template and the instance each receive a plain copy. To-dos have none.
552
+ const subtree = type === 1 ? readProjectSubtree(sim, params.uuid) : null;
553
+ const todayIso = ctx.todayIso;
554
+ const nextIso = addUnitsIso(todayIso, params.frequency, params.interval);
555
+ const deadlined = params.deadline === true || (params.startDaysEarlier ?? 0) > 0;
556
+ const startEarlier = params.startDaysEarlier ?? 0;
557
+ const spec = composeRepeatRuleSpec(params, todayIso, epochOfIso(nextIso));
558
+ const templateUuid = genUuid();
559
+ // The hidden template row is identical in both fixed-project fates.
560
+ insertRecurrenceRow(sim, ctx, {
561
+ uuid: templateUuid,
562
+ type,
563
+ title: src.title,
564
+ notes: src.notes,
565
+ area: src.area,
566
+ start: START.someday,
567
+ startDate: null,
568
+ deadline: deadlined ? encodePackedDate(DEADLINE_SENTINEL_ISO) : null,
569
+ recurrenceRuleXml: ruleXml(spec),
570
+ instanceCreationCount: 1,
571
+ instanceCreationStartDate: encodePackedDate(nextIso),
572
+ nextInstanceStartDate: encodePackedDate(nextIso),
573
+ });
574
+ copyTaskTags(sim, params.uuid, templateUuid);
575
+ // The current occurrence's start (deadlined series back off `startDaysEarlier`;
576
+ // decode identity deadline = startDate − ts; deadlined creation is not itself
577
+ // RSIM-drive-proven).
578
+ const instStartIso = deadlined ? addDaysIso(todayIso, -startEarlier) : todayIso;
579
+ // RSIM-R: fixed PROJECT with a nested repeater PRESERVES the source (flatten path).
580
+ if (subtree !== null && subtreeHasNestedRepeater(subtree)) {
581
+ applyMakeRepeatingFixedProjectFlatten(sim, ctx, params.uuid, subtree, templateUuid, instStartIso);
582
+ return;
583
+ }
584
+ // RSIM-P P1: template-side children are completely PLAIN (no per-child link).
585
+ if (subtree !== null)
586
+ materializeSubtreeCopy(sim, ctx, subtree, templateUuid);
587
+ // ONE instance at the current occurrence (start=someday pending maintenance
588
+ // promotion). A deadlined series dates the instance's deadline at the occurrence.
589
+ const instanceUuid = genUuid();
590
+ insertRecurrenceRow(sim, ctx, {
591
+ uuid: instanceUuid,
592
+ type,
593
+ title: src.title,
594
+ notes: src.notes,
595
+ area: src.area,
596
+ start: START.someday,
597
+ startDate: encodePackedDate(instStartIso),
598
+ deadline: deadlined ? encodePackedDate(todayIso) : null,
599
+ repeatingTemplate: templateUuid,
600
+ });
601
+ copyTaskTags(sim, params.uuid, instanceUuid);
602
+ // RSIM-P P1: FIXED instance-side children are ALSO plain — no per-child link.
603
+ if (subtree !== null)
604
+ materializeSubtreeCopy(sim, ctx, subtree, instanceUuid);
605
+ // RSIM-P P1: hard-delete the whole source subtree, then the source project.
606
+ if (subtree !== null)
607
+ deleteProjectSubtree(sim, subtree);
608
+ sim.prepare("DELETE FROM TMTaskTag WHERE tasks = ?").run(params.uuid);
609
+ sim.prepare("DELETE FROM TMTask WHERE uuid = ?").run(params.uuid);
610
+ }
611
+ /**
612
+ * RSIM-R (fixed PROJECT whose subtree contains a nested repeater — A1/A2/A3/S2/
613
+ * S2b): the source project is PRESERVED and relinked as the current-occurrence
614
+ * instance rather than deleted; the nested repeater is FLATTENED in place; and
615
+ * only the (already-minted) hidden template project is populated — there is NO
616
+ * separate instance project. Row-level shape (S2, re-derived uuid-by-uuid):
617
+ * - source project CHANGED: `start → 2` (someday), `startDate → currentOccurrence`,
618
+ * `rt1_repeatingTemplate → template`. All other source columns (title, notes,
619
+ * area, deadline) are left untouched.
620
+ * - nested template row hard-deleted; nested instance demoted to PLAIN
621
+ * (`rt1_repeatingTemplate → NULL`).
622
+ * - the template project receives a PLAIN copy of the FLATTENED subtree (the
623
+ * nested template rows excluded; the demoted instance copied as a plain to-do).
624
+ */
625
+ function applyMakeRepeatingFixedProjectFlatten(sim, ctx, sourceUuid, subtree, templateUuid, instStartIso) {
626
+ // The template project gets a plain copy of the FLATTENED subtree — nested
627
+ // template rows are dropped; every surviving to-do copies plain via
628
+ // materializeSubtreeCopy (start=1, no recurrence/template links).
629
+ // RSIM-S S-R1: the copy excludes trashed rows; materializeSubtreeCopy also
630
+ // skips them, but keep the flattened set clean for clarity.
631
+ const flattened = {
632
+ headings: subtree.headings.filter((h) => !h.trashed),
633
+ todos: subtree.todos.filter((t) => t.recurrenceRule === null && !t.trashed),
634
+ };
635
+ materializeSubtreeCopy(sim, ctx, flattened, templateUuid);
636
+ // Flatten the nested repeater IN the preserved source subtree. Trashed rows
637
+ // ride along untouched under the preserved source (RSIM-S S-R2, inferred).
638
+ for (const t of subtree.todos) {
639
+ if (t.trashed)
640
+ continue;
641
+ if (t.recurrenceRule !== null) {
642
+ // Nested hidden template: hard-delete its tags + checklist + row.
643
+ sim.prepare("DELETE FROM TMTaskTag WHERE tasks = ?").run(t.uuid);
644
+ sim.prepare("DELETE FROM TMChecklistItem WHERE task = ?").run(t.uuid);
645
+ sim.prepare("DELETE FROM TMTask WHERE uuid = ?").run(t.uuid);
646
+ }
647
+ else if (t.repeatingTemplate !== null) {
648
+ // Nested instance: demote to a plain to-do.
649
+ sim.prepare("UPDATE TMTask SET rt1_repeatingTemplate = NULL WHERE uuid = ?").run(t.uuid);
650
+ }
651
+ }
652
+ // Preserve the source project AS the current-occurrence instance.
653
+ sim
654
+ .prepare(`UPDATE TMTask SET start = ?, startDate = ?, rt1_repeatingTemplate = ?, userModificationDate = ? WHERE uuid = ?`)
655
+ .run(START.someday, encodePackedDate(instStartIso), templateUuid, ctx.nowEpoch, sourceUuid);
656
+ }
657
+ /**
658
+ * AFTER-COMPLETION make-repeating.
659
+ *
660
+ * TO-DO (type=0, RSIM2): the source is PRESERVED and relinked as the sole first
661
+ * instance (identity kept — §8g: identity replacement is fixed-only). A new tp=1
662
+ * template is created with NO next/reference dates (unknown until a completion).
663
+ * No fresh instance row is minted.
664
+ *
665
+ * PROJECT (type=1, RSIM-P P4): the type-0 path does NOT hold — the source is
666
+ * DELETED (like the fixed case) and BOTH a template and a fresh instance are
667
+ * minted, each with a full plain child copy, and each instance-side to-do child
668
+ * carries a per-child rt1_repeatingTemplate. Split out to a dedicated applier.
669
+ */
670
+ function applyMakeRepeatingAfterCompletion(sim, type, params, ctx) {
671
+ // RSIM-P P4: the after-completion PROJECT path is NOT the to-do path.
672
+ if (type === 1) {
673
+ applyMakeRepeatingAfterCompletionProject(sim, params, ctx);
674
+ return;
675
+ }
676
+ const src = loadRepeatSource(sim, params.uuid);
677
+ const refIso = decodePackedDate(src.startDate) ?? ctx.todayIso;
678
+ const deadlined = params.deadline === true || (params.startDaysEarlier ?? 0) > 0;
679
+ const spec = composeRepeatRuleSpec(params, refIso, epochOfIso(refIso));
680
+ const templateUuid = genUuid();
681
+ insertRecurrenceRow(sim, ctx, {
682
+ uuid: templateUuid,
683
+ type,
684
+ title: src.title,
685
+ notes: src.notes,
686
+ area: src.area,
687
+ start: START.someday,
688
+ startDate: null,
689
+ deadline: deadlined ? encodePackedDate(DEADLINE_SENTINEL_ISO) : null,
690
+ recurrenceRuleXml: ruleXml(spec),
691
+ instanceCreationCount: 0,
692
+ });
693
+ copyTaskTags(sim, params.uuid, templateUuid);
694
+ // Relink the preserved source as the instance; startDate/start unchanged.
695
+ sim
696
+ .prepare("UPDATE TMTask SET rt1_repeatingTemplate = ?, userModificationDate = ? WHERE uuid = ?")
697
+ .run(templateUuid, ctx.nowEpoch, params.uuid);
698
+ }
699
+ /**
700
+ * AFTER-COMPLETION make-repeating for a PROJECT (RSIM-P P4). Unlike the to-do
701
+ * path (RSIM2, source preserved), the source project is DELETED and both a
702
+ * template and a fresh instance are minted — the same delete+duplicate shape as
703
+ * the fixed project case, but with:
704
+ * - a tp=1 (after-completion) rule and NO next/reference dates
705
+ * (`rt1_nextInstanceStartDate` / `rt1_afterCompletionReferenceDate` NULL);
706
+ * - the template's `rt1_instanceCreationCount` = 1 (P4 observed, vs 0 for the
707
+ * after-completion TO-DO template);
708
+ * - the instance's `startDate` = the SOURCE project's startDate (not the
709
+ * current occurrence used by the fixed case).
710
+ *
711
+ * RSIM-R: instance-side children (to-dos AND headings) are PLAIN — no per-child
712
+ * `rt1_repeatingTemplate` link. RSIM-P P4's per-child links are a NON-reproducible
713
+ * anomaly (A5/R7/R8 = 3/3 plain); only the project row carries the instance→
714
+ * template link.
715
+ */
716
+ function applyMakeRepeatingAfterCompletionProject(sim, params, ctx) {
717
+ const src = loadRepeatSource(sim, params.uuid);
718
+ const subtree = readProjectSubtree(sim, params.uuid);
719
+ const refIso = decodePackedDate(src.startDate) ?? ctx.todayIso;
720
+ const deadlined = params.deadline === true || (params.startDaysEarlier ?? 0) > 0;
721
+ const spec = composeRepeatRuleSpec(params, refIso, epochOfIso(refIso));
722
+ const templateUuid = genUuid();
723
+ const instanceUuid = genUuid();
724
+ // Template: tp=1 rule, hidden, icCount=1, NO next/reference dates (P4).
725
+ insertRecurrenceRow(sim, ctx, {
726
+ uuid: templateUuid,
727
+ type: 1,
728
+ title: src.title,
729
+ notes: src.notes,
730
+ area: src.area,
731
+ start: START.someday,
732
+ startDate: null,
733
+ deadline: deadlined ? encodePackedDate(DEADLINE_SENTINEL_ISO) : null,
734
+ recurrenceRuleXml: ruleXml(spec),
735
+ instanceCreationCount: 1, // RSIM-P P4
736
+ });
737
+ copyTaskTags(sim, params.uuid, templateUuid);
738
+ // Template-side children are PLAIN.
739
+ materializeSubtreeCopy(sim, ctx, subtree, templateUuid);
740
+ // Instance: fresh row (source is deleted, unlike the to-do path), tmpl link,
741
+ // startDate = the SOURCE project's startDate (P4).
742
+ insertRecurrenceRow(sim, ctx, {
743
+ uuid: instanceUuid,
744
+ type: 1,
745
+ title: src.title,
746
+ notes: src.notes,
747
+ area: src.area,
748
+ start: START.someday,
749
+ startDate: src.startDate,
750
+ deadline: null,
751
+ repeatingTemplate: templateUuid,
752
+ });
753
+ copyTaskTags(sim, params.uuid, instanceUuid);
754
+ // RSIM-R: instance-side children are PLAIN — no per-child template link
755
+ // (P4's links do not reproduce; pass null exactly as the fixed case does).
756
+ materializeSubtreeCopy(sim, ctx, subtree, instanceUuid);
757
+ // Hard-delete the source subtree + the source project (P4 deletes the source).
758
+ deleteProjectSubtree(sim, subtree);
759
+ sim.prepare("DELETE FROM TMTaskTag WHERE tasks = ?").run(params.uuid);
760
+ sim.prepare("DELETE FROM TMTask WHERE uuid = ?").run(params.uuid);
761
+ }
762
+ /** Route a make-repeating to the fixed or after-completion applier by rule type. */
763
+ function applyMakeRepeating(sim, type, params, ctx) {
764
+ if (params.afterCompletion === true)
765
+ applyMakeRepeatingAfterCompletion(sim, type, params, ctx);
766
+ else
767
+ applyMakeRepeatingFixed(sim, type, params, ctx);
768
+ }
769
+ /**
770
+ * reschedule-repeat (RSIM5, to-do or project): identity PRESERVED, the rule
771
+ * rewritten in place to the target `{frequency, interval, anchors}` with the
772
+ * instance-creation date advanced to the new next occurrence. `tp` and the
773
+ * deadline-ness (ts + the template's deadline column) are preserved unless the
774
+ * reschedule explicitly changes them. (The shipped op's interval-entry app bug
775
+ * — RSIM5 caveat — is NOT modeled here: the simulator applies the TARGET rule.)
776
+ */
777
+ function applyReschedule(sim, params, ctx) {
778
+ const row = sim
779
+ .prepare("SELECT rt1_recurrenceRule AS rule FROM TMTask WHERE uuid = ?")
780
+ .get(params.uuid);
781
+ if (row === undefined || row.rule === null) {
782
+ throw new Error("simulator: reschedule-repeat target is not a repeating template");
783
+ }
784
+ const existing = decodeRecurrenceRule(row.rule);
785
+ const paramsHasAnchor = params.weekdays !== undefined || params.monthly !== undefined || params.yearly !== undefined;
786
+ const preserveAfterCompletion = params.afterCompletion === undefined &&
787
+ !paramsHasAnchor &&
788
+ existing.type === "after-completion";
789
+ const effective = { ...params };
790
+ if (preserveAfterCompletion)
791
+ effective.afterCompletion = true;
792
+ const todayIso = ctx.todayIso;
793
+ const nextIso = addUnitsIso(todayIso, params.frequency, params.interval);
794
+ const spec = composeRepeatRuleSpec(effective, todayIso, epochOfIso(nextIso));
795
+ const setsDeadline = params.deadline !== undefined || params.startDaysEarlier !== undefined;
796
+ if (!setsDeadline)
797
+ spec.ts = existing.startOffsetDays; // preserve the prior start offset
798
+ const sets = [
799
+ "rt1_recurrenceRule = ?",
800
+ "rt1_instanceCreationStartDate = ?",
801
+ "rt1_nextInstanceStartDate = ?",
802
+ "userModificationDate = ?",
803
+ ];
804
+ const binds = [
805
+ new TextEncoder().encode(ruleXml(spec)),
806
+ encodePackedDate(nextIso),
807
+ encodePackedDate(nextIso),
808
+ ctx.nowEpoch,
809
+ ];
810
+ if (setsDeadline) {
811
+ const deadlined = params.deadline === true || (params.startDaysEarlier ?? 0) > 0;
812
+ sets.push("deadline = ?");
813
+ binds.push(deadlined ? encodePackedDate(DEADLINE_SENTINEL_ISO) : null);
814
+ }
815
+ sim.prepare(`UPDATE TMTask SET ${sets.join(", ")} WHERE uuid = ?`).run(...binds, params.uuid);
816
+ }
817
+ /**
818
+ * Completion-side scheduling (RSIM4): completing an INSTANCE of an
819
+ * after-completion (tp=1) template stamps the template's reference + next-start
820
+ * dates (completion date, completion date + interval) WITHOUT materializing the
821
+ * next instance — it stays pending until its future start date arrives. A
822
+ * fixed-template instance (or a non-instance) is untouched here.
823
+ */
824
+ function stampAfterCompletionTemplate(sim, instanceUuid, ctx) {
825
+ const inst = sim
826
+ .prepare("SELECT rt1_repeatingTemplate AS tmpl FROM TMTask WHERE uuid = ?")
827
+ .get(instanceUuid);
828
+ const templateUuid = inst?.tmpl;
829
+ if (templateUuid === null || templateUuid === undefined)
830
+ return;
831
+ const tpl = sim
832
+ .prepare("SELECT rt1_recurrenceRule AS rule FROM TMTask WHERE uuid = ?")
833
+ .get(templateUuid);
834
+ if (tpl === undefined || tpl.rule === null)
835
+ return;
836
+ let rule;
837
+ try {
838
+ rule = decodeRecurrenceRule(tpl.rule);
839
+ }
840
+ catch {
841
+ return; // undecodable template — leave it untouched
842
+ }
843
+ if (rule.type !== "after-completion")
844
+ return;
845
+ const completionIso = ctx.todayIso;
846
+ const nextIso = addUnitsIso(completionIso, rule.unit, rule.interval);
847
+ sim
848
+ .prepare("UPDATE TMTask SET rt1_afterCompletionReferenceDate = ?, rt1_nextInstanceStartDate = ?, " +
849
+ "userModificationDate = ? WHERE uuid = ?")
850
+ .run(encodePackedDate(completionIso), encodePackedDate(nextIso), ctx.nowEpoch, templateUuid);
851
+ }
852
+ /** Typed applier helper — narrows the params for one operation kind. */
853
+ function op(fn) {
854
+ return (sim, params, ctx) => fn(sim, params, ctx);
855
+ }
856
+ const APPLIERS = {
857
+ "todo.add": op((sim, params, ctx) => {
858
+ let project = null;
859
+ let heading = null;
860
+ let area = null;
861
+ if (params.heading !== undefined && params.project !== undefined) {
862
+ const projUuid = containerUuid(sim, params.project, "project");
863
+ if (projUuid === null)
864
+ throw new Error("simulator: to-do heading needs a project");
865
+ const h = resolveHeading(sim, projUuid, params.heading);
866
+ if (h.resolved === null)
867
+ throw new Error("simulator: unresolved heading");
868
+ heading = h.resolved.uuid; // project reached via the heading (project col NULL)
869
+ }
870
+ else if (params.project !== undefined) {
871
+ project = containerUuid(sim, params.project, "project");
872
+ }
873
+ else if (params.area !== undefined) {
874
+ area = containerUuid(sim, params.area, "area");
875
+ }
876
+ const hasContainer = project !== null || heading !== null || area !== null;
877
+ const s = scheduleColumns(params.when, ctx.todayIso, hasContainer);
878
+ const uuid = genUuid();
879
+ insertTask(sim, 0, ctx, {
880
+ uuid,
881
+ title: params.title,
882
+ notes: params.notes ?? "",
883
+ start: s.start,
884
+ startDate: s.startDate,
885
+ startBucket: s.startBucket,
886
+ reminderTime: params.reminder !== undefined ? encodeReminderTime(params.reminder) : null,
887
+ deadline: params.deadline !== undefined ? encodePackedDate(params.deadline) : null,
888
+ area,
889
+ project,
890
+ heading,
891
+ });
892
+ if (params.tags !== undefined)
893
+ setTaskTags(sim, uuid, params.tags);
894
+ if (params.checklistItems !== undefined)
895
+ replaceChecklist(sim, uuid, params.checklistItems, ctx);
896
+ }),
897
+ "todo.update": op((sim, params, ctx) => applyEntityUpdate(sim, params, ctx)),
898
+ "todo.complete": op((sim, params, ctx) => {
899
+ setStatus(sim, params.uuid, STATUS.completed, ctx.nowEpoch, ctx);
900
+ // RSIM4: completing an after-completion instance schedules the next
901
+ // occurrence on its template without materializing it.
902
+ stampAfterCompletionTemplate(sim, params.uuid, ctx);
903
+ }),
904
+ "todo.cancel": op((sim, params, ctx) => setStatus(sim, params.uuid, STATUS.canceled, ctx.nowEpoch, ctx)),
905
+ "todo.reopen": op((sim, params, ctx) => setStatus(sim, params.uuid, STATUS.open, null, ctx)),
906
+ "todo.delete": op((sim, params, ctx) => sim
907
+ .prepare("UPDATE TMTask SET trashed = 1, userModificationDate = ? WHERE uuid = ?")
908
+ .run(ctx.nowEpoch, params.uuid)),
909
+ "todo.restore": op((sim, params, ctx) =>
910
+ // Put Back → un-trash into the Inbox, de-scheduled (E15).
911
+ sim
912
+ .prepare("UPDATE TMTask SET trashed = 0, start = 0, startDate = NULL, startBucket = 0, userModificationDate = ? WHERE uuid = ?")
913
+ .run(ctx.nowEpoch, params.uuid)),
914
+ "todo.move": op((sim, params, ctx) => {
915
+ if (params.inbox === true) {
916
+ sim
917
+ .prepare("UPDATE TMTask SET start = 0, startDate = NULL, startBucket = 0, project = NULL, area = NULL, heading = NULL, userModificationDate = ? WHERE uuid = ?")
918
+ .run(ctx.nowEpoch, params.uuid);
919
+ return;
920
+ }
921
+ if (params.detach === true) {
922
+ sim
923
+ .prepare("UPDATE TMTask SET project = NULL, area = NULL, heading = NULL, userModificationDate = ? WHERE uuid = ?")
924
+ .run(ctx.nowEpoch, params.uuid);
925
+ return;
926
+ }
927
+ if (params.heading !== undefined && params.project !== undefined) {
928
+ const projUuid = containerUuid(sim, params.project, "project");
929
+ if (projUuid === null)
930
+ throw new Error("simulator: move heading needs a project");
931
+ const h = resolveHeading(sim, projUuid, params.heading);
932
+ if (h.resolved === null)
933
+ throw new Error("simulator: unresolved heading");
934
+ sim
935
+ .prepare("UPDATE TMTask SET heading = ?, project = NULL, area = NULL, start = CASE WHEN start = 0 THEN 1 ELSE start END, userModificationDate = ? WHERE uuid = ?")
936
+ .run(h.resolved.uuid, ctx.nowEpoch, params.uuid);
937
+ return;
938
+ }
939
+ if (params.project !== undefined) {
940
+ const projUuid = containerUuid(sim, params.project, "project");
941
+ sim
942
+ .prepare("UPDATE TMTask SET project = ?, heading = NULL, area = NULL, start = CASE WHEN start = 0 THEN 1 ELSE start END, userModificationDate = ? WHERE uuid = ?")
943
+ .run(projUuid, ctx.nowEpoch, params.uuid);
944
+ return;
945
+ }
946
+ if (params.area !== undefined) {
947
+ const areaUuid = containerUuid(sim, params.area, "area");
948
+ // Filing an inbox item into a container promotes it to Anytime (start
949
+ // 0→1), matching the app; someday (2) and already-active items keep
950
+ // their start. Same promotion on the project/heading branches above.
951
+ sim
952
+ .prepare("UPDATE TMTask SET area = ?, project = NULL, heading = NULL, start = CASE WHEN start = 0 THEN 1 ELSE start END, userModificationDate = ? WHERE uuid = ?")
953
+ .run(areaUuid, ctx.nowEpoch, params.uuid);
954
+ }
955
+ }),
956
+ "todo.set-tags": op((sim, params, ctx) => {
957
+ setTaskTags(sim, params.uuid, params.tags);
958
+ sim
959
+ .prepare("UPDATE TMTask SET userModificationDate = ? WHERE uuid = ?")
960
+ .run(ctx.nowEpoch, params.uuid);
961
+ }),
962
+ "todo.replace-checklist": op((sim, params, ctx) => replaceChecklist(sim, params.uuid, params.items, ctx)),
963
+ "project.add": op((sim, params, ctx) => {
964
+ const area = containerUuid(sim, params.area, "area");
965
+ const s = scheduleColumns(params.when, ctx.todayIso, area !== null);
966
+ const uuid = genUuid();
967
+ insertTask(sim, 1, ctx, {
968
+ uuid,
969
+ title: params.title,
970
+ notes: params.notes ?? "",
971
+ start: s.start,
972
+ startDate: s.startDate,
973
+ startBucket: s.startBucket,
974
+ deadline: params.deadline !== undefined ? encodePackedDate(params.deadline) : null,
975
+ area,
976
+ });
977
+ // `todos` seed children are not asserted by the delta and are omitted.
978
+ }),
979
+ "project.update": op((sim, params, ctx) => applyEntityUpdate(sim, params, ctx)),
980
+ "project.complete": op((sim, params, ctx) => {
981
+ // Cascade open children to completed; canceled children are untouched (T08).
982
+ // RSIM-P P2: completion also flips the containing HEADING rows (type=2)
983
+ // status 0→3, not just the to-dos (type=0).
984
+ const children = sim
985
+ .prepare(`SELECT uuid FROM TMTask WHERE trashed = 0 AND status = 0
986
+ AND (
987
+ (type = 0 AND (project = ? OR heading IN (SELECT uuid FROM TMTask WHERE type = 2 AND project = ?)))
988
+ OR (type = 2 AND project = ?)
989
+ )`)
990
+ .all(params.uuid, params.uuid, params.uuid);
991
+ for (const c of children)
992
+ setStatus(sim, c.uuid, STATUS.completed, ctx.nowEpoch, ctx);
993
+ setStatus(sim, params.uuid, STATUS.completed, ctx.nowEpoch, ctx);
994
+ // RSIM-P P2: completing an INSTANCE project also promotes its own start 2→1
995
+ // (observed only for instance projects — a plain project is left untouched).
996
+ sim
997
+ .prepare("UPDATE TMTask SET start = 1 WHERE uuid = ? AND rt1_repeatingTemplate IS NOT NULL")
998
+ .run(params.uuid);
999
+ }),
1000
+ "area.add": op((sim, params) => {
1001
+ const uuid = genUuid();
1002
+ sim
1003
+ .prepare(`INSERT INTO TMArea (uuid, title, visible, "index") VALUES (?, ?, 1, 0)`)
1004
+ .run(uuid, params.title);
1005
+ if (params.tags !== undefined) {
1006
+ for (const tagUuid of tagUuids(sim, params.tags)) {
1007
+ sim.prepare("INSERT INTO TMAreaTag (areas, tags) VALUES (?, ?)").run(uuid, tagUuid);
1008
+ }
1009
+ }
1010
+ }),
1011
+ "area.update": op((sim, params) => {
1012
+ const res = resolveArea(sim, { uuid: params.target, title: params.target });
1013
+ if (res.resolved === null)
1014
+ throw new Error("simulator: unresolved area target");
1015
+ const uuid = res.resolved.uuid;
1016
+ if (params.title !== undefined) {
1017
+ sim.prepare("UPDATE TMArea SET title = ? WHERE uuid = ?").run(params.title, uuid);
1018
+ }
1019
+ if (params.tags !== undefined) {
1020
+ sim.prepare("DELETE FROM TMAreaTag WHERE areas = ?").run(uuid);
1021
+ for (const tagUuid of tagUuids(sim, params.tags)) {
1022
+ sim.prepare("INSERT INTO TMAreaTag (areas, tags) VALUES (?, ?)").run(uuid, tagUuid);
1023
+ }
1024
+ }
1025
+ }),
1026
+ "tag.add": op((sim, params) => {
1027
+ let parent = null;
1028
+ if (params.parent !== undefined) {
1029
+ const res = resolveTag(sim, params.parent);
1030
+ if (res.resolved === null)
1031
+ throw new Error("simulator: unresolved parent tag");
1032
+ parent = res.resolved.uuid;
1033
+ }
1034
+ sim
1035
+ .prepare(`INSERT INTO TMTag (uuid, title, shortcut, usedDate, parent, "index") VALUES (?, ?, NULL, NULL, ?, 0)`)
1036
+ .run(genUuid(), params.title, parent);
1037
+ }),
1038
+ "heading.create": op((sim, params, ctx) => {
1039
+ const projUuid = containerUuid(sim, params.project, "project");
1040
+ if (projUuid === null)
1041
+ throw new Error("simulator: heading.create needs a project");
1042
+ insertTask(sim, 2, ctx, { uuid: genUuid(), title: params.title, project: projUuid });
1043
+ }),
1044
+ // Recurrence ops (RSIM1–6). project.create-repeating is delivered by the
1045
+ // runCreateRepeatingProject orchestrator over project.add + project.make-repeating
1046
+ // (both covered here), so it needs no direct applier. pause/resume/stop-repeat
1047
+ // are deliberately OMITTED — no RSIM shape proves their delta, and unsupported
1048
+ // beats guessed.
1049
+ "todo.make-repeating": op((sim, params, ctx) => applyMakeRepeating(sim, 0, params, ctx)),
1050
+ "todo.reschedule-repeat": op((sim, params, ctx) => applyReschedule(sim, params, ctx)),
1051
+ "project.make-repeating": op((sim, params, ctx) => applyMakeRepeating(sim, 1, params, ctx)),
1052
+ "project.reschedule-repeat": op((sim, params, ctx) => applyReschedule(sim, params, ctx)),
1053
+ };
1054
+ /** The ops this simulator can apply — the ONLY entries in its honest matrix. */
1055
+ export const SIMULATOR_COVERAGE = Object.keys(APPLIERS);
1056
+ function simulatorMatrix() {
1057
+ const support = {
1058
+ support: "yes",
1059
+ disruption: 0,
1060
+ validation: "validated",
1061
+ notes: "simulated",
1062
+ };
1063
+ const matrix = {};
1064
+ for (const kind of SIMULATOR_COVERAGE)
1065
+ matrix[kind] = { ...support };
1066
+ return matrix;
1067
+ }
1068
+ // ------------------------------------------------------------- vector
1069
+ /**
1070
+ * Build the simulator vector for `dbPath`. Throws at creation if the fence is
1071
+ * not satisfied (so a misconfigured harness fails loud, never silently writing
1072
+ * to a wrong DB). `now` is injectable for tests; production reads the effective
1073
+ * clock from the environment (THINGS_NOW) so applier timestamps align with the
1074
+ * pipeline's, which the create-probe verification depends on.
1075
+ */
1076
+ export function createSimulatorVector(dbPath, opts = {}) {
1077
+ const reason = simulatorFenceReason(dbPath);
1078
+ if (reason !== null) {
1079
+ throw new Error(`simulator write vector refused: ${reason}`);
1080
+ }
1081
+ const now = opts.now ?? resolveEnvNow();
1082
+ let sim;
1083
+ return {
1084
+ id: "url-scheme",
1085
+ matrix: simulatorMatrix(),
1086
+ simulates: true,
1087
+ async execute(invocation) {
1088
+ // Defensive re-check: the environment could have changed underneath a
1089
+ // live instance. Refuse rather than write to a now-unfenced DB.
1090
+ const liveReason = simulatorFenceReason(dbPath);
1091
+ if (liveReason !== null) {
1092
+ return { exitCode: 1, stdout: "", stderr: `simulator fence inactive: ${liveReason}` };
1093
+ }
1094
+ const kind = invocation.op;
1095
+ if (kind === undefined) {
1096
+ return {
1097
+ exitCode: 1,
1098
+ stdout: "",
1099
+ stderr: "simulator: invocation carries no structured op",
1100
+ };
1101
+ }
1102
+ const applier = APPLIERS[kind];
1103
+ if (applier === undefined) {
1104
+ return { exitCode: 1, stdout: "", stderr: `simulator: no applier for op ${kind}` };
1105
+ }
1106
+ sim ??= new DatabaseSync(dbPath);
1107
+ const when = now();
1108
+ const ctx = {
1109
+ nowEpoch: Math.floor(when.getTime() / 1000),
1110
+ todayIso: localToday(when),
1111
+ };
1112
+ try {
1113
+ applier(sim, invocation.opParams, ctx);
1114
+ }
1115
+ catch (err) {
1116
+ return {
1117
+ exitCode: 1,
1118
+ stdout: "",
1119
+ stderr: `simulator applier for ${kind} failed: ${err.message}`,
1120
+ };
1121
+ }
1122
+ return { exitCode: 0, stdout: "", stderr: "" };
1123
+ },
1124
+ };
1125
+ }
1126
+ /** Read the pinned THINGS_NOW instant, if any; else real time per call. */
1127
+ function resolveEnvNow() {
1128
+ const raw = process.env["THINGS_NOW"];
1129
+ if (raw !== undefined && raw.trim() !== "") {
1130
+ const ms = new Date(raw).getTime();
1131
+ if (!Number.isNaN(ms))
1132
+ return () => new Date(ms);
1133
+ }
1134
+ return () => new Date();
1135
+ }
1136
+ //# sourceMappingURL=simulator.js.map