@runwingman/flightdeck-cli 0.2.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/Dockerfile +21 -0
- package/LICENSE +21 -0
- package/README.md +353 -0
- package/docs/to_fdcli.md +78 -0
- package/install.sh +86 -0
- package/package.json +53 -0
- package/src/bot-helpers.js +774 -0
- package/src/chat-runtime.js +782 -0
- package/src/cli.js +2767 -0
- package/src/client.js +285 -0
- package/src/config.js +117 -0
- package/src/db.js +653 -0
- package/src/flow-steps.js +215 -0
- package/src/nostr.js +160 -0
- package/src/render.js +34 -0
- package/src/sqlite-runtime.js +17 -0
- package/src/storage.js +191 -0
- package/src/sync.js +900 -0
- package/src/token.js +72 -0
- package/src/translators.js +1652 -0
- package/src/workspace-keys.js +214 -0
package/src/sync.js
ADDED
|
@@ -0,0 +1,900 @@
|
|
|
1
|
+
import { getAllWorkspaceKeyMappings, getMeta, getRows, openDb, putMeta, replaceGroupKeys, replaceGroups, upsertAppSchemas, upsertRows, upsertWorkspaceKeyMapping } from './db.js';
|
|
2
|
+
import {
|
|
3
|
+
decryptAppSchemaManifest,
|
|
4
|
+
decryptRecordPayload,
|
|
5
|
+
inboundApproval,
|
|
6
|
+
inboundAudioNote,
|
|
7
|
+
inboundChannel,
|
|
8
|
+
inboundChatMessage,
|
|
9
|
+
inboundComment,
|
|
10
|
+
inboundDirectory,
|
|
11
|
+
inboundDocument,
|
|
12
|
+
inboundFlow,
|
|
13
|
+
inboundGroup,
|
|
14
|
+
inboundReport,
|
|
15
|
+
inboundReaction,
|
|
16
|
+
inboundSchedule,
|
|
17
|
+
inboundScope,
|
|
18
|
+
inboundTask,
|
|
19
|
+
loadGroupKeyMap,
|
|
20
|
+
recordFamilyHash,
|
|
21
|
+
} from './translators.js';
|
|
22
|
+
import { decodeNsec, getSession } from './nostr.js';
|
|
23
|
+
|
|
24
|
+
export const FAMILY_TABLES = [
|
|
25
|
+
{ collection: 'channel', table: 'channels', mapper: inboundChannel },
|
|
26
|
+
{ collection: 'chat_message', table: 'messages', mapper: inboundChatMessage },
|
|
27
|
+
{ collection: 'directory', table: 'directories', mapper: inboundDirectory },
|
|
28
|
+
{ collection: 'document', table: 'documents', mapper: inboundDocument },
|
|
29
|
+
{ collection: 'report', table: 'reports', mapper: inboundReport },
|
|
30
|
+
{ collection: 'task', table: 'tasks', mapper: inboundTask },
|
|
31
|
+
{ collection: 'comment', table: 'comments', mapper: inboundComment },
|
|
32
|
+
{ collection: 'reaction', table: 'reactions', mapper: inboundReaction },
|
|
33
|
+
{ collection: 'audio_note', table: 'audio_notes', mapper: inboundAudioNote },
|
|
34
|
+
{ collection: 'scope', table: 'scopes', mapper: inboundScope },
|
|
35
|
+
{ collection: 'schedule', table: 'schedules', mapper: inboundSchedule },
|
|
36
|
+
{ collection: 'flow', table: 'flows', mapper: inboundFlow },
|
|
37
|
+
{ collection: 'approval', table: 'approvals', mapper: inboundApproval },
|
|
38
|
+
];
|
|
39
|
+
|
|
40
|
+
const GROUP_BEARING_TABLES = [
|
|
41
|
+
'channels',
|
|
42
|
+
'tasks',
|
|
43
|
+
'documents',
|
|
44
|
+
'directories',
|
|
45
|
+
'reports',
|
|
46
|
+
'audio_notes',
|
|
47
|
+
'scopes',
|
|
48
|
+
'schedules',
|
|
49
|
+
'flows',
|
|
50
|
+
'approvals',
|
|
51
|
+
];
|
|
52
|
+
|
|
53
|
+
const LOCAL_ACCESS_REPAIR_TABLES = [
|
|
54
|
+
{ table: 'channels', hasShares: false, hasBoardGroup: false },
|
|
55
|
+
{ table: 'tasks', hasShares: true, hasBoardGroup: true },
|
|
56
|
+
{ table: 'documents', hasShares: true, hasBoardGroup: false },
|
|
57
|
+
{ table: 'directories', hasShares: true, hasBoardGroup: false },
|
|
58
|
+
{ table: 'reports', hasShares: false, hasBoardGroup: false },
|
|
59
|
+
{ table: 'audio_notes', hasShares: false, hasBoardGroup: false },
|
|
60
|
+
{ table: 'scopes', hasShares: true, hasBoardGroup: false },
|
|
61
|
+
{ table: 'schedules', hasShares: true, hasBoardGroup: false },
|
|
62
|
+
{ table: 'flows', hasShares: true, hasBoardGroup: false },
|
|
63
|
+
{ table: 'approvals', hasShares: true, hasBoardGroup: false },
|
|
64
|
+
];
|
|
65
|
+
|
|
66
|
+
function json(value) {
|
|
67
|
+
return JSON.stringify(value ?? null);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function newerIsoTimestamp(current, candidate) {
|
|
71
|
+
const currentTs = Date.parse(current || '');
|
|
72
|
+
const candidateTs = Date.parse(candidate || '');
|
|
73
|
+
if (!Number.isFinite(candidateTs)) return current || null;
|
|
74
|
+
if (!Number.isFinite(currentTs) || candidateTs > currentTs) return candidate;
|
|
75
|
+
return current || null;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function parseJsonArray(value) {
|
|
79
|
+
if (!value) return [];
|
|
80
|
+
try {
|
|
81
|
+
const parsed = JSON.parse(value);
|
|
82
|
+
return Array.isArray(parsed) ? parsed : [];
|
|
83
|
+
} catch {
|
|
84
|
+
return [];
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function buildAccessibleGroupRefs(groups, keyRows, viewerNpub, workspaceOwnerNpub) {
|
|
89
|
+
if (viewerNpub === workspaceOwnerNpub) return null;
|
|
90
|
+
|
|
91
|
+
const accessible = new Set();
|
|
92
|
+
for (const group of groups) {
|
|
93
|
+
for (const ref of [group.group_id, group.current_group_npub, group.group_npub]) {
|
|
94
|
+
if (ref) accessible.add(ref);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
for (const key of keyRows) {
|
|
98
|
+
for (const ref of [key.group_id, key.group_npub]) {
|
|
99
|
+
if (ref) accessible.add(ref);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
return accessible;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function buildCanonicalGroupMaps(groups, keyRows) {
|
|
106
|
+
const canonicalGroupIdByRef = new Map();
|
|
107
|
+
const currentGroupNpubById = new Map();
|
|
108
|
+
|
|
109
|
+
for (const group of groups || []) {
|
|
110
|
+
const stableGroupId = String(group?.group_id || '').trim();
|
|
111
|
+
const currentGroupNpub = String(group?.current_group_npub || group?.group_npub || '').trim();
|
|
112
|
+
if (!stableGroupId) continue;
|
|
113
|
+
canonicalGroupIdByRef.set(stableGroupId, stableGroupId);
|
|
114
|
+
if (currentGroupNpub) canonicalGroupIdByRef.set(currentGroupNpub, stableGroupId);
|
|
115
|
+
currentGroupNpubById.set(stableGroupId, currentGroupNpub || null);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
for (const keyRow of keyRows || []) {
|
|
119
|
+
const stableGroupId = String(keyRow?.group_id || '').trim();
|
|
120
|
+
const groupNpub = String(keyRow?.group_npub || '').trim();
|
|
121
|
+
if (stableGroupId) canonicalGroupIdByRef.set(stableGroupId, stableGroupId);
|
|
122
|
+
if (groupNpub && stableGroupId) canonicalGroupIdByRef.set(groupNpub, stableGroupId);
|
|
123
|
+
if (stableGroupId && groupNpub && !currentGroupNpubById.has(stableGroupId)) {
|
|
124
|
+
currentGroupNpubById.set(stableGroupId, groupNpub);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
return {
|
|
129
|
+
canonicalGroupIdByRef,
|
|
130
|
+
currentGroupNpubById,
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function sanitizeGroupIds(groupIds, canonicalGroupIdByRef, accessibleGroupRefs) {
|
|
135
|
+
const sanitized = [];
|
|
136
|
+
const seen = new Set();
|
|
137
|
+
|
|
138
|
+
for (const value of groupIds || []) {
|
|
139
|
+
const ref = String(value || '').trim();
|
|
140
|
+
if (!ref) continue;
|
|
141
|
+
const stableGroupId = canonicalGroupIdByRef.get(ref) || ref;
|
|
142
|
+
if (accessibleGroupRefs && !accessibleGroupRefs.has(ref) && !accessibleGroupRefs.has(stableGroupId)) {
|
|
143
|
+
continue;
|
|
144
|
+
}
|
|
145
|
+
if (seen.has(stableGroupId)) continue;
|
|
146
|
+
seen.add(stableGroupId);
|
|
147
|
+
sanitized.push(stableGroupId);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
return sanitized;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function sanitizeShares(shares, canonicalGroupIdByRef, currentGroupNpubById, accessibleGroupRefs) {
|
|
154
|
+
const sanitized = [];
|
|
155
|
+
const seen = new Set();
|
|
156
|
+
|
|
157
|
+
for (const share of shares || []) {
|
|
158
|
+
if (!share || typeof share !== 'object') continue;
|
|
159
|
+
const nextShare = { ...share };
|
|
160
|
+
|
|
161
|
+
const groupRef = String(
|
|
162
|
+
nextShare.group_id
|
|
163
|
+
|| nextShare.group_npub
|
|
164
|
+
|| nextShare.key
|
|
165
|
+
|| '',
|
|
166
|
+
).trim();
|
|
167
|
+
if (groupRef) {
|
|
168
|
+
const stableGroupId = canonicalGroupIdByRef.get(groupRef) || groupRef;
|
|
169
|
+
if (accessibleGroupRefs && !accessibleGroupRefs.has(groupRef) && !accessibleGroupRefs.has(stableGroupId)) {
|
|
170
|
+
continue;
|
|
171
|
+
}
|
|
172
|
+
nextShare.key = stableGroupId;
|
|
173
|
+
nextShare.group_id = stableGroupId;
|
|
174
|
+
nextShare.group_npub = currentGroupNpubById.get(stableGroupId) || nextShare.group_npub || null;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const viaGroupRef = String(
|
|
178
|
+
nextShare.via_group_id
|
|
179
|
+
|| nextShare.via_group_npub
|
|
180
|
+
|| '',
|
|
181
|
+
).trim();
|
|
182
|
+
if (viaGroupRef) {
|
|
183
|
+
const stableViaGroupId = canonicalGroupIdByRef.get(viaGroupRef) || viaGroupRef;
|
|
184
|
+
if (accessibleGroupRefs && !accessibleGroupRefs.has(viaGroupRef) && !accessibleGroupRefs.has(stableViaGroupId)) {
|
|
185
|
+
continue;
|
|
186
|
+
}
|
|
187
|
+
nextShare.via_group_id = stableViaGroupId;
|
|
188
|
+
nextShare.via_group_npub = currentGroupNpubById.get(stableViaGroupId) || nextShare.via_group_npub || null;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const signature = JSON.stringify([
|
|
192
|
+
nextShare.type || '',
|
|
193
|
+
nextShare.access || '',
|
|
194
|
+
nextShare.person_npub || '',
|
|
195
|
+
nextShare.group_id || '',
|
|
196
|
+
nextShare.via_group_id || '',
|
|
197
|
+
nextShare.inherited === true,
|
|
198
|
+
nextShare.inherited_from_directory_id || '',
|
|
199
|
+
]);
|
|
200
|
+
if (seen.has(signature)) continue;
|
|
201
|
+
seen.add(signature);
|
|
202
|
+
sanitized.push(nextShare);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
return sanitized;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function repairLocalAccessMetadata(db, { viewerNpub, workspaceOwnerNpub, groups, keyRows }) {
|
|
209
|
+
const accessibleGroupRefs = buildAccessibleGroupRefs(groups, keyRows, viewerNpub, workspaceOwnerNpub);
|
|
210
|
+
if (accessibleGroupRefs === null) {
|
|
211
|
+
return { repaired: 0 };
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const { canonicalGroupIdByRef, currentGroupNpubById } = buildCanonicalGroupMaps(groups, keyRows);
|
|
215
|
+
let repaired = 0;
|
|
216
|
+
|
|
217
|
+
for (const tableConfig of LOCAL_ACCESS_REPAIR_TABLES) {
|
|
218
|
+
const rows = getRows(
|
|
219
|
+
db,
|
|
220
|
+
`SELECT * FROM ${tableConfig.table} WHERE record_state != 'deleted'`,
|
|
221
|
+
);
|
|
222
|
+
if (rows.length === 0) continue;
|
|
223
|
+
|
|
224
|
+
const updates = [];
|
|
225
|
+
for (const row of rows) {
|
|
226
|
+
const raw = row?.raw_json ? JSON.parse(row.raw_json) : {};
|
|
227
|
+
const nextRaw = { ...raw };
|
|
228
|
+
let changed = false;
|
|
229
|
+
|
|
230
|
+
const currentGroupIds = Array.isArray(raw?.group_ids)
|
|
231
|
+
? raw.group_ids
|
|
232
|
+
: parseJsonArray(row.group_ids_json);
|
|
233
|
+
const nextGroupIds = sanitizeGroupIds(currentGroupIds, canonicalGroupIdByRef, accessibleGroupRefs);
|
|
234
|
+
if (JSON.stringify(nextGroupIds) !== JSON.stringify(currentGroupIds)) changed = true;
|
|
235
|
+
nextRaw.group_ids = nextGroupIds;
|
|
236
|
+
|
|
237
|
+
let nextSharesJson = null;
|
|
238
|
+
if (tableConfig.hasShares) {
|
|
239
|
+
const currentShares = Array.isArray(raw?.shares)
|
|
240
|
+
? raw.shares
|
|
241
|
+
: parseJsonArray(row.shares_json);
|
|
242
|
+
const nextShares = sanitizeShares(currentShares, canonicalGroupIdByRef, currentGroupNpubById, accessibleGroupRefs);
|
|
243
|
+
if (JSON.stringify(nextShares) !== JSON.stringify(currentShares)) changed = true;
|
|
244
|
+
nextRaw.shares = nextShares;
|
|
245
|
+
nextSharesJson = json(nextShares);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
let nextBoardGroupId = null;
|
|
249
|
+
if (tableConfig.hasBoardGroup) {
|
|
250
|
+
const currentBoardGroupId = String(raw?.board_group_id || row.board_group_id || '').trim();
|
|
251
|
+
const resolvedBoardGroupId = currentBoardGroupId
|
|
252
|
+
? (canonicalGroupIdByRef.get(currentBoardGroupId) || currentBoardGroupId)
|
|
253
|
+
: null;
|
|
254
|
+
nextBoardGroupId = resolvedBoardGroupId && nextGroupIds.includes(resolvedBoardGroupId)
|
|
255
|
+
? resolvedBoardGroupId
|
|
256
|
+
: (nextGroupIds[0] || null);
|
|
257
|
+
if ((currentBoardGroupId || null) !== nextBoardGroupId) changed = true;
|
|
258
|
+
nextRaw.board_group_id = nextBoardGroupId;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
if (!changed) continue;
|
|
262
|
+
updates.push({
|
|
263
|
+
record_id: row.record_id,
|
|
264
|
+
group_ids_json: json(nextGroupIds),
|
|
265
|
+
shares_json: nextSharesJson,
|
|
266
|
+
board_group_id: nextBoardGroupId,
|
|
267
|
+
raw_json: json(nextRaw),
|
|
268
|
+
});
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
if (updates.length === 0) continue;
|
|
272
|
+
repaired += updates.length;
|
|
273
|
+
|
|
274
|
+
if (tableConfig.hasShares && tableConfig.hasBoardGroup) {
|
|
275
|
+
const stmt = db.prepare(`
|
|
276
|
+
UPDATE ${tableConfig.table}
|
|
277
|
+
SET group_ids_json = ?,
|
|
278
|
+
shares_json = ?,
|
|
279
|
+
board_group_id = ?,
|
|
280
|
+
raw_json = ?
|
|
281
|
+
WHERE record_id = ?
|
|
282
|
+
`);
|
|
283
|
+
const tx = db.transaction((items) => {
|
|
284
|
+
for (const item of items) {
|
|
285
|
+
stmt.run(item.group_ids_json, item.shares_json, item.board_group_id, item.raw_json, item.record_id);
|
|
286
|
+
}
|
|
287
|
+
});
|
|
288
|
+
tx(updates);
|
|
289
|
+
continue;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
if (tableConfig.hasShares) {
|
|
293
|
+
const stmt = db.prepare(`
|
|
294
|
+
UPDATE ${tableConfig.table}
|
|
295
|
+
SET group_ids_json = ?,
|
|
296
|
+
shares_json = ?,
|
|
297
|
+
raw_json = ?
|
|
298
|
+
WHERE record_id = ?
|
|
299
|
+
`);
|
|
300
|
+
const tx = db.transaction((items) => {
|
|
301
|
+
for (const item of items) {
|
|
302
|
+
stmt.run(item.group_ids_json, item.shares_json, item.raw_json, item.record_id);
|
|
303
|
+
}
|
|
304
|
+
});
|
|
305
|
+
tx(updates);
|
|
306
|
+
continue;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
if (tableConfig.hasBoardGroup) {
|
|
310
|
+
const stmt = db.prepare(`
|
|
311
|
+
UPDATE ${tableConfig.table}
|
|
312
|
+
SET group_ids_json = ?,
|
|
313
|
+
board_group_id = ?,
|
|
314
|
+
raw_json = ?
|
|
315
|
+
WHERE record_id = ?
|
|
316
|
+
`);
|
|
317
|
+
const tx = db.transaction((items) => {
|
|
318
|
+
for (const item of items) {
|
|
319
|
+
stmt.run(item.group_ids_json, item.board_group_id, item.raw_json, item.record_id);
|
|
320
|
+
}
|
|
321
|
+
});
|
|
322
|
+
tx(updates);
|
|
323
|
+
continue;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
const stmt = db.prepare(`
|
|
327
|
+
UPDATE ${tableConfig.table}
|
|
328
|
+
SET group_ids_json = ?,
|
|
329
|
+
raw_json = ?
|
|
330
|
+
WHERE record_id = ?
|
|
331
|
+
`);
|
|
332
|
+
const tx = db.transaction((items) => {
|
|
333
|
+
for (const item of items) {
|
|
334
|
+
stmt.run(item.group_ids_json, item.raw_json, item.record_id);
|
|
335
|
+
}
|
|
336
|
+
});
|
|
337
|
+
tx(updates);
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
return { repaired };
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
function pruneInaccessibleRows(db, { viewerNpub, workspaceOwnerNpub, groups, keyRows }) {
|
|
344
|
+
const accessibleGroupRefs = buildAccessibleGroupRefs(groups, keyRows, viewerNpub, workspaceOwnerNpub);
|
|
345
|
+
if (accessibleGroupRefs === null) {
|
|
346
|
+
return { pruned: 0 };
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
let totalPruned = 0;
|
|
350
|
+
const prunedRecordIds = new Set();
|
|
351
|
+
const prunedChannelIds = new Set();
|
|
352
|
+
|
|
353
|
+
for (const tableName of GROUP_BEARING_TABLES) {
|
|
354
|
+
const rows = getRows(
|
|
355
|
+
db,
|
|
356
|
+
`SELECT record_id, group_ids_json FROM ${tableName} WHERE record_state != 'deleted'`,
|
|
357
|
+
);
|
|
358
|
+
const toDelete = rows
|
|
359
|
+
.filter((row) => {
|
|
360
|
+
const groupIds = parseJsonArray(row.group_ids_json);
|
|
361
|
+
if (groupIds.length === 0) return false;
|
|
362
|
+
return !groupIds.some((groupId) => accessibleGroupRefs.has(groupId));
|
|
363
|
+
})
|
|
364
|
+
.map((row) => row.record_id)
|
|
365
|
+
.filter(Boolean);
|
|
366
|
+
|
|
367
|
+
if (toDelete.length === 0) continue;
|
|
368
|
+
|
|
369
|
+
const stmt = db.prepare(`DELETE FROM ${tableName} WHERE record_id = ?`);
|
|
370
|
+
const tx = db.transaction((recordIds) => {
|
|
371
|
+
for (const recordId of recordIds) stmt.run(recordId);
|
|
372
|
+
});
|
|
373
|
+
tx(toDelete);
|
|
374
|
+
|
|
375
|
+
totalPruned += toDelete.length;
|
|
376
|
+
for (const recordId of toDelete) {
|
|
377
|
+
prunedRecordIds.add(recordId);
|
|
378
|
+
if (tableName === 'channels') prunedChannelIds.add(recordId);
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
if (prunedChannelIds.size > 0) {
|
|
383
|
+
const channelLookup = new Set(prunedChannelIds);
|
|
384
|
+
const toDelete = getRows(
|
|
385
|
+
db,
|
|
386
|
+
`SELECT record_id, channel_id FROM messages WHERE record_state != 'deleted'`,
|
|
387
|
+
)
|
|
388
|
+
.filter((row) => channelLookup.has(row.channel_id))
|
|
389
|
+
.map((row) => row.record_id)
|
|
390
|
+
.filter(Boolean);
|
|
391
|
+
if (toDelete.length > 0) {
|
|
392
|
+
const stmt = db.prepare(`DELETE FROM messages WHERE record_id = ?`);
|
|
393
|
+
const tx = db.transaction((recordIds) => {
|
|
394
|
+
for (const recordId of recordIds) stmt.run(recordId);
|
|
395
|
+
});
|
|
396
|
+
tx(toDelete);
|
|
397
|
+
totalPruned += toDelete.length;
|
|
398
|
+
for (const recordId of toDelete) prunedRecordIds.add(recordId);
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
if (prunedRecordIds.size > 0) {
|
|
403
|
+
const targetLookup = new Set(prunedRecordIds);
|
|
404
|
+
const toDelete = getRows(
|
|
405
|
+
db,
|
|
406
|
+
`SELECT record_id, target_record_id FROM comments WHERE record_state != 'deleted'`,
|
|
407
|
+
)
|
|
408
|
+
.filter((row) => targetLookup.has(row.target_record_id))
|
|
409
|
+
.map((row) => row.record_id)
|
|
410
|
+
.filter(Boolean);
|
|
411
|
+
if (toDelete.length > 0) {
|
|
412
|
+
const stmt = db.prepare(`DELETE FROM comments WHERE record_id = ?`);
|
|
413
|
+
const tx = db.transaction((recordIds) => {
|
|
414
|
+
for (const recordId of recordIds) stmt.run(recordId);
|
|
415
|
+
});
|
|
416
|
+
tx(toDelete);
|
|
417
|
+
totalPruned += toDelete.length;
|
|
418
|
+
for (const recordId of toDelete) prunedRecordIds.add(recordId);
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
if (prunedRecordIds.size > 0) {
|
|
423
|
+
const targetLookup = new Set(prunedRecordIds);
|
|
424
|
+
const toDelete = getRows(
|
|
425
|
+
db,
|
|
426
|
+
`SELECT record_id, target_record_id FROM reactions WHERE record_state != 'deleted'`,
|
|
427
|
+
)
|
|
428
|
+
.filter((row) => targetLookup.has(row.target_record_id))
|
|
429
|
+
.map((row) => row.record_id)
|
|
430
|
+
.filter(Boolean);
|
|
431
|
+
if (toDelete.length > 0) {
|
|
432
|
+
const stmt = db.prepare(`DELETE FROM reactions WHERE record_id = ?`);
|
|
433
|
+
const tx = db.transaction((recordIds) => {
|
|
434
|
+
for (const recordId of recordIds) stmt.run(recordId);
|
|
435
|
+
});
|
|
436
|
+
tx(toDelete);
|
|
437
|
+
totalPruned += toDelete.length;
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
return { pruned: totalPruned };
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
function rowForTable(table, mapped, rawRecord) {
|
|
445
|
+
const common = {
|
|
446
|
+
raw_json: json(mapped),
|
|
447
|
+
};
|
|
448
|
+
switch (table) {
|
|
449
|
+
case 'channels':
|
|
450
|
+
return {
|
|
451
|
+
record_id: mapped.record_id,
|
|
452
|
+
owner_npub: mapped.owner_npub,
|
|
453
|
+
title: mapped.title,
|
|
454
|
+
group_ids_json: json(mapped.group_ids),
|
|
455
|
+
participant_npubs_json: json(mapped.participant_npubs),
|
|
456
|
+
record_state: mapped.record_state,
|
|
457
|
+
version: mapped.version,
|
|
458
|
+
updated_at: mapped.updated_at,
|
|
459
|
+
...common,
|
|
460
|
+
};
|
|
461
|
+
case 'messages':
|
|
462
|
+
return {
|
|
463
|
+
record_id: mapped.record_id,
|
|
464
|
+
owner_npub: mapped.owner_npub,
|
|
465
|
+
channel_id: mapped.channel_id,
|
|
466
|
+
parent_message_id: mapped.parent_message_id,
|
|
467
|
+
body: mapped.body,
|
|
468
|
+
attachments_json: json(mapped.attachments),
|
|
469
|
+
sender_npub: mapped.sender_npub,
|
|
470
|
+
record_state: mapped.record_state,
|
|
471
|
+
version: mapped.version,
|
|
472
|
+
updated_at: mapped.updated_at,
|
|
473
|
+
...common,
|
|
474
|
+
};
|
|
475
|
+
case 'tasks':
|
|
476
|
+
return {
|
|
477
|
+
record_id: mapped.record_id,
|
|
478
|
+
owner_npub: mapped.owner_npub,
|
|
479
|
+
title: mapped.title,
|
|
480
|
+
description: mapped.description,
|
|
481
|
+
state: mapped.state,
|
|
482
|
+
priority: mapped.priority,
|
|
483
|
+
assigned_to_npub: mapped.assigned_to_npub,
|
|
484
|
+
parent_task_id: mapped.parent_task_id,
|
|
485
|
+
board_group_id: mapped.board_group_id,
|
|
486
|
+
scheduled_for: mapped.scheduled_for,
|
|
487
|
+
tags: mapped.tags,
|
|
488
|
+
scope_id: mapped.scope_id,
|
|
489
|
+
scope_l1_id: mapped.scope_l1_id,
|
|
490
|
+
scope_l2_id: mapped.scope_l2_id,
|
|
491
|
+
scope_l3_id: mapped.scope_l3_id,
|
|
492
|
+
scope_l4_id: mapped.scope_l4_id,
|
|
493
|
+
scope_l5_id: mapped.scope_l5_id,
|
|
494
|
+
predecessor_task_ids_json: json(mapped.predecessor_task_ids),
|
|
495
|
+
flow_id: mapped.flow_id ?? null,
|
|
496
|
+
flow_run_id: mapped.flow_run_id ?? null,
|
|
497
|
+
flow_step: mapped.flow_step ?? null,
|
|
498
|
+
references_json: json(mapped.references),
|
|
499
|
+
group_ids_json: json(mapped.group_ids),
|
|
500
|
+
shares_json: json(mapped.shares),
|
|
501
|
+
record_state: mapped.record_state,
|
|
502
|
+
version: mapped.version,
|
|
503
|
+
updated_at: mapped.updated_at,
|
|
504
|
+
...common,
|
|
505
|
+
};
|
|
506
|
+
case 'comments':
|
|
507
|
+
return {
|
|
508
|
+
record_id: mapped.record_id,
|
|
509
|
+
owner_npub: mapped.owner_npub,
|
|
510
|
+
target_record_id: mapped.target_record_id,
|
|
511
|
+
target_record_family_hash: mapped.target_record_family_hash,
|
|
512
|
+
parent_comment_id: mapped.parent_comment_id,
|
|
513
|
+
anchor_line_number: mapped.anchor_line_number,
|
|
514
|
+
comment_status: mapped.comment_status,
|
|
515
|
+
body: mapped.body,
|
|
516
|
+
attachments_json: json(mapped.attachments),
|
|
517
|
+
sender_npub: mapped.sender_npub,
|
|
518
|
+
record_state: mapped.record_state,
|
|
519
|
+
version: mapped.version,
|
|
520
|
+
updated_at: mapped.updated_at,
|
|
521
|
+
...common,
|
|
522
|
+
};
|
|
523
|
+
case 'reactions':
|
|
524
|
+
return {
|
|
525
|
+
record_id: mapped.record_id,
|
|
526
|
+
owner_npub: mapped.owner_npub,
|
|
527
|
+
target_record_id: mapped.target_record_id,
|
|
528
|
+
target_record_family_hash: mapped.target_record_family_hash,
|
|
529
|
+
emoji: mapped.emoji,
|
|
530
|
+
emoji_shortcode: mapped.emoji_shortcode,
|
|
531
|
+
reactor_npub: mapped.reactor_npub,
|
|
532
|
+
sender_npub: mapped.sender_npub,
|
|
533
|
+
record_state: mapped.record_state,
|
|
534
|
+
version: mapped.version,
|
|
535
|
+
created_at: mapped.created_at,
|
|
536
|
+
updated_at: mapped.updated_at,
|
|
537
|
+
...common,
|
|
538
|
+
};
|
|
539
|
+
case 'documents':
|
|
540
|
+
return {
|
|
541
|
+
record_id: mapped.record_id,
|
|
542
|
+
owner_npub: mapped.owner_npub,
|
|
543
|
+
title: mapped.title,
|
|
544
|
+
content: mapped.content,
|
|
545
|
+
parent_directory_id: mapped.parent_directory_id,
|
|
546
|
+
scope_id: mapped.scope_id,
|
|
547
|
+
scope_l1_id: mapped.scope_l1_id,
|
|
548
|
+
scope_l2_id: mapped.scope_l2_id,
|
|
549
|
+
scope_l3_id: mapped.scope_l3_id,
|
|
550
|
+
scope_l4_id: mapped.scope_l4_id,
|
|
551
|
+
scope_l5_id: mapped.scope_l5_id,
|
|
552
|
+
group_ids_json: json(mapped.group_ids),
|
|
553
|
+
shares_json: json(mapped.shares),
|
|
554
|
+
record_state: mapped.record_state,
|
|
555
|
+
version: mapped.version,
|
|
556
|
+
updated_at: mapped.updated_at,
|
|
557
|
+
...common,
|
|
558
|
+
};
|
|
559
|
+
case 'directories':
|
|
560
|
+
return {
|
|
561
|
+
record_id: mapped.record_id,
|
|
562
|
+
owner_npub: mapped.owner_npub,
|
|
563
|
+
title: mapped.title,
|
|
564
|
+
parent_directory_id: mapped.parent_directory_id,
|
|
565
|
+
scope_id: mapped.scope_id,
|
|
566
|
+
scope_l1_id: mapped.scope_l1_id,
|
|
567
|
+
scope_l2_id: mapped.scope_l2_id,
|
|
568
|
+
scope_l3_id: mapped.scope_l3_id,
|
|
569
|
+
scope_l4_id: mapped.scope_l4_id,
|
|
570
|
+
scope_l5_id: mapped.scope_l5_id,
|
|
571
|
+
group_ids_json: json(mapped.group_ids),
|
|
572
|
+
shares_json: json(mapped.shares),
|
|
573
|
+
record_state: mapped.record_state,
|
|
574
|
+
version: mapped.version,
|
|
575
|
+
updated_at: mapped.updated_at,
|
|
576
|
+
...common,
|
|
577
|
+
};
|
|
578
|
+
case 'reports':
|
|
579
|
+
return {
|
|
580
|
+
record_id: mapped.record_id,
|
|
581
|
+
owner_npub: mapped.owner_npub,
|
|
582
|
+
title: mapped.title,
|
|
583
|
+
declaration_type: mapped.declaration_type,
|
|
584
|
+
surface: mapped.surface,
|
|
585
|
+
generated_at: mapped.generated_at,
|
|
586
|
+
payload_json: json(mapped.payload),
|
|
587
|
+
scope_id: mapped.scope_id,
|
|
588
|
+
scope_level: mapped.scope_level,
|
|
589
|
+
scope_l1_id: mapped.scope_l1_id,
|
|
590
|
+
scope_l2_id: mapped.scope_l2_id,
|
|
591
|
+
scope_l3_id: mapped.scope_l3_id,
|
|
592
|
+
scope_l4_id: mapped.scope_l4_id,
|
|
593
|
+
scope_l5_id: mapped.scope_l5_id,
|
|
594
|
+
group_ids_json: json(mapped.group_ids),
|
|
595
|
+
record_state: mapped.record_state,
|
|
596
|
+
version: mapped.version,
|
|
597
|
+
updated_at: mapped.updated_at,
|
|
598
|
+
...common,
|
|
599
|
+
};
|
|
600
|
+
case 'audio_notes':
|
|
601
|
+
return {
|
|
602
|
+
record_id: mapped.record_id,
|
|
603
|
+
owner_npub: mapped.owner_npub,
|
|
604
|
+
target_record_id: mapped.target_record_id,
|
|
605
|
+
target_record_family_hash: mapped.target_record_family_hash,
|
|
606
|
+
title: mapped.title,
|
|
607
|
+
storage_object_id: mapped.storage_object_id,
|
|
608
|
+
mime_type: mapped.mime_type,
|
|
609
|
+
duration_seconds: mapped.duration_seconds,
|
|
610
|
+
size_bytes: mapped.size_bytes ?? 0,
|
|
611
|
+
media_encryption_json: json(mapped.media_encryption),
|
|
612
|
+
waveform_preview_json: json(mapped.waveform_preview),
|
|
613
|
+
transcript_status: mapped.transcript_status,
|
|
614
|
+
transcript_preview: mapped.transcript_preview,
|
|
615
|
+
transcript: mapped.transcript,
|
|
616
|
+
summary: mapped.summary,
|
|
617
|
+
sender_npub: mapped.sender_npub ?? null,
|
|
618
|
+
group_ids_json: json(mapped.group_ids),
|
|
619
|
+
record_state: mapped.record_state,
|
|
620
|
+
version: mapped.version,
|
|
621
|
+
updated_at: mapped.updated_at,
|
|
622
|
+
...common,
|
|
623
|
+
};
|
|
624
|
+
case 'scopes':
|
|
625
|
+
return {
|
|
626
|
+
record_id: mapped.record_id,
|
|
627
|
+
owner_npub: mapped.owner_npub,
|
|
628
|
+
level: mapped.level,
|
|
629
|
+
title: mapped.title,
|
|
630
|
+
description: mapped.description,
|
|
631
|
+
parent_id: mapped.parent_id,
|
|
632
|
+
l1_id: mapped.l1_id,
|
|
633
|
+
l2_id: mapped.l2_id,
|
|
634
|
+
l3_id: mapped.l3_id,
|
|
635
|
+
l4_id: mapped.l4_id,
|
|
636
|
+
l5_id: mapped.l5_id,
|
|
637
|
+
group_ids_json: json(mapped.group_ids),
|
|
638
|
+
shares_json: json(mapped.shares),
|
|
639
|
+
record_state: mapped.record_state,
|
|
640
|
+
version: mapped.version,
|
|
641
|
+
updated_at: mapped.updated_at,
|
|
642
|
+
...common,
|
|
643
|
+
};
|
|
644
|
+
case 'schedules':
|
|
645
|
+
return {
|
|
646
|
+
record_id: mapped.record_id,
|
|
647
|
+
owner_npub: mapped.owner_npub,
|
|
648
|
+
title: mapped.title,
|
|
649
|
+
description: mapped.description,
|
|
650
|
+
time_start: mapped.time_start,
|
|
651
|
+
time_end: mapped.time_end,
|
|
652
|
+
days_json: json(mapped.days),
|
|
653
|
+
timezone: mapped.timezone,
|
|
654
|
+
assigned_group_id: mapped.assigned_group_id,
|
|
655
|
+
active: mapped.active ? 1 : 0,
|
|
656
|
+
last_run: mapped.last_run,
|
|
657
|
+
repeat: mapped.repeat,
|
|
658
|
+
group_ids_json: json(mapped.group_ids),
|
|
659
|
+
shares_json: json(mapped.shares),
|
|
660
|
+
record_state: mapped.record_state,
|
|
661
|
+
version: mapped.version,
|
|
662
|
+
updated_at: mapped.updated_at,
|
|
663
|
+
...common,
|
|
664
|
+
};
|
|
665
|
+
case 'flows':
|
|
666
|
+
return {
|
|
667
|
+
record_id: mapped.record_id,
|
|
668
|
+
owner_npub: mapped.owner_npub,
|
|
669
|
+
title: mapped.title,
|
|
670
|
+
description: mapped.description,
|
|
671
|
+
steps_json: json(mapped.steps),
|
|
672
|
+
next_flow_id: mapped.next_flow_id ?? null,
|
|
673
|
+
scope_id: mapped.scope_id ?? null,
|
|
674
|
+
scope_l1_id: mapped.scope_l1_id ?? null,
|
|
675
|
+
scope_l2_id: mapped.scope_l2_id ?? null,
|
|
676
|
+
scope_l3_id: mapped.scope_l3_id ?? null,
|
|
677
|
+
scope_l4_id: mapped.scope_l4_id ?? null,
|
|
678
|
+
scope_l5_id: mapped.scope_l5_id ?? null,
|
|
679
|
+
group_ids_json: json(mapped.group_ids),
|
|
680
|
+
shares_json: json(mapped.shares),
|
|
681
|
+
record_state: mapped.record_state,
|
|
682
|
+
version: mapped.version,
|
|
683
|
+
updated_at: mapped.updated_at,
|
|
684
|
+
...common,
|
|
685
|
+
};
|
|
686
|
+
case 'approvals':
|
|
687
|
+
return {
|
|
688
|
+
record_id: mapped.record_id,
|
|
689
|
+
owner_npub: mapped.owner_npub,
|
|
690
|
+
title: mapped.title,
|
|
691
|
+
description: mapped.description ?? '',
|
|
692
|
+
flow_id: mapped.flow_id ?? null,
|
|
693
|
+
flow_run_id: mapped.flow_run_id ?? null,
|
|
694
|
+
flow_step: mapped.flow_step ?? null,
|
|
695
|
+
task_ids_json: json(mapped.task_ids),
|
|
696
|
+
status: mapped.status,
|
|
697
|
+
approval_mode: mapped.approval_mode,
|
|
698
|
+
brief: mapped.brief,
|
|
699
|
+
approver_whitelist_json: json(mapped.approver_whitelist ?? []),
|
|
700
|
+
confidence_score: mapped.confidence_score ?? null,
|
|
701
|
+
approved_by: mapped.approved_by ?? null,
|
|
702
|
+
approved_at: mapped.approved_at ?? null,
|
|
703
|
+
decision_note: mapped.decision_note ?? null,
|
|
704
|
+
agent_review_by: mapped.agent_review_by ?? null,
|
|
705
|
+
agent_review_note: mapped.agent_review_note ?? null,
|
|
706
|
+
artifact_refs_json: json(mapped.artifact_refs),
|
|
707
|
+
revision_task_id: mapped.revision_task_id ?? null,
|
|
708
|
+
scope_id: mapped.scope_id ?? null,
|
|
709
|
+
scope_l1_id: mapped.scope_l1_id ?? null,
|
|
710
|
+
scope_l2_id: mapped.scope_l2_id ?? null,
|
|
711
|
+
scope_l3_id: mapped.scope_l3_id ?? null,
|
|
712
|
+
scope_l4_id: mapped.scope_l4_id ?? null,
|
|
713
|
+
scope_l5_id: mapped.scope_l5_id ?? null,
|
|
714
|
+
group_ids_json: json(mapped.group_ids),
|
|
715
|
+
shares_json: json(mapped.shares),
|
|
716
|
+
record_state: mapped.record_state,
|
|
717
|
+
version: mapped.version,
|
|
718
|
+
updated_at: mapped.updated_at,
|
|
719
|
+
...common,
|
|
720
|
+
};
|
|
721
|
+
default:
|
|
722
|
+
throw new Error(`Unsupported table ${table}`);
|
|
723
|
+
}
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
function normalizeAppSchemaPayload(payload, manifest) {
|
|
727
|
+
const data = payload?.data && typeof payload.data === 'object' ? payload.data : payload;
|
|
728
|
+
const recordFamilies = Array.isArray(data?.record_families)
|
|
729
|
+
? data.record_families
|
|
730
|
+
: (Array.isArray(manifest?.record_families) ? manifest.record_families : []);
|
|
731
|
+
const schemas = Array.isArray(data?.schemas) ? data.schemas : [];
|
|
732
|
+
return {
|
|
733
|
+
app_npub: String(data?.app_npub || manifest?.app_npub || '').trim(),
|
|
734
|
+
app_name: String(data?.app_name || manifest?.app_name || '').trim(),
|
|
735
|
+
schema_hash: String(data?.schema_hash || manifest?.schema_hash || '').trim(),
|
|
736
|
+
schema_version: Number(data?.schema_version || manifest?.schema_version || 1),
|
|
737
|
+
record_families: recordFamilies,
|
|
738
|
+
schemas,
|
|
739
|
+
};
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
async function syncAppSchemas({ client, db, session, groupKeyMap, wsSession, quiet }) {
|
|
743
|
+
if (typeof client.fetchWorkspaceAppSchemas !== 'function') return 0;
|
|
744
|
+
let result;
|
|
745
|
+
try {
|
|
746
|
+
result = await client.fetchWorkspaceAppSchemas({ latest: true });
|
|
747
|
+
} catch (error) {
|
|
748
|
+
if (/^API 404\b/.test(error instanceof Error ? error.message : String(error))) {
|
|
749
|
+
if (!quiet) console.warn('Skipping app schema sync: endpoint is not available on this Tower.');
|
|
750
|
+
return 0;
|
|
751
|
+
}
|
|
752
|
+
throw error;
|
|
753
|
+
}
|
|
754
|
+
const rows = [];
|
|
755
|
+
const syncedAt = new Date().toISOString();
|
|
756
|
+
for (const manifest of result.schemas || []) {
|
|
757
|
+
try {
|
|
758
|
+
const payload = decryptAppSchemaManifest(manifest, session, groupKeyMap, wsSession);
|
|
759
|
+
const normalized = normalizeAppSchemaPayload(payload, manifest);
|
|
760
|
+
if (!normalized.app_npub || !normalized.schema_hash) continue;
|
|
761
|
+
rows.push({
|
|
762
|
+
id: manifest.id || `${normalized.app_npub}:${normalized.schema_hash}`,
|
|
763
|
+
workspace_owner_npub: manifest.workspace_owner_npub,
|
|
764
|
+
app_npub: normalized.app_npub,
|
|
765
|
+
app_name: normalized.app_name || manifest.app_name || normalized.app_npub,
|
|
766
|
+
schema_hash: normalized.schema_hash,
|
|
767
|
+
schema_version: Number.isInteger(normalized.schema_version) ? normalized.schema_version : 1,
|
|
768
|
+
record_families_json: json(normalized.record_families),
|
|
769
|
+
schemas_json: json(normalized.schemas),
|
|
770
|
+
raw_json: json({
|
|
771
|
+
...normalized,
|
|
772
|
+
manifest: {
|
|
773
|
+
id: manifest.id,
|
|
774
|
+
updated_at: manifest.updated_at,
|
|
775
|
+
created_at: manifest.created_at,
|
|
776
|
+
created_by_npub: manifest.created_by_npub,
|
|
777
|
+
},
|
|
778
|
+
}),
|
|
779
|
+
updated_at: manifest.updated_at || manifest.created_at || syncedAt,
|
|
780
|
+
synced_at: syncedAt,
|
|
781
|
+
});
|
|
782
|
+
} catch (error) {
|
|
783
|
+
if (!quiet) {
|
|
784
|
+
const label = manifest.app_npub || manifest.id || 'unknown app';
|
|
785
|
+
console.warn(`Skipping undecryptable app schema ${label}: ${error instanceof Error ? error.message : String(error)}`);
|
|
786
|
+
}
|
|
787
|
+
}
|
|
788
|
+
}
|
|
789
|
+
upsertAppSchemas(db, rows);
|
|
790
|
+
return rows.length;
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
export async function syncWorkspace({ client, config, session, wsSession = null, quiet = false }) {
|
|
794
|
+
const db = openDb();
|
|
795
|
+
const groupsResult = await client.getGroups();
|
|
796
|
+
const groups = (groupsResult.groups ?? []).map(inboundGroup);
|
|
797
|
+
replaceGroups(db, groups.map((group) => ({
|
|
798
|
+
group_id: group.group_id,
|
|
799
|
+
current_group_npub: group.current_group_npub ?? group.group_npub ?? null,
|
|
800
|
+
current_epoch: group.current_epoch ?? 1,
|
|
801
|
+
owner_npub: group.owner_npub,
|
|
802
|
+
name: group.name,
|
|
803
|
+
group_kind: group.group_kind,
|
|
804
|
+
private_member_npub: group.private_member_npub,
|
|
805
|
+
member_npubs_json: json(group.member_npubs),
|
|
806
|
+
raw_json: json(group),
|
|
807
|
+
synced_at: new Date().toISOString(),
|
|
808
|
+
})));
|
|
809
|
+
|
|
810
|
+
const keysResult = await client.getGroupKeys();
|
|
811
|
+
const keyRows = (keysResult.keys ?? []).map((entry) => ({
|
|
812
|
+
group_id: entry.group_id ?? entry.group_npub ?? null,
|
|
813
|
+
key_version: entry.key_version ?? entry.epoch ?? 1,
|
|
814
|
+
group_npub: entry.group_npub,
|
|
815
|
+
wrapped_group_nsec: entry.wrapped_group_nsec,
|
|
816
|
+
wrapped_by_npub: entry.wrapped_by_npub,
|
|
817
|
+
raw_json: json(entry),
|
|
818
|
+
synced_at: new Date().toISOString(),
|
|
819
|
+
}));
|
|
820
|
+
replaceGroupKeys(db, keyRows);
|
|
821
|
+
const groupKeyMap = loadGroupKeyMap(session, keyRows, decodeNsec);
|
|
822
|
+
|
|
823
|
+
// Fetch and cache workspace key → user npub mappings
|
|
824
|
+
try {
|
|
825
|
+
const mappingsResult = await client.fetchWorkspaceKeyMappings(config.workspaceOwnerNpub);
|
|
826
|
+
for (const entry of (mappingsResult.mappings ?? [])) {
|
|
827
|
+
upsertWorkspaceKeyMapping(db, entry.ws_key_npub, entry.user_npub);
|
|
828
|
+
}
|
|
829
|
+
} catch {
|
|
830
|
+
// Non-critical — existing cached mappings still work
|
|
831
|
+
}
|
|
832
|
+
const wsKeyMappings = getAllWorkspaceKeyMappings(db);
|
|
833
|
+
const resolveDisplayNpub = (npub) => wsKeyMappings.get(npub) || npub;
|
|
834
|
+
|
|
835
|
+
const counts = { groups: groups.length, group_keys: keyRows.length };
|
|
836
|
+
counts.app_schemas = await syncAppSchemas({
|
|
837
|
+
client,
|
|
838
|
+
db,
|
|
839
|
+
session,
|
|
840
|
+
groupKeyMap,
|
|
841
|
+
wsSession,
|
|
842
|
+
quiet,
|
|
843
|
+
});
|
|
844
|
+
|
|
845
|
+
for (const family of FAMILY_TABLES) {
|
|
846
|
+
const hash = recordFamilyHash(config.appNpub, family.collection);
|
|
847
|
+
const since = getMeta(db, `sync:${family.collection}:at`);
|
|
848
|
+
const result = await client.fetchRecords(hash, since);
|
|
849
|
+
const rows = [];
|
|
850
|
+
let latestAppliedAt = since;
|
|
851
|
+
for (const record of result.records ?? []) {
|
|
852
|
+
try {
|
|
853
|
+
const payload = decryptRecordPayload(record, session, groupKeyMap, wsSession);
|
|
854
|
+
const mapped = family.mapper(record, payload);
|
|
855
|
+
// Resolve workspace key npubs to real user npubs for display fields
|
|
856
|
+
if (mapped.sender_npub) mapped.sender_npub = resolveDisplayNpub(mapped.sender_npub);
|
|
857
|
+
if (mapped.reactor_npub) mapped.reactor_npub = resolveDisplayNpub(mapped.reactor_npub);
|
|
858
|
+
if (mapped.assigned_to_npub) mapped.assigned_to_npub = resolveDisplayNpub(mapped.assigned_to_npub);
|
|
859
|
+
rows.push(rowForTable(family.table, mapped, record));
|
|
860
|
+
latestAppliedAt = newerIsoTimestamp(
|
|
861
|
+
latestAppliedAt,
|
|
862
|
+
record.updated_at ?? mapped.updated_at ?? null,
|
|
863
|
+
);
|
|
864
|
+
} catch (error) {
|
|
865
|
+
if (!quiet) {
|
|
866
|
+
console.warn(`Skipping undecryptable ${family.collection} record ${record.record_id}: ${error instanceof Error ? error.message : String(error)}`);
|
|
867
|
+
}
|
|
868
|
+
}
|
|
869
|
+
}
|
|
870
|
+
upsertRows(db, family.table, rows);
|
|
871
|
+
counts[family.collection] = rows.length;
|
|
872
|
+
if (latestAppliedAt && latestAppliedAt !== since) {
|
|
873
|
+
putMeta(db, `sync:${family.collection}:at`, latestAppliedAt);
|
|
874
|
+
}
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
const pruneResult = pruneInaccessibleRows(db, {
|
|
878
|
+
viewerNpub: session.npub,
|
|
879
|
+
workspaceOwnerNpub: config.workspaceOwnerNpub,
|
|
880
|
+
groups,
|
|
881
|
+
keyRows,
|
|
882
|
+
});
|
|
883
|
+
if (pruneResult.pruned > 0) counts.pruned = pruneResult.pruned;
|
|
884
|
+
const repairResult = repairLocalAccessMetadata(db, {
|
|
885
|
+
viewerNpub: session.npub,
|
|
886
|
+
workspaceOwnerNpub: config.workspaceOwnerNpub,
|
|
887
|
+
groups,
|
|
888
|
+
keyRows,
|
|
889
|
+
});
|
|
890
|
+
if (repairResult.repaired > 0) counts.repaired = repairResult.repaired;
|
|
891
|
+
|
|
892
|
+
putMeta(db, 'sync:last_at', new Date().toISOString());
|
|
893
|
+
return counts;
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
export function getSyncStatus(db) {
|
|
897
|
+
return {
|
|
898
|
+
lastSyncAt: getMeta(db, 'sync:last_at'),
|
|
899
|
+
};
|
|
900
|
+
}
|