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.
- package/README.md +10 -8
- package/dist/cli.js +346 -219
- 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
|
|
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
|
-
| `
|
|
73
|
-
| `
|
|
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
|
-
| `
|
|
78
|
-
| `
|
|
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 |
|
|
147
|
-
| Headings
|
|
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
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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
|
|
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
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
FROM
|
|
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
|
-
`
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
}
|
|
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
|
|
161
|
-
const rows = db.prepare(
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
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
|
-
`
|
|
197
|
+
`
|
|
198
|
+
).all();
|
|
169
199
|
return rows.map((row) => ({
|
|
170
|
-
|
|
171
|
-
|
|
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
|
-
|
|
177
|
-
|
|
178
|
-
|
|
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
|
-
`
|
|
220
|
+
`
|
|
221
|
+
).all(since);
|
|
184
222
|
return rows.map((row) => ({
|
|
185
|
-
|
|
186
|
-
|
|
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
|
|
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
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
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
|
-
|
|
221
|
-
WHERE
|
|
222
|
-
`
|
|
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
|
-
|
|
279
|
+
serverId
|
|
233
280
|
);
|
|
234
|
-
return
|
|
281
|
+
return;
|
|
235
282
|
}
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
INSERT INTO todos (id,
|
|
239
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?,
|
|
240
|
-
`
|
|
241
|
-
|
|
242
|
-
|
|
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
|
-
|
|
299
|
+
userId
|
|
253
300
|
);
|
|
254
|
-
return id;
|
|
255
301
|
}
|
|
256
|
-
function deleteTodoByServerId(db, serverId
|
|
257
|
-
const existing = db.prepare(`SELECT id
|
|
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
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
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
|
|
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 {
|
|
401
|
+
const { todos } = request.body;
|
|
350
402
|
const userId = request.user.id;
|
|
351
403
|
const conflicts = [];
|
|
404
|
+
const mappings = [];
|
|
352
405
|
try {
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
const
|
|
372
|
-
|
|
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
|
-
|
|
375
|
-
|
|
376
|
-
todo.
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
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 =
|
|
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
|
|
915
|
+
program.command("reset").description("Delete all todos (keeps users)").action(async () => {
|
|
784
916
|
const db = initDatabase();
|
|
785
917
|
const todos = getAllTodos(db);
|
|
786
|
-
|
|
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
|
|
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": "
|
|
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
|
}
|