shared-things-server 1.1.0 → 2.0.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 (3) hide show
  1. package/README.md +10 -8
  2. package/dist/cli.js +346 -219
  3. package/package.json +3 -1
package/README.md CHANGED
@@ -59,7 +59,7 @@ shared-things init
59
59
  ### After Setup
60
60
 
61
61
  ```bash
62
- shared-things install # Start daemon (auto-runs on login)
62
+ shared-things start # Start daemon (auto-runs on login)
63
63
  shared-things status # Check sync status
64
64
  shared-things logs -f # Follow sync logs
65
65
  ```
@@ -69,13 +69,16 @@ shared-things logs -f # Follow sync logs
69
69
  | Command | Description |
70
70
  |---------|-------------|
71
71
  | `init` | Setup wizard |
72
- | `install` | Install launchd daemon (auto-starts on login) |
73
- | `uninstall` | Remove launchd daemon |
72
+ | `start` | Start launchd daemon (auto-starts on login) |
73
+ | `stop` | Stop launchd daemon |
74
74
  | `status` | Show sync status & last sync time |
75
75
  | `sync` | Force immediate sync |
76
76
  | `logs [-f]` | Show logs (`-f` to follow) |
77
- | `reset [--server]` | Reset local state (`--server` clears server too) |
78
- | `purge` | Remove all local config |
77
+ | `conflicts [--all]` | Show conflict history |
78
+ | `repair` | Diagnose state issues (no auto-fix) |
79
+ | `reset --local` | Clear local state |
80
+ | `reset --server` | Clear server data for this user |
81
+ | `doctor` | Comprehensive health check |
79
82
 
80
83
  ## Server Setup
81
84
 
@@ -143,9 +146,8 @@ things.yourdomain.com {
143
146
 
144
147
  | Synced | Not Synced |
145
148
  |--------|------------|
146
- | Todo title, notes, due date, tags | Completed todos |
147
- | Headings | Checklist items |
148
- | | Areas |
149
+ | Todo title, notes, due date, tags, status | Checklist items |
150
+ | | Headings, Areas |
149
151
 
150
152
  > **Note:** The project must exist in each user's Things app. Only items within that project sync.
151
153
 
package/dist/cli.js CHANGED
@@ -26,8 +26,9 @@ function initDatabase() {
26
26
  }
27
27
  const db = new Database(DB_PATH);
28
28
  db.pragma("journal_mode = WAL");
29
+ db.pragma("foreign_keys = ON");
30
+ migrateDatabase(db);
29
31
  db.exec(`
30
- -- Users table
31
32
  CREATE TABLE IF NOT EXISTS users (
32
33
  id TEXT PRIMARY KEY,
33
34
  name TEXT NOT NULL,
@@ -35,49 +36,111 @@ function initDatabase() {
35
36
  created_at TEXT NOT NULL DEFAULT (datetime('now'))
36
37
  );
37
38
 
38
- -- Headings table
39
- CREATE TABLE IF NOT EXISTS headings (
40
- id TEXT PRIMARY KEY,
41
- things_id TEXT NOT NULL UNIQUE,
42
- title TEXT NOT NULL,
43
- position INTEGER NOT NULL DEFAULT 0,
44
- updated_at TEXT NOT NULL DEFAULT (datetime('now')),
45
- updated_by TEXT NOT NULL REFERENCES users(id),
46
- created_at TEXT NOT NULL DEFAULT (datetime('now'))
47
- );
48
-
49
- -- Todos table
50
39
  CREATE TABLE IF NOT EXISTS todos (
51
40
  id TEXT PRIMARY KEY,
52
- things_id TEXT NOT NULL UNIQUE,
53
41
  title TEXT NOT NULL,
54
42
  notes TEXT NOT NULL DEFAULT '',
55
43
  due_date TEXT,
56
44
  tags TEXT NOT NULL DEFAULT '[]',
57
45
  status TEXT NOT NULL DEFAULT 'open' CHECK (status IN ('open', 'completed', 'canceled')),
58
- heading_id TEXT REFERENCES headings(id) ON DELETE SET NULL,
59
46
  position INTEGER NOT NULL DEFAULT 0,
60
- updated_at TEXT NOT NULL DEFAULT (datetime('now')),
61
- updated_by TEXT NOT NULL REFERENCES users(id),
62
- created_at TEXT NOT NULL DEFAULT (datetime('now'))
47
+ edited_at TEXT NOT NULL,
48
+ updated_at TEXT NOT NULL,
49
+ created_by TEXT NOT NULL REFERENCES users(id),
50
+ updated_by TEXT NOT NULL REFERENCES users(id)
63
51
  );
64
52
 
65
- -- Deleted items tracking (for sync)
66
53
  CREATE TABLE IF NOT EXISTS deleted_items (
67
54
  id TEXT PRIMARY KEY,
68
- things_id TEXT NOT NULL,
69
- item_type TEXT NOT NULL CHECK (item_type IN ('todo', 'heading')),
70
- deleted_at TEXT NOT NULL DEFAULT (datetime('now')),
55
+ server_id TEXT NOT NULL,
56
+ deleted_at TEXT NOT NULL,
57
+ recorded_at TEXT NOT NULL DEFAULT (datetime('now')),
71
58
  deleted_by TEXT NOT NULL REFERENCES users(id)
72
59
  );
73
60
 
74
- -- Indexes
75
61
  CREATE INDEX IF NOT EXISTS idx_todos_updated ON todos(updated_at);
76
- CREATE INDEX IF NOT EXISTS idx_headings_updated ON headings(updated_at);
77
- CREATE INDEX IF NOT EXISTS idx_deleted_at ON deleted_items(deleted_at);
62
+ CREATE INDEX IF NOT EXISTS idx_deleted_recorded ON deleted_items(recorded_at);
78
63
  `);
79
64
  return db;
80
65
  }
66
+ function migrateDatabase(db) {
67
+ db.pragma("foreign_keys = OFF");
68
+ const hasTodos = db.prepare(
69
+ `SELECT name FROM sqlite_master WHERE type='table' AND name='todos'`
70
+ ).get();
71
+ if (hasTodos) {
72
+ const columns = db.prepare(`PRAGMA table_info(todos)`).all();
73
+ const hasThingsId = columns.some((col) => col.name === "things_id");
74
+ const hasEditedAt = columns.some((col) => col.name === "edited_at");
75
+ if (hasThingsId || !hasEditedAt) {
76
+ db.exec(`
77
+ CREATE TABLE IF NOT EXISTS todos_new (
78
+ id TEXT PRIMARY KEY,
79
+ title TEXT NOT NULL,
80
+ notes TEXT NOT NULL DEFAULT '',
81
+ due_date TEXT,
82
+ tags TEXT NOT NULL DEFAULT '[]',
83
+ status TEXT NOT NULL DEFAULT 'open' CHECK (status IN ('open', 'completed', 'canceled')),
84
+ position INTEGER NOT NULL DEFAULT 0,
85
+ edited_at TEXT NOT NULL,
86
+ updated_at TEXT NOT NULL,
87
+ created_by TEXT NOT NULL,
88
+ updated_by TEXT NOT NULL
89
+ );
90
+ `);
91
+ db.exec(`
92
+ INSERT INTO todos_new (id, title, notes, due_date, tags, status, position, edited_at, updated_at, created_by, updated_by)
93
+ SELECT id, title, notes, due_date, tags, status, position, updated_at, updated_at, updated_by, updated_by
94
+ FROM todos;
95
+ `);
96
+ db.exec(`
97
+ DROP TABLE todos;
98
+ ALTER TABLE todos_new RENAME TO todos;
99
+ `);
100
+ }
101
+ }
102
+ const hasDeleted = db.prepare(
103
+ `SELECT name FROM sqlite_master WHERE type='table' AND name='deleted_items'`
104
+ ).get();
105
+ if (hasDeleted) {
106
+ const columns = db.prepare(`PRAGMA table_info(deleted_items)`).all();
107
+ const hasServerId = columns.some((col) => col.name === "server_id");
108
+ const hasRecordedAt = columns.some((col) => col.name === "recorded_at");
109
+ if (!hasServerId) {
110
+ db.exec(`
111
+ CREATE TABLE IF NOT EXISTS deleted_items_new (
112
+ id TEXT PRIMARY KEY,
113
+ server_id TEXT NOT NULL,
114
+ deleted_at TEXT NOT NULL,
115
+ recorded_at TEXT NOT NULL,
116
+ deleted_by TEXT NOT NULL
117
+ );
118
+ `);
119
+ db.exec(`
120
+ INSERT INTO deleted_items_new (id, server_id, deleted_at, recorded_at, deleted_by)
121
+ SELECT id, things_id, deleted_at, deleted_at, deleted_by
122
+ FROM deleted_items
123
+ WHERE item_type = 'todo';
124
+ `);
125
+ db.exec(`
126
+ DROP TABLE deleted_items;
127
+ ALTER TABLE deleted_items_new RENAME TO deleted_items;
128
+ `);
129
+ } else if (!hasRecordedAt) {
130
+ db.exec(`
131
+ ALTER TABLE deleted_items ADD COLUMN recorded_at TEXT;
132
+ UPDATE deleted_items SET recorded_at = deleted_at WHERE recorded_at IS NULL;
133
+ `);
134
+ }
135
+ }
136
+ const hasHeadings = db.prepare(
137
+ `SELECT name FROM sqlite_master WHERE type='table' AND name='headings'`
138
+ ).get();
139
+ if (hasHeadings) {
140
+ db.exec(`DROP TABLE headings;`);
141
+ }
142
+ db.pragma("foreign_keys = ON");
143
+ }
81
144
  function userExists(db, name) {
82
145
  const row = db.prepare(`SELECT 1 FROM users WHERE name = ?`).get(name);
83
146
  return !!row;
@@ -97,190 +160,185 @@ function createUser(db, name) {
97
160
  }
98
161
  function getUserByApiKey(db, apiKey) {
99
162
  const apiKeyHash = crypto.createHash("sha256").update(apiKey).digest("hex");
100
- const row = db.prepare(`
101
- SELECT id, name FROM users WHERE api_key_hash = ?
102
- `).get(apiKeyHash);
163
+ const row = db.prepare(`SELECT id, name FROM users WHERE api_key_hash = ?`).get(apiKeyHash);
103
164
  return row || null;
104
165
  }
105
166
  function listUsers(db) {
106
- return db.prepare(`
107
- SELECT id, name, created_at as createdAt FROM users
108
- `).all();
167
+ return db.prepare(`SELECT id, name, created_at as createdAt FROM users`).all();
109
168
  }
110
- function getAllHeadings(db) {
111
- return db.prepare(`
112
- SELECT
113
- id, things_id as thingsId, title, position,
114
- updated_at as updatedAt, updated_by as updatedBy, created_at as createdAt
115
- FROM headings
116
- ORDER BY position
117
- `).all();
118
- }
119
- function getHeadingsSince(db, since) {
120
- return db.prepare(`
121
- SELECT
122
- id, things_id as thingsId, title, position,
123
- updated_at as updatedAt, updated_by as updatedBy, created_at as createdAt
124
- FROM headings
125
- WHERE updated_at > ?
169
+ function getAllTodos(db) {
170
+ const rows = db.prepare(
171
+ `
172
+ SELECT id, title, notes, due_date, tags, status, position,
173
+ edited_at, updated_at, updated_by
174
+ FROM todos
126
175
  ORDER BY position
127
- `).all(since);
128
- }
129
- function upsertHeading(db, thingsId, title, position, userId) {
130
- const now = (/* @__PURE__ */ new Date()).toISOString();
131
- const existing = db.prepare(`SELECT id FROM headings WHERE things_id = ?`).get(thingsId);
132
- if (existing) {
133
- db.prepare(`
134
- UPDATE headings
135
- SET title = ?, position = ?, updated_at = ?, updated_by = ?
136
- WHERE things_id = ?
137
- `).run(title, position, now, userId, thingsId);
138
- return existing.id;
139
- } else {
140
- const id = crypto.randomUUID();
141
- db.prepare(`
142
- INSERT INTO headings (id, things_id, title, position, updated_at, updated_by, created_at)
143
- VALUES (?, ?, ?, ?, ?, ?, ?)
144
- `).run(id, thingsId, title, position, now, userId, now);
145
- return id;
146
- }
147
- }
148
- function deleteHeading(db, thingsId, userId) {
149
- const existing = db.prepare(`SELECT id FROM headings WHERE things_id = ?`).get(thingsId);
150
- if (!existing) return false;
151
- const now = (/* @__PURE__ */ new Date()).toISOString();
152
- const deleteId = crypto.randomUUID();
153
- db.prepare(`
154
- INSERT INTO deleted_items (id, things_id, item_type, deleted_at, deleted_by)
155
- VALUES (?, ?, 'heading', ?, ?)
156
- `).run(deleteId, thingsId, now, userId);
157
- db.prepare(`DELETE FROM headings WHERE things_id = ?`).run(thingsId);
158
- return true;
176
+ `
177
+ ).all();
178
+ return rows.map((row) => ({
179
+ id: row.id,
180
+ title: row.title,
181
+ notes: row.notes,
182
+ dueDate: row.due_date,
183
+ tags: JSON.parse(row.tags),
184
+ status: row.status,
185
+ position: row.position,
186
+ editedAt: row.edited_at,
187
+ updatedAt: row.updated_at
188
+ }));
159
189
  }
160
- function getAllTodos(db) {
161
- const rows = db.prepare(`
162
- SELECT
163
- id, things_id as thingsId, title, notes, due_date as dueDate,
164
- tags, status, heading_id as headingId, position,
165
- updated_at as updatedAt, updated_by as updatedBy, created_at as createdAt
190
+ function getAllTodosWithMeta(db) {
191
+ const rows = db.prepare(
192
+ `
193
+ SELECT id, title, notes, due_date, tags, status, position,
194
+ edited_at, updated_at, updated_by
166
195
  FROM todos
167
196
  ORDER BY position
168
- `).all();
197
+ `
198
+ ).all();
169
199
  return rows.map((row) => ({
170
- ...row,
171
- tags: JSON.parse(row.tags)
200
+ id: row.id,
201
+ title: row.title,
202
+ notes: row.notes,
203
+ dueDate: row.due_date,
204
+ tags: JSON.parse(row.tags),
205
+ status: row.status,
206
+ position: row.position,
207
+ editedAt: row.edited_at,
208
+ updatedAt: row.updated_at,
209
+ updatedBy: row.updated_by
172
210
  }));
173
211
  }
174
212
  function getTodosSince(db, since) {
175
- const rows = db.prepare(`
176
- SELECT
177
- id, things_id as thingsId, title, notes, due_date as dueDate,
178
- tags, status, heading_id as headingId, position,
179
- updated_at as updatedAt, updated_by as updatedBy, created_at as createdAt
213
+ const rows = db.prepare(
214
+ `
215
+ SELECT id, title, notes, due_date, tags, status, position,
216
+ edited_at, updated_at, updated_by
180
217
  FROM todos
181
218
  WHERE updated_at > ?
182
219
  ORDER BY position
183
- `).all(since);
220
+ `
221
+ ).all(since);
184
222
  return rows.map((row) => ({
185
- ...row,
186
- tags: JSON.parse(row.tags)
223
+ id: row.id,
224
+ title: row.title,
225
+ notes: row.notes,
226
+ dueDate: row.due_date,
227
+ tags: JSON.parse(row.tags),
228
+ status: row.status,
229
+ position: row.position,
230
+ editedAt: row.edited_at,
231
+ updatedAt: row.updated_at
187
232
  }));
188
233
  }
189
- function upsertTodoByServerId(db, serverId, data, userId) {
234
+ function getTodoByServerId(db, serverId) {
235
+ const row = db.prepare(
236
+ `
237
+ SELECT id, title, notes, due_date, tags, status, position,
238
+ edited_at, updated_at, updated_by
239
+ FROM todos
240
+ WHERE id = ?
241
+ `
242
+ ).get(serverId);
243
+ if (!row) return null;
244
+ return {
245
+ id: row.id,
246
+ title: row.title,
247
+ notes: row.notes,
248
+ dueDate: row.due_date,
249
+ tags: JSON.parse(row.tags),
250
+ status: row.status,
251
+ position: row.position,
252
+ editedAt: row.edited_at,
253
+ updatedAt: row.updated_at,
254
+ updatedBy: row.updated_by
255
+ };
256
+ }
257
+ function upsertTodo(db, serverId, data, userId) {
190
258
  const now = (/* @__PURE__ */ new Date()).toISOString();
191
259
  const tagsJson = JSON.stringify(data.tags);
192
- if (serverId) {
193
- const existing = db.prepare(`SELECT id FROM todos WHERE id = ?`).get(serverId);
194
- if (existing) {
195
- db.prepare(`
196
- UPDATE todos
197
- SET title = ?, notes = ?, due_date = ?, tags = ?, status = ?,
198
- heading_id = ?, position = ?, updated_at = ?, updated_by = ?
199
- WHERE id = ?
200
- `).run(
201
- data.title,
202
- data.notes,
203
- data.dueDate,
204
- tagsJson,
205
- data.status,
206
- data.headingId,
207
- data.position,
208
- now,
209
- userId,
210
- serverId
211
- );
212
- return serverId;
213
- }
214
- }
215
- const existingByThingsId = db.prepare(`SELECT id FROM todos WHERE things_id = ?`).get(data.thingsId);
216
- if (existingByThingsId) {
217
- db.prepare(`
260
+ const existing = db.prepare(`SELECT id FROM todos WHERE id = ?`).get(serverId);
261
+ if (existing) {
262
+ db.prepare(
263
+ `
218
264
  UPDATE todos
219
265
  SET title = ?, notes = ?, due_date = ?, tags = ?, status = ?,
220
- heading_id = ?, position = ?, updated_at = ?, updated_by = ?
221
- WHERE things_id = ?
222
- `).run(
266
+ position = ?, edited_at = ?, updated_at = ?, updated_by = ?
267
+ WHERE id = ?
268
+ `
269
+ ).run(
223
270
  data.title,
224
271
  data.notes,
225
272
  data.dueDate,
226
273
  tagsJson,
227
274
  data.status,
228
- data.headingId,
229
275
  data.position,
276
+ data.editedAt,
230
277
  now,
231
278
  userId,
232
- data.thingsId
279
+ serverId
233
280
  );
234
- return existingByThingsId.id;
281
+ return;
235
282
  }
236
- const id = serverId || crypto.randomUUID();
237
- db.prepare(`
238
- INSERT INTO todos (id, things_id, title, notes, due_date, tags, status, heading_id, position, updated_at, updated_by, created_at)
239
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
240
- `).run(
241
- id,
242
- data.thingsId,
283
+ db.prepare(
284
+ `
285
+ INSERT INTO todos (id, title, notes, due_date, tags, status, position, edited_at, updated_at, created_by, updated_by)
286
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
287
+ `
288
+ ).run(
289
+ serverId,
243
290
  data.title,
244
291
  data.notes,
245
292
  data.dueDate,
246
293
  tagsJson,
247
294
  data.status,
248
- data.headingId,
249
295
  data.position,
296
+ data.editedAt,
250
297
  now,
251
298
  userId,
252
- now
299
+ userId
253
300
  );
254
- return id;
255
301
  }
256
- function deleteTodoByServerId(db, serverId, userId) {
257
- const existing = db.prepare(`SELECT id, things_id FROM todos WHERE id = ?`).get(serverId);
302
+ function deleteTodoByServerId(db, serverId) {
303
+ const existing = db.prepare(`SELECT id FROM todos WHERE id = ?`).get(serverId);
258
304
  if (!existing) return false;
259
- const now = (/* @__PURE__ */ new Date()).toISOString();
260
- const deleteId = crypto.randomUUID();
261
- db.prepare(`
262
- INSERT INTO deleted_items (id, things_id, item_type, deleted_at, deleted_by)
263
- VALUES (?, ?, 'todo', ?, ?)
264
- `).run(deleteId, serverId, now, userId);
265
305
  db.prepare(`DELETE FROM todos WHERE id = ?`).run(serverId);
266
306
  return true;
267
307
  }
308
+ function getDeletedByServerId(db, serverId) {
309
+ const row = db.prepare(
310
+ `SELECT deleted_at as deletedAt, deleted_by as deletedBy FROM deleted_items WHERE server_id = ? ORDER BY deleted_at DESC LIMIT 1`
311
+ ).get(serverId);
312
+ return row || null;
313
+ }
314
+ function recordDeletion(db, serverId, deletedAt, userId) {
315
+ db.prepare(`DELETE FROM deleted_items WHERE server_id = ?`).run(serverId);
316
+ const deleteId = crypto.randomUUID();
317
+ const recordedAt = (/* @__PURE__ */ new Date()).toISOString();
318
+ db.prepare(
319
+ `
320
+ INSERT INTO deleted_items (id, server_id, deleted_at, recorded_at, deleted_by)
321
+ VALUES (?, ?, ?, ?, ?)
322
+ `
323
+ ).run(deleteId, serverId, deletedAt, recordedAt, userId);
324
+ }
325
+ function clearDeletion(db, serverId) {
326
+ db.prepare(`DELETE FROM deleted_items WHERE server_id = ?`).run(serverId);
327
+ }
268
328
  function getDeletedSince(db, since) {
269
- const rows = db.prepare(`
270
- SELECT things_id, item_type FROM deleted_items WHERE deleted_at > ?
271
- `).all(since);
272
- return {
273
- todos: rows.filter((r) => r.item_type === "todo").map((r) => r.things_id),
274
- headings: rows.filter((r) => r.item_type === "heading").map((r) => r.things_id)
275
- };
329
+ return db.prepare(
330
+ `
331
+ SELECT server_id as serverId, deleted_at as deletedAt
332
+ FROM deleted_items
333
+ WHERE recorded_at > ?
334
+ `
335
+ ).all(since);
276
336
  }
277
337
  function resetUserData(db, userId) {
278
- const todoResult = db.prepare(`DELETE FROM todos WHERE updated_by = ?`).run(userId);
279
- const headingResult = db.prepare(`DELETE FROM headings WHERE updated_by = ?`).run(userId);
338
+ const todoResult = db.prepare(`DELETE FROM todos WHERE updated_by = ? OR created_by = ?`).run(userId, userId);
280
339
  db.prepare(`DELETE FROM deleted_items WHERE deleted_by = ?`).run(userId);
281
340
  return {
282
- deletedTodos: todoResult.changes,
283
- deletedHeadings: headingResult.changes
341
+ deletedTodos: todoResult.changes
284
342
  };
285
343
  }
286
344
 
@@ -310,15 +368,14 @@ function authMiddleware(db) {
310
368
  }
311
369
 
312
370
  // src/routes.ts
371
+ import * as crypto2 from "crypto";
313
372
  function registerRoutes(app, db) {
314
373
  app.get("/health", async () => {
315
374
  return { status: "ok", timestamp: (/* @__PURE__ */ new Date()).toISOString() };
316
375
  });
317
376
  app.get("/state", async (_request) => {
318
- const headings = getAllHeadings(db);
319
377
  const todos = getAllTodos(db);
320
378
  return {
321
- headings,
322
379
  todos,
323
380
  syncedAt: (/* @__PURE__ */ new Date()).toISOString()
324
381
  };
@@ -328,17 +385,12 @@ function registerRoutes(app, db) {
328
385
  if (!since) {
329
386
  return { error: 'Missing "since" query parameter', code: "BAD_REQUEST" };
330
387
  }
331
- const headings = getHeadingsSince(db, since);
332
388
  const todos = getTodosSince(db, since);
333
389
  const deleted = getDeletedSince(db, since);
334
390
  return {
335
- headings: {
336
- upserted: headings,
337
- deleted: deleted.headings
338
- },
339
391
  todos: {
340
392
  upserted: todos,
341
- deleted: deleted.todos
393
+ deleted
342
394
  },
343
395
  syncedAt: (/* @__PURE__ */ new Date()).toISOString()
344
396
  };
@@ -346,47 +398,108 @@ function registerRoutes(app, db) {
346
398
  app.post(
347
399
  "/push",
348
400
  async (request, reply) => {
349
- const { headings, todos } = request.body;
401
+ const { todos } = request.body;
350
402
  const userId = request.user.id;
351
403
  const conflicts = [];
404
+ const mappings = [];
352
405
  try {
353
- for (const thingsId of headings.deleted) {
354
- deleteHeading(db, thingsId, userId);
355
- }
356
- for (const heading of headings.upserted) {
357
- upsertHeading(
358
- db,
359
- heading.thingsId,
360
- heading.title,
361
- heading.position,
362
- userId
363
- );
364
- }
365
- for (const serverId of todos.deleted) {
366
- deleteTodoByServerId(db, serverId, userId);
367
- }
368
- for (const todo of todos.upserted) {
369
- let headingId = null;
370
- if (todo.headingId) {
371
- const headingRow = db.prepare(`SELECT id FROM headings WHERE things_id = ?`).get(todo.headingId);
372
- headingId = headingRow?.id || null;
406
+ const transaction = db.transaction(() => {
407
+ for (const deletion of todos.deleted) {
408
+ const existing = getTodoByServerId(db, deletion.serverId);
409
+ if (!existing) {
410
+ const existingDeletion = getDeletedByServerId(
411
+ db,
412
+ deletion.serverId
413
+ );
414
+ if (!existingDeletion || compareIso(deletion.deletedAt, existingDeletion.deletedAt) > 0) {
415
+ recordDeletion(
416
+ db,
417
+ deletion.serverId,
418
+ deletion.deletedAt,
419
+ userId
420
+ );
421
+ }
422
+ continue;
423
+ }
424
+ const shouldDelete = shouldApplyChange(
425
+ deletion.deletedAt,
426
+ existing.editedAt,
427
+ userId,
428
+ existing.updatedBy
429
+ );
430
+ if (!shouldDelete) {
431
+ conflicts.push({
432
+ serverId: deletion.serverId,
433
+ reason: "Remote edit was newer",
434
+ serverTodo: toTodo(existing),
435
+ clientDeletedAt: deletion.deletedAt
436
+ });
437
+ continue;
438
+ }
439
+ deleteTodoByServerId(db, deletion.serverId);
440
+ recordDeletion(db, deletion.serverId, deletion.deletedAt, userId);
373
441
  }
374
- upsertTodoByServerId(
375
- db,
376
- todo.serverId,
377
- {
378
- thingsId: todo.thingsId,
379
- title: todo.title,
380
- notes: todo.notes,
381
- dueDate: todo.dueDate,
382
- tags: todo.tags,
383
- status: todo.status,
384
- headingId,
385
- position: todo.position
386
- },
387
- userId
388
- );
389
- }
442
+ for (const todo of todos.upserted) {
443
+ const serverId = todo.serverId || crypto2.randomUUID();
444
+ const position = typeof todo.position === "number" && Number.isFinite(todo.position) ? todo.position : 0;
445
+ const existingDeletion = getDeletedByServerId(db, serverId);
446
+ if (existingDeletion) {
447
+ const editWins = shouldApplyChange(
448
+ todo.editedAt,
449
+ existingDeletion.deletedAt,
450
+ userId,
451
+ existingDeletion.deletedBy
452
+ );
453
+ if (!editWins) {
454
+ conflicts.push({
455
+ serverId,
456
+ reason: "Remote delete was newer",
457
+ serverTodo: null,
458
+ clientTodo: todo
459
+ });
460
+ continue;
461
+ }
462
+ clearDeletion(db, serverId);
463
+ }
464
+ const existing = getTodoByServerId(db, serverId);
465
+ if (existing) {
466
+ const shouldApply = shouldApplyChange(
467
+ todo.editedAt,
468
+ existing.editedAt,
469
+ userId,
470
+ existing.updatedBy
471
+ );
472
+ if (!shouldApply) {
473
+ conflicts.push({
474
+ serverId,
475
+ reason: "Remote edit was newer",
476
+ serverTodo: toTodo(existing),
477
+ clientTodo: todo
478
+ });
479
+ continue;
480
+ }
481
+ } else if (todo.serverId) {
482
+ }
483
+ upsertTodo(
484
+ db,
485
+ serverId,
486
+ {
487
+ title: todo.title,
488
+ notes: todo.notes,
489
+ dueDate: todo.dueDate,
490
+ tags: todo.tags,
491
+ status: todo.status,
492
+ position,
493
+ editedAt: todo.editedAt
494
+ },
495
+ userId
496
+ );
497
+ if (!todo.serverId && todo.clientId) {
498
+ mappings?.push({ serverId, clientId: todo.clientId });
499
+ }
500
+ }
501
+ });
502
+ transaction();
390
503
  } catch (err) {
391
504
  const error = err;
392
505
  if (error.message?.includes("UNIQUE constraint failed")) {
@@ -398,15 +511,14 @@ function registerRoutes(app, db) {
398
511
  }
399
512
  throw err;
400
513
  }
401
- const currentHeadings = getAllHeadings(db);
402
514
  const currentTodos = getAllTodos(db);
403
515
  return {
404
516
  state: {
405
- headings: currentHeadings,
406
517
  todos: currentTodos,
407
518
  syncedAt: (/* @__PURE__ */ new Date()).toISOString()
408
519
  },
409
- conflicts
520
+ conflicts,
521
+ mappings: mappings?.length ? mappings : void 0
410
522
  };
411
523
  }
412
524
  );
@@ -416,12 +528,33 @@ function registerRoutes(app, db) {
416
528
  return {
417
529
  success: true,
418
530
  deleted: {
419
- todos: result.deletedTodos,
420
- headings: result.deletedHeadings
531
+ todos: result.deletedTodos
421
532
  }
422
533
  };
423
534
  });
424
535
  }
536
+ function compareIso(a, b) {
537
+ return new Date(a).getTime() - new Date(b).getTime();
538
+ }
539
+ function shouldApplyChange(incomingEditedAt, storedEditedAt, incomingUserId, storedUserId) {
540
+ const diff = compareIso(incomingEditedAt, storedEditedAt);
541
+ if (diff > 0) return true;
542
+ if (diff < 0) return false;
543
+ return incomingUserId > storedUserId;
544
+ }
545
+ function toTodo(todo) {
546
+ return {
547
+ id: todo.id,
548
+ title: todo.title,
549
+ notes: todo.notes,
550
+ dueDate: todo.dueDate,
551
+ tags: todo.tags,
552
+ status: todo.status,
553
+ position: todo.position,
554
+ editedAt: todo.editedAt,
555
+ updatedAt: todo.updatedAt
556
+ };
557
+ }
425
558
 
426
559
  // src/cli.ts
427
560
  var pkg = JSON.parse(
@@ -728,7 +861,6 @@ program.command("delete-user").description("Delete a user").option("-n, --name <
728
861
  return;
729
862
  }
730
863
  db.prepare("DELETE FROM todos WHERE updated_by = ?").run(user.id);
731
- db.prepare("DELETE FROM headings WHERE updated_by = ?").run(user.id);
732
864
  db.prepare("DELETE FROM deleted_items WHERE deleted_by = ?").run(user.id);
733
865
  db.prepare("DELETE FROM users WHERE id = ?").run(user.id);
734
866
  console.log(chalk.green(`
@@ -737,7 +869,7 @@ program.command("delete-user").description("Delete a user").option("-n, --name <
737
869
  });
738
870
  program.command("list-todos").description("List all todos").option("-u, --user <name>", "Filter by username").action(async (options) => {
739
871
  const db = initDatabase();
740
- const todos = getAllTodos(db);
872
+ const todos = getAllTodosWithMeta(db);
741
873
  const users = listUsers(db);
742
874
  const userMap = new Map(users.map((u) => [u.id, u.name]));
743
875
  let filteredTodos = todos;
@@ -780,20 +912,18 @@ ${title}
780
912
  console.log();
781
913
  }
782
914
  });
783
- program.command("reset").description("Delete all todos and headings (keeps users)").action(async () => {
915
+ program.command("reset").description("Delete all todos (keeps users)").action(async () => {
784
916
  const db = initDatabase();
785
917
  const todos = getAllTodos(db);
786
- const headings = getAllHeadings(db);
787
- if (todos.length === 0 && headings.length === 0) {
918
+ if (todos.length === 0) {
788
919
  console.log(chalk.yellow("\nNo data to reset.\n"));
789
920
  return;
790
921
  }
791
922
  console.log(chalk.bold("\n\u{1F504} Reset Server Data\n"));
792
923
  console.log(` ${chalk.dim("Todos:")} ${todos.length}`);
793
- console.log(` ${chalk.dim("Headings:")} ${headings.length}`);
794
924
  console.log();
795
925
  const confirmed = await confirm({
796
- message: "Delete all todos and headings? Users will be kept.",
926
+ message: "Delete all todos? Users will be kept.",
797
927
  default: false
798
928
  });
799
929
  if (!confirmed) {
@@ -801,11 +931,8 @@ program.command("reset").description("Delete all todos and headings (keeps users
801
931
  return;
802
932
  }
803
933
  db.prepare("DELETE FROM todos").run();
804
- db.prepare("DELETE FROM headings").run();
805
934
  db.prepare("DELETE FROM deleted_items").run();
806
- console.log(
807
- chalk.green("\n\u2705 All todos and headings deleted. Users preserved.\n")
808
- );
935
+ console.log(chalk.green("\n\u2705 All todos deleted. Users preserved.\n"));
809
936
  });
810
937
  program.command("purge").description("Delete entire database (all data including users)").action(async () => {
811
938
  const dataDir = process.env.DATA_DIR || path2.join(os2.homedir(), ".shared-things-server");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "shared-things-server",
3
- "version": "1.1.0",
3
+ "version": "2.0.0",
4
4
  "description": "Sync server for Things 3 projects",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -58,6 +58,8 @@
58
58
  "build": "tsup",
59
59
  "dev": "tsx watch src/cli.ts start",
60
60
  "typecheck": "tsc --noEmit",
61
+ "test": "vitest run",
62
+ "test:watch": "vitest",
61
63
  "postinstall": "node scripts/postinstall.js || true"
62
64
  }
63
65
  }