wyrm-mcp 7.2.0 → 7.2.2
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/LICENSE +26 -667
- package/NOTICE +14 -33
- package/dist/activation.d.ts.map +1 -1
- package/dist/activation.js +1 -44
- package/dist/activation.js.map +1 -1
- package/dist/agent-daemon.js +4 -281
- package/dist/agent-loop.js +7 -332
- package/dist/analytics.js +13 -236
- package/dist/attribution.js +1 -49
- package/dist/audit.js +2 -457
- package/dist/auto-capture.js +3 -138
- package/dist/auto-orchestrator.js +1 -325
- package/dist/autoconfig.js +39 -840
- package/dist/buddy-runner.js +1 -109
- package/dist/buddy.js +14 -564
- package/dist/build-flags.js +1 -17
- package/dist/capabilities.js +3 -183
- package/dist/capture.js +1 -56
- package/dist/causality.js +6 -107
- package/dist/cli.js +20 -281
- package/dist/cloud/cli.js +5 -541
- package/dist/cloud/client.js +1 -221
- package/dist/cloud/crypto.js +1 -85
- package/dist/cloud/machine-id.js +2 -113
- package/dist/cloud/recovery.js +1 -60
- package/dist/cloud/sync-engine.js +7 -543
- package/dist/cloud-backup.js +5 -579
- package/dist/cloud-profile.js +1 -138
- package/dist/cloud-sync-entrypoint.js +1 -47
- package/dist/cloud-sync.js +2 -309
- package/dist/constellation.js +12 -168
- package/dist/context-build-budgeted.js +4 -144
- package/dist/context-ranking.js +1 -69
- package/dist/crypto.js +1 -179
- package/dist/daemon-write-endpoint.js +1 -290
- package/dist/daemon-writer.js +2 -406
- package/dist/database.js +43 -1110
- package/dist/deprecations.js +2 -162
- package/dist/design.js +13 -141
- package/dist/event-replication.js +1 -112
- package/dist/events-sse.js +7 -43
- package/dist/events.js +6 -238
- package/dist/failure-patterns.js +42 -659
- package/dist/federation.js +12 -236
- package/dist/goals.js +13 -101
- package/dist/golden.js +3 -355
- package/dist/handlers/agent.js +4 -165
- package/dist/handlers/alias-adapters.js +1 -129
- package/dist/handlers/aliases.js +1 -171
- package/dist/handlers/audit.js +1 -87
- package/dist/handlers/boundary.js +1 -221
- package/dist/handlers/capture.js +73 -1109
- package/dist/handlers/causality.js +7 -114
- package/dist/handlers/cloud.js +85 -382
- package/dist/handlers/companion.js +28 -459
- package/dist/handlers/datalake.js +7 -187
- package/dist/handlers/dispatch-context.js +0 -22
- package/dist/handlers/entity.js +25 -256
- package/dist/handlers/events.js +16 -335
- package/dist/handlers/failure.js +13 -340
- package/dist/handlers/goals.js +4 -296
- package/dist/handlers/intelligence.js +126 -674
- package/dist/handlers/invoicing.js +1 -70
- package/dist/handlers/mcpclient.js +6 -137
- package/dist/handlers/orchestration.js +40 -125
- package/dist/handlers/output-schemas.js +1 -24
- package/dist/handlers/presence.js +3 -99
- package/dist/handlers/project.js +28 -182
- package/dist/handlers/prompts.js +6 -157
- package/dist/handlers/quest.js +4 -224
- package/dist/handlers/recall.js +11 -218
- package/dist/handlers/registry.js +1 -167
- package/dist/handlers/resources.js +1 -288
- package/dist/handlers/review.js +11 -74
- package/dist/handlers/run.js +17 -487
- package/dist/handlers/search.js +15 -326
- package/dist/handlers/session.js +28 -615
- package/dist/handlers/share.js +8 -184
- package/dist/handlers/shims.js +1 -464
- package/dist/handlers/skill.js +67 -449
- package/dist/handlers/survivors.js +1 -120
- package/dist/handlers/symbols.js +8 -109
- package/dist/handlers/syncops.js +4 -302
- package/dist/handlers/types.js +1 -27
- package/dist/harvest.js +5 -191
- package/dist/hours.js +7 -156
- package/dist/http-auth.js +3 -321
- package/dist/http-fast.js +21 -1137
- package/dist/icons.js +1 -47
- package/dist/index.js +2 -924
- package/dist/indexer.js +4 -145
- package/dist/intelligence.js +31 -261
- package/dist/internal-dispatch.js +3 -212
- package/dist/keyset.js +1 -110
- package/dist/knowledge-graph.js +12 -176
- package/dist/license.d.ts +11 -0
- package/dist/license.d.ts.map +1 -1
- package/dist/license.js +2 -414
- package/dist/license.js.map +1 -1
- package/dist/logger.js +2 -199
- package/dist/maintenance.js +2 -148
- package/dist/mcp-client.js +6 -262
- package/dist/memory-artifacts.js +30 -449
- package/dist/migrate-prompt.js +2 -124
- package/dist/migrations.js +40 -655
- package/dist/performance.js +1 -228
- package/dist/presence.js +11 -140
- package/dist/priority-embed.js +5 -164
- package/dist/providers/embedding-provider.js +1 -196
- package/dist/readonly-gate.js +1 -29
- package/dist/rehydration.js +9 -157
- package/dist/reindex.js +1 -88
- package/dist/render-target.js +21 -514
- package/dist/render.js +4 -280
- package/dist/repl-guard.js +1 -173
- package/dist/replication-daemon-entrypoint.js +1 -31
- package/dist/replication-daemon.js +2 -262
- package/dist/resilience.js +1 -591
- package/dist/reverse-bridge.js +5 -360
- package/dist/security.js +1 -244
- package/dist/session-seen.js +3 -51
- package/dist/setup.js +1 -260
- package/dist/skill-author.js +5 -168
- package/dist/spec-kit.js +1 -191
- package/dist/sqlite-busy.js +1 -154
- package/dist/statusline.js +11 -315
- package/dist/sub-agent.js +13 -262
- package/dist/summarizer.js +13 -139
- package/dist/symbols.js +7 -283
- package/dist/sync.js +5 -359
- package/dist/tasks-dispatch.js +1 -84
- package/dist/tasks.js +1 -282
- package/dist/token-budget.js +1 -143
- package/dist/tool-analytics.js +7 -129
- package/dist/tool-annotations.js +1 -365
- package/dist/tool-manifest-v2.json +1 -1
- package/dist/tool-manifest.json +1 -1
- package/dist/tool-profiles.js +1 -75
- package/dist/trace-harvest.js +6 -244
- package/dist/types.js +1 -30
- package/dist/ui-dashboard.js +41 -50
- package/dist/ulid.js +1 -81
- package/dist/validate.js +1 -129
- package/dist/vault.js +1 -534
- package/dist/vectors.js +3 -184
- package/dist/version-check.js +4 -136
- package/dist/visibility.js +19 -155
- package/dist/wyrm-cli.js +98 -2451
- package/dist/wyrm-cli.js.map +1 -1
- package/dist/wyrm-guard.js +14 -424
- package/dist/wyrm-loop.js +3 -150
- package/dist/wyrm-manifest.json +1 -1
- package/dist/wyrm-statusline-daemon.js +1 -11
- package/dist/wyrm-statusline.js +4 -56
- package/dist/wyrm-ui.js +9 -77
- package/package.json +4 -2
|
@@ -1,176 +1,5 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
*
|
|
4
|
-
* v1.1 semantics (incremental + tombstones):
|
|
5
|
-
* - PUSH: rows with cross_project_visibility != 'within' AND
|
|
6
|
-
* updated_at > last_push_updated_at_ms get serialised + encrypted +
|
|
7
|
-
* uploaded. Plus any pending tombstones from sync_tombstones.
|
|
8
|
-
* - PULL: fetch peer deltas since pull_cursor. Decrypt. kind='tombstone'
|
|
9
|
-
* → DELETE locally. All other kinds → INSERT OR REPLACE on (kind, row_id).
|
|
10
|
-
* Last-write-wins on conflicts.
|
|
11
|
-
* - This is intentionally NOT a CRDT — for v1 we're solving "same operator
|
|
12
|
-
* on phone + laptop". v2 (team tier) will add real conflict semantics.
|
|
13
|
-
*
|
|
14
|
-
* Tables synced in v1:
|
|
15
|
-
* - ground_truths (kind='truth')
|
|
16
|
-
* - memory_artifacts (kind='memory_artifact')
|
|
17
|
-
* - quests (kind='quest')
|
|
18
|
-
* - design_tokens (kind='design_token')
|
|
19
|
-
* - design_references (kind='design_reference')
|
|
20
|
-
*
|
|
21
|
-
* Sessions / hour_entries / failure_patterns are intentionally NOT synced
|
|
22
|
-
* yet — they're per-device time-series data that doesn't merge cleanly.
|
|
23
|
-
*
|
|
24
|
-
* @copyright 2026 Ghost Protocol (Pvt) Ltd.
|
|
25
|
-
*/
|
|
26
|
-
import { readFileSync, writeFileSync, existsSync } from 'node:fs';
|
|
27
|
-
import { join } from 'node:path';
|
|
28
|
-
import { WyrmDB } from '../database.js';
|
|
29
|
-
import { CloudClient, loadSession, saveSession, resolveCloudDir } from './client.js';
|
|
30
|
-
import { encrypt, decrypt } from './crypto.js';
|
|
31
|
-
import { classifySession, computeMachineFp } from './machine-id.js';
|
|
32
|
-
/** Resolve the pull-cursor file at call time (honors WYRM_CLOUD_DIR). */
|
|
33
|
-
function cursorFilePath() {
|
|
34
|
-
return join(resolveCloudDir(), 'cloud-cursor.json');
|
|
35
|
-
}
|
|
36
|
-
// Default (privacy-first) tables — these have `cross_project_visibility`
|
|
37
|
-
// and the operator opts in by setting it to 'org' or 'public'.
|
|
38
|
-
export const DEFAULT_TABLES = [
|
|
39
|
-
{ kind: 'truth', table: 'ground_truths', hasVisibility: true },
|
|
40
|
-
{ kind: 'memory_artifact', table: 'memory_artifacts', hasVisibility: true },
|
|
41
|
-
{ kind: 'quest', table: 'quests', hasVisibility: true },
|
|
42
|
-
{ kind: 'design_token', table: 'design_tokens', hasVisibility: true },
|
|
43
|
-
{ kind: 'design_reference', table: 'design_references', hasVisibility: true },
|
|
44
|
-
// Skills carry the SKILL.md body (migration 25) so they're portable across
|
|
45
|
-
// machines. Gated by per-row `cross_project_visibility` (DEFAULT 'within' =
|
|
46
|
-
// private) exactly like the rows above — a skill only egresses once the
|
|
47
|
-
// operator promotes it (`wyrm skill share <name>`), never by default.
|
|
48
|
-
{ kind: 'skill', table: 'skills', hasVisibility: true },
|
|
49
|
-
];
|
|
50
|
-
/**
|
|
51
|
-
* Tables that NEVER sync, even in `--all` mode. These are per-device
|
|
52
|
-
* telemetry, derived state, or volatile bookkeeping that doesn't merge
|
|
53
|
-
* meaningfully across devices.
|
|
54
|
-
*/
|
|
55
|
-
const DENYLIST_TABLES = new Set([
|
|
56
|
-
// Per-device timing / activity
|
|
57
|
-
'sessions', 'agent_actions', 'agent_presence', 'session_seen_artifacts',
|
|
58
|
-
// Logs (per-device, append-only, would balloon storage)
|
|
59
|
-
'tool_call_log', 'llm_query_log', 'external_call_log',
|
|
60
|
-
'token_savings_log', 'cost_tracking', 'usage_events', 'audit_log',
|
|
61
|
-
// Derived / cache state
|
|
62
|
-
'indexing_queue', 'vectors', 'symbol_index', 'update_check',
|
|
63
|
-
// Sync internals — never sync the sync state itself
|
|
64
|
-
'sync_conflicts', 'sync_log', 'sync_tombstones', 'wyrm_cloud_sync_state',
|
|
65
|
-
// Local config (machine-specific)
|
|
66
|
-
'mcp_client_configs', 'watch_dirs', 'global_context', 'schema_versions',
|
|
67
|
-
// Conservatively skip failure_patterns — per-device debugging context
|
|
68
|
-
'failure_patterns',
|
|
69
|
-
]);
|
|
70
|
-
/**
|
|
71
|
-
* FTS shadow tables (auto-maintained by SQLite from base tables).
|
|
72
|
-
* These get rebuilt from the base table, never sync them directly.
|
|
73
|
-
*/
|
|
74
|
-
const FTS_SHADOW_PATTERN = /_fts(_config|_data|_docsize|_idx)?$/;
|
|
75
|
-
/**
|
|
76
|
-
* Discover every syncable table in the local DB at runtime.
|
|
77
|
-
*
|
|
78
|
-
* - Skips FTS shadow tables (auto-derived from base tables)
|
|
79
|
-
* - Skips entries in DENYLIST_TABLES (per-device or volatile)
|
|
80
|
-
* - Tables WITH `cross_project_visibility` keep `hasVisibility=true`
|
|
81
|
-
* so the default opt-in semantics still work for them
|
|
82
|
-
* - Tables WITHOUT it get `hasVisibility=false` (synced unconditionally
|
|
83
|
-
* in `--all` mode; never in default mode)
|
|
84
|
-
*
|
|
85
|
-
* The `kind` is derived from the table name (drop trailing 's' for
|
|
86
|
-
* common plurals: `skills` → `skill`, `projects` → `project`). This
|
|
87
|
-
* keeps the wire format stable even if the local table layout changes.
|
|
88
|
-
*/
|
|
89
|
-
function discoverTables(db) {
|
|
90
|
-
const raw = db.getDatabase();
|
|
91
|
-
const out = [...DEFAULT_TABLES];
|
|
92
|
-
const known = new Set(DEFAULT_TABLES.map((t) => t.table));
|
|
93
|
-
try {
|
|
94
|
-
const rows = raw.prepare(`SELECT name FROM sqlite_master
|
|
95
|
-
WHERE type='table' AND name NOT LIKE 'sqlite_%'`).all();
|
|
96
|
-
for (const r of rows) {
|
|
97
|
-
const name = r.name;
|
|
98
|
-
if (known.has(name))
|
|
99
|
-
continue;
|
|
100
|
-
if (DENYLIST_TABLES.has(name))
|
|
101
|
-
continue;
|
|
102
|
-
if (FTS_SHADOW_PATTERN.test(name))
|
|
103
|
-
continue;
|
|
104
|
-
if (!SAFE_IDENT.test(name))
|
|
105
|
-
continue;
|
|
106
|
-
// Require an `id` column — primary key for INSERT OR REPLACE
|
|
107
|
-
const cols = raw.prepare(`PRAGMA table_info(${name})`).all();
|
|
108
|
-
if (!cols.some((c) => c.name === 'id'))
|
|
109
|
-
continue;
|
|
110
|
-
const hasVis = cols.some((c) => c.name === 'cross_project_visibility');
|
|
111
|
-
out.push({ kind: tableToKind(name), table: name, hasVisibility: hasVis });
|
|
112
|
-
}
|
|
113
|
-
}
|
|
114
|
-
catch { /* fall back to defaults */ }
|
|
115
|
-
return out;
|
|
116
|
-
}
|
|
117
|
-
function tableToKind(table) {
|
|
118
|
-
// For dynamically-discovered tables, use the table name verbatim as
|
|
119
|
-
// the kind. Plural-stripping was buggy (`context` → `contex`,
|
|
120
|
-
// `data_lake` → `data_lak`) and the kind is just an opaque routing
|
|
121
|
-
// label anyway — round-tripping via the same registry on both sides
|
|
122
|
-
// means there's no benefit to "prettifying" it.
|
|
123
|
-
//
|
|
124
|
-
// DEFAULT_TABLES still uses explicit singular kinds (truth, quest,
|
|
125
|
-
// etc.) for backwards-compatibility with deltas already pushed by
|
|
126
|
-
// earlier CLI versions.
|
|
127
|
-
return table;
|
|
128
|
-
}
|
|
129
|
-
/**
|
|
130
|
-
* Build the inbound-apply column whitelist for a table dynamically by
|
|
131
|
-
* reading the local schema. Local schema is trusted (it's our own DB,
|
|
132
|
-
* not from peers); the whitelist defends against a malicious peer
|
|
133
|
-
* sending crafted column names by ONLY accepting columns that exist
|
|
134
|
-
* locally. Anything else is dropped before SQL generation.
|
|
135
|
-
*/
|
|
136
|
-
function columnWhitelist(db, table) {
|
|
137
|
-
if (!SAFE_IDENT.test(table))
|
|
138
|
-
return new Set();
|
|
139
|
-
try {
|
|
140
|
-
const raw = db.getDatabase();
|
|
141
|
-
const cols = raw.prepare(`PRAGMA table_info(${table})`).all();
|
|
142
|
-
return new Set(cols.filter((c) => SAFE_IDENT.test(c.name)).map((c) => c.name));
|
|
143
|
-
}
|
|
144
|
-
catch {
|
|
145
|
-
return new Set();
|
|
146
|
-
}
|
|
147
|
-
}
|
|
148
|
-
// Validation regex for table/column identifiers — defence-in-depth.
|
|
149
|
-
const SAFE_IDENT = /^[a-z_][a-z0-9_]*$/i;
|
|
150
|
-
// ── Cursor persistence ────────────────────────────────────────────────────
|
|
151
|
-
function loadCursor() {
|
|
152
|
-
const empty = { pull_cursor: 0, last_push_updated_ms: 0, last_push_at: 0, last_pull_at: 0 };
|
|
153
|
-
const file = cursorFilePath();
|
|
154
|
-
if (!existsSync(file))
|
|
155
|
-
return empty;
|
|
156
|
-
try {
|
|
157
|
-
const raw = JSON.parse(readFileSync(file, 'utf-8'));
|
|
158
|
-
return { ...empty, ...raw };
|
|
159
|
-
}
|
|
160
|
-
catch {
|
|
161
|
-
return empty;
|
|
162
|
-
}
|
|
163
|
-
}
|
|
164
|
-
function saveCursor(c) {
|
|
165
|
-
writeFileSync(cursorFilePath(), JSON.stringify(c, null, 2), { mode: 0o600 });
|
|
166
|
-
}
|
|
167
|
-
// ── Tombstone table ───────────────────────────────────────────────────────
|
|
168
|
-
// We keep deletion intents in a local sync_tombstones table. Other code
|
|
169
|
-
// paths that DELETE a synced row should also INSERT into sync_tombstones
|
|
170
|
-
// (see recordTombstone below). The sync push drains this table.
|
|
171
|
-
function ensureTombstoneTable(db) {
|
|
172
|
-
const raw = db.getDatabase();
|
|
173
|
-
raw.exec(`
|
|
1
|
+
import{readFileSync as O,writeFileSync as k,existsSync as D}from"node:fs";import{join as I}from"node:path";import{WyrmDB as $}from"../database.js";import{CloudClient as C,loadSession as L,saveSession as A,resolveCloudDir as v}from"./client.js";import{encrypt as S,decrypt as j}from"./crypto.js";import{classifySession as x,computeMachineFp as P}from"./machine-id.js";function T(){return I(v(),"cloud-cursor.json")}const b=[{kind:"truth",table:"ground_truths",hasVisibility:!0},{kind:"memory_artifact",table:"memory_artifacts",hasVisibility:!0},{kind:"quest",table:"quests",hasVisibility:!0},{kind:"design_token",table:"design_tokens",hasVisibility:!0},{kind:"design_reference",table:"design_references",hasVisibility:!0},{kind:"skill",table:"skills",hasVisibility:!0}],U=new Set(["sessions","agent_actions","agent_presence","session_seen_artifacts","tool_call_log","llm_query_log","external_call_log","token_savings_log","cost_tracking","usage_events","audit_log","indexing_queue","vectors","symbol_index","update_check","sync_conflicts","sync_log","sync_tombstones","wyrm_cloud_sync_state","mcp_client_configs","watch_dirs","global_context","schema_versions","failure_patterns"]),W=/_fts(_config|_data|_docsize|_idx)?$/;function F(e){const t=e.getDatabase(),s=[...b],r=new Set(b.map(o=>o.table));try{const o=t.prepare(`SELECT name FROM sqlite_master
|
|
2
|
+
WHERE type='table' AND name NOT LIKE 'sqlite_%'`).all();for(const n of o){const l=n.name;if(r.has(l)||U.has(l)||W.test(l)||!h.test(l))continue;const _=t.prepare(`PRAGMA table_info(${l})`).all();if(!_.some(c=>c.name==="id"))continue;const d=_.some(c=>c.name==="cross_project_visibility");s.push({kind:l,table:l,hasVisibility:d})}}catch{}return s}function re(e){return e}function M(e,t){if(!h.test(t))return new Set;try{const r=e.getDatabase().prepare(`PRAGMA table_info(${t})`).all();return new Set(r.filter(o=>h.test(o.name)).map(o=>o.name))}catch{return new Set}}const h=/^[a-z_][a-z0-9_]*$/i;function H(){const e={pull_cursor:0,last_push_updated_ms:0,last_push_at:0,last_pull_at:0},t=T();if(!D(t))return e;try{const s=JSON.parse(O(t,"utf-8"));return{...e,...s}}catch{return e}}function V(e){k(T(),JSON.stringify(e,null,2),{mode:384})}function N(e){e.getDatabase().exec(`
|
|
174
3
|
CREATE TABLE IF NOT EXISTS sync_tombstones (
|
|
175
4
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
176
5
|
kind TEXT NOT NULL,
|
|
@@ -181,376 +10,11 @@ function ensureTombstoneTable(db) {
|
|
|
181
10
|
);
|
|
182
11
|
CREATE INDEX IF NOT EXISTS idx_sync_tombstones_pending
|
|
183
12
|
ON sync_tombstones(pushed_at_ms);
|
|
184
|
-
`);
|
|
185
|
-
}
|
|
186
|
-
|
|
187
|
-
*
|
|
188
|
-
* row in one of the synced tables, BEFORE the actual delete runs (so
|
|
189
|
-
* we capture intent even if the local delete races a sync). Idempotent.
|
|
190
|
-
*
|
|
191
|
-
* Accepts any kind — we no longer hard-validate against the registry
|
|
192
|
-
* because the registry is discovered dynamically at sync time.
|
|
193
|
-
*/
|
|
194
|
-
export function recordTombstone(db, kind, rowId) {
|
|
195
|
-
if (typeof kind !== 'string' || kind.length === 0 || kind.length > 64)
|
|
196
|
-
return;
|
|
197
|
-
ensureTombstoneTable(db);
|
|
198
|
-
const raw = db.getDatabase();
|
|
199
|
-
raw.prepare(`INSERT OR IGNORE INTO sync_tombstones (kind, row_id, deleted_at_ms)
|
|
200
|
-
VALUES (?, ?, ?)`).run(kind, String(rowId), Date.now());
|
|
201
|
-
}
|
|
202
|
-
/**
|
|
203
|
-
* Thrown when the session's machine fingerprint does not match this
|
|
204
|
-
* machine (a copied cloud.json → device_id collision → silent 0-row
|
|
205
|
-
* pulls, failure #40). Caught by the CLI and rendered as guidance.
|
|
206
|
-
*/
|
|
207
|
-
export class CopiedSessionError extends Error {
|
|
208
|
-
constructor(message) {
|
|
209
|
-
super(message);
|
|
210
|
-
this.name = 'CopiedSessionError';
|
|
211
|
-
}
|
|
212
|
-
}
|
|
213
|
-
/** True when the operator has explicitly opted to proceed past the guard. */
|
|
214
|
-
function copiedSessionAllowed(force) {
|
|
215
|
-
return !!force || process.env.WYRM_ALLOW_COPIED_SESSION === '1';
|
|
216
|
-
}
|
|
217
|
-
/**
|
|
218
|
-
* Guard against a copied cloud session (failure #40). Recompute this
|
|
219
|
-
* machine's fingerprint and compare to the one stored at login:
|
|
220
|
-
* - 'adopt' → no fp on the session (pre-7.0.3). Write the current fp
|
|
221
|
-
* in place, silently. Avoids false alarms on every
|
|
222
|
-
* existing install after upgrade.
|
|
223
|
-
* - 'match' → silent, normal.
|
|
224
|
-
* - 'mismatch' → this session was minted on another machine. Both boxes
|
|
225
|
-
* share one device_id; cross-device pull silently returns
|
|
226
|
-
* 0. Warn LOUD on stderr and STOP (throw), unless --force
|
|
227
|
-
* / WYRM_ALLOW_COPIED_SESSION=1.
|
|
228
|
-
*
|
|
229
|
-
* stderr ONLY — stdout is the MCP wire when this module is server-imported.
|
|
230
|
-
*/
|
|
231
|
-
export function guardCopiedSession(session, saveAdopted, force) {
|
|
232
|
-
const verdict = classifySession(session.machine_fp);
|
|
233
|
-
if (verdict.state === 'match')
|
|
234
|
-
return;
|
|
235
|
-
if (verdict.state === 'adopt') {
|
|
236
|
-
// Backward compat: pre-7.0.3 session has no fp. Adopt this machine
|
|
237
|
-
// silently — only a PRESENT-but-different fp is suspicious.
|
|
238
|
-
saveAdopted(computeMachineFp());
|
|
239
|
-
return;
|
|
240
|
-
}
|
|
241
|
-
// mismatch
|
|
242
|
-
const lines = [
|
|
243
|
-
'',
|
|
244
|
-
'⚠ WYRM CLOUD: this session looks COPIED from another machine.',
|
|
245
|
-
'',
|
|
246
|
-
` Stored machine fingerprint: ${verdict.stored.slice(0, 12)}…`,
|
|
247
|
-
` This machine\'s fingerprint: ${verdict.current.slice(0, 12)}…`,
|
|
248
|
-
'',
|
|
249
|
-
' Both machines now share ONE device_id. The cloud pull query skips a',
|
|
250
|
-
' device\'s own deltas, so each machine filters the OTHER\'s changes out:',
|
|
251
|
-
' push works but pull silently returns 0 rows (peer memory looks MISSING).',
|
|
252
|
-
'',
|
|
253
|
-
' FIX — give this machine its own identity (keep your encryption key):',
|
|
254
|
-
' rm ~/.wyrm/cloud.json ~/.wyrm/cloud-cursor.json # NOT cloud.key',
|
|
255
|
-
' wyrm cloud login',
|
|
256
|
-
'',
|
|
257
|
-
' If this is a legitimate change (e.g. you renamed this host), re-run with:',
|
|
258
|
-
' wyrm cloud sync --force (or set WYRM_ALLOW_COPIED_SESSION=1)',
|
|
259
|
-
'',
|
|
260
|
-
];
|
|
261
|
-
if (copiedSessionAllowed(force)) {
|
|
262
|
-
// Warn but continue. Also adopt the current fp so the operator isn't
|
|
263
|
-
// nagged every sync after an intentional, acknowledged change.
|
|
264
|
-
console.error(lines.join('\n'));
|
|
265
|
-
console.error(' Proceeding anyway (--force / WYRM_ALLOW_COPIED_SESSION=1); fingerprint updated.');
|
|
266
|
-
console.error('');
|
|
267
|
-
saveAdopted(verdict.current);
|
|
268
|
-
return;
|
|
269
|
-
}
|
|
270
|
-
console.error(lines.join('\n'));
|
|
271
|
-
throw new CopiedSessionError('Cloud session looks copied from another machine (device_id collision). Re-login or pass --force.');
|
|
272
|
-
}
|
|
273
|
-
export async function runSync(opts = {}) {
|
|
274
|
-
const session = loadSession();
|
|
275
|
-
if (!session || !session.device_id) {
|
|
276
|
-
throw new Error('Not logged in or device not registered. Run `wyrm cloud login`.');
|
|
277
|
-
}
|
|
278
|
-
// Failure #40 guard: detect a copied session before doing any work.
|
|
279
|
-
guardCopiedSession(session, (fp) => { saveSession({ ...session, machine_fp: fp }); }, opts.force);
|
|
280
|
-
const client = new CloudClient(session.base, session.session);
|
|
281
|
-
const cursor = loadCursor();
|
|
282
|
-
const db = new WyrmDB();
|
|
283
|
-
ensureTombstoneTable(db);
|
|
284
|
-
const result = { pushed: 0, pulled: 0, deleted_local: 0, errors: [] };
|
|
285
|
-
// Build the table registry: defaults always; in --all mode, expand to
|
|
286
|
-
// every discoverable syncable table in the local DB.
|
|
287
|
-
const tables = opts.all ? discoverTables(db) : DEFAULT_TABLES;
|
|
288
|
-
// ── PUSH ──
|
|
289
|
-
const upserts = collectChangedRows(db, tables, cursor.last_push_updated_ms, !!opts.all, result.errors);
|
|
290
|
-
const tombstones = collectPendingTombstones(db);
|
|
291
|
-
const allDeltas = [
|
|
292
|
-
...upserts.map((r) => ({
|
|
293
|
-
kind: r.kind,
|
|
294
|
-
row_id: String(r.row_id),
|
|
295
|
-
visibility: r.visibility,
|
|
296
|
-
payload_b64: encrypt(JSON.stringify(r.row)),
|
|
297
|
-
updated_at: r.updated_at,
|
|
298
|
-
})),
|
|
299
|
-
...tombstones.map((t) => ({
|
|
300
|
-
kind: 'tombstone',
|
|
301
|
-
row_id: `${t.kind}:${t.row_id}`,
|
|
302
|
-
visibility: 'org',
|
|
303
|
-
payload_b64: encrypt(JSON.stringify({ kind: t.kind, row_id: t.row_id, deleted_at_ms: t.deleted_at_ms })),
|
|
304
|
-
updated_at: t.deleted_at_ms,
|
|
305
|
-
})),
|
|
306
|
-
];
|
|
307
|
-
if (allDeltas.length > 0 && !opts.dryRun) {
|
|
308
|
-
const CHUNK = 50;
|
|
309
|
-
let maxUpdatedAt = cursor.last_push_updated_ms;
|
|
310
|
-
for (let i = 0; i < allDeltas.length; i += CHUNK) {
|
|
311
|
-
const chunk = allDeltas.slice(i, i + CHUNK);
|
|
312
|
-
try {
|
|
313
|
-
const resp = await client.syncPush(session.device_id, chunk);
|
|
314
|
-
result.pushed += resp.accepted;
|
|
315
|
-
if (resp.rejected > 0) {
|
|
316
|
-
for (const r of resp.rejected_details) {
|
|
317
|
-
result.errors.push(`push: ${r.reason} on ${r.id.slice(0, 8)}…`);
|
|
318
|
-
}
|
|
319
|
-
}
|
|
320
|
-
// Track the high-water mark from what we sent in this chunk.
|
|
321
|
-
// Server-side validates/clamps; we trust our local timestamps here.
|
|
322
|
-
for (const d of chunk) {
|
|
323
|
-
if (d.updated_at && d.updated_at > maxUpdatedAt)
|
|
324
|
-
maxUpdatedAt = d.updated_at;
|
|
325
|
-
}
|
|
326
|
-
}
|
|
327
|
-
catch (err) {
|
|
328
|
-
result.errors.push(`push chunk failed: ${err instanceof Error ? err.message : err}`);
|
|
329
|
-
}
|
|
330
|
-
}
|
|
331
|
-
cursor.last_push_updated_ms = maxUpdatedAt;
|
|
332
|
-
cursor.last_push_at = Date.now();
|
|
333
|
-
if (tombstones.length > 0)
|
|
334
|
-
markTombstonesPushed(db, tombstones.map((t) => t.id));
|
|
335
|
-
}
|
|
336
|
-
else {
|
|
337
|
-
result.pushed = allDeltas.length;
|
|
338
|
-
}
|
|
339
|
-
// ── PULL ──
|
|
340
|
-
if (!opts.dryRun) {
|
|
341
|
-
let cur = cursor.pull_cursor;
|
|
342
|
-
let pages = 0;
|
|
343
|
-
pullLoop: while (pages < 50) {
|
|
344
|
-
let resp;
|
|
345
|
-
try {
|
|
346
|
-
resp = await client.syncPull(session.device_id, cur, 100);
|
|
347
|
-
}
|
|
348
|
-
catch (err) {
|
|
349
|
-
result.errors.push(`pull failed: ${err instanceof Error ? err.message : err}`);
|
|
350
|
-
break;
|
|
351
|
-
}
|
|
352
|
-
if (resp.deltas.length === 0)
|
|
353
|
-
break;
|
|
354
|
-
for (const d of resp.deltas) {
|
|
355
|
-
try {
|
|
356
|
-
const plaintext = decrypt(d.payload_b64);
|
|
357
|
-
if (d.kind === 'tombstone') {
|
|
358
|
-
const { kind, row_id } = JSON.parse(plaintext);
|
|
359
|
-
if (applyTombstone(db, tables, kind, row_id))
|
|
360
|
-
result.deleted_local++;
|
|
361
|
-
}
|
|
362
|
-
else {
|
|
363
|
-
const row = JSON.parse(plaintext);
|
|
364
|
-
applyRow(db, tables, d.kind, d.row_id, row);
|
|
365
|
-
result.pulled++;
|
|
366
|
-
}
|
|
367
|
-
}
|
|
368
|
-
catch (err) {
|
|
369
|
-
// A delta we couldn't decrypt/parse/apply is sticky — STOP
|
|
370
|
-
// advancing the cursor so we don't drop it on the floor.
|
|
371
|
-
// Operator sees the error and can decide what to do (it might
|
|
372
|
-
// be a key mismatch, a future schema, or genuine corruption).
|
|
373
|
-
result.errors.push(`apply ${d.kind}:${d.row_id}: ${err instanceof Error ? err.message : err}`);
|
|
374
|
-
break pullLoop;
|
|
375
|
-
}
|
|
376
|
-
}
|
|
377
|
-
cur = resp.next_cursor;
|
|
378
|
-
if (!resp.has_more)
|
|
379
|
-
break;
|
|
380
|
-
pages++;
|
|
381
|
-
}
|
|
382
|
-
cursor.pull_cursor = cur;
|
|
383
|
-
cursor.last_pull_at = Date.now();
|
|
384
|
-
}
|
|
385
|
-
saveCursor(cursor);
|
|
386
|
-
return result;
|
|
387
|
-
}
|
|
388
|
-
/**
|
|
389
|
-
* Parse a SQLite `datetime('now')`-style string into ms epoch.
|
|
390
|
-
* Format: "YYYY-MM-DD HH:MM:SS" in UTC. Falls back to 0 on parse failure.
|
|
391
|
-
*/
|
|
392
|
-
function parseSqliteDatetimeMs(s) {
|
|
393
|
-
if (typeof s !== 'string' || s.length === 0)
|
|
394
|
-
return 0;
|
|
395
|
-
// Treat as UTC. Replace space with T so Date parses ISO-like.
|
|
396
|
-
const iso = s.includes('T') ? s : s.replace(' ', 'T') + 'Z';
|
|
397
|
-
const t = Date.parse(iso);
|
|
398
|
-
return Number.isFinite(t) ? t : 0;
|
|
399
|
-
}
|
|
400
|
-
function tableHasColumn(db, table, column) {
|
|
401
|
-
try {
|
|
402
|
-
const raw = db.getDatabase();
|
|
403
|
-
// PRAGMA can't be parameterised; identifiers are validated upstream.
|
|
404
|
-
const rows = raw.prepare(`PRAGMA table_info(${table})`).all();
|
|
405
|
-
return rows.some((r) => r.name === column);
|
|
406
|
-
}
|
|
407
|
-
catch {
|
|
408
|
-
return false;
|
|
409
|
-
}
|
|
410
|
-
}
|
|
411
|
-
export function collectChangedRows(db, tables, sinceMs, allMode, errors) {
|
|
412
|
-
const out = [];
|
|
413
|
-
const rawDb = db.getDatabase();
|
|
414
|
-
// [grove isolation] Gate cloud replication by projects.sync_policy when the
|
|
415
|
-
// column is present. Computed once; cheap.
|
|
416
|
-
const projectsHasSyncPolicy = tableHasColumn(db, 'projects', 'sync_policy');
|
|
417
|
-
for (const t of tables) {
|
|
418
|
-
if (!SAFE_IDENT.test(t.table))
|
|
419
|
-
continue;
|
|
420
|
-
// In default mode: skip tables that have no visibility column
|
|
421
|
-
// (they were never meant for opt-in privacy). In --all mode: sync
|
|
422
|
-
// every discovered table regardless.
|
|
423
|
-
if (!allMode && !t.hasVisibility)
|
|
424
|
-
continue;
|
|
425
|
-
try {
|
|
426
|
-
const hasUpdatedAt = tableHasColumn(db, t.table, 'updated_at');
|
|
427
|
-
// [sec 6.3.1] Honor per-row privacy in BOTH default and --all mode:
|
|
428
|
-
// visibility-bearing tables NEVER replicate 'within' (operator-private)
|
|
429
|
-
// rows to the cloud. --all still widens to column-less tables, but it no
|
|
430
|
-
// longer overrides an explicit per-row privacy marking. Use the encrypted
|
|
431
|
-
// full-DB snapshot (cloud-backup) for a literal everything-backup.
|
|
432
|
-
const visClause = t.hasVisibility
|
|
433
|
-
? `WHERE cross_project_visibility IN ('org', 'public')`
|
|
434
|
-
: `WHERE 1=1`;
|
|
435
|
-
// [grove isolation] Only replicate rows whose grove opts into cloud sync
|
|
436
|
-
// (projects.sync_policy IN 'cloud','team'). Project-less rows fall back to
|
|
437
|
-
// the per-row visibility gate above. A 'private' grove (the default, incl.
|
|
438
|
-
// GHOST PROTOCOL) NEVER replicates, even in --all mode.
|
|
439
|
-
const groveClause = (projectsHasSyncPolicy && tableHasColumn(db, t.table, 'project_id'))
|
|
440
|
-
? `AND (project_id IS NULL OR project_id IN (SELECT id FROM projects WHERE sync_policy IN ('cloud', 'team')))`
|
|
441
|
-
: ``;
|
|
442
|
-
const updatedClause = hasUpdatedAt
|
|
443
|
-
? `AND (? = 0 OR strftime('%s', updated_at) * 1000 > ?)`
|
|
444
|
-
: ``;
|
|
445
|
-
// ORDER BY updated_at so the chunked push processes rows in
|
|
446
|
-
// monotonically-increasing time order. Without this, a chunk
|
|
447
|
-
// that fails would advance the cursor past rows in EARLIER
|
|
448
|
-
// chunks that succeeded, permanently skipping the failed rows.
|
|
449
|
-
const orderClause = hasUpdatedAt ? `ORDER BY updated_at ASC` : ``;
|
|
450
|
-
const sql = `SELECT * FROM ${t.table} ${visClause} ${groveClause} ${updatedClause} ${orderClause}`;
|
|
451
|
-
const stmt = rawDb.prepare(sql);
|
|
452
|
-
const rows = hasUpdatedAt
|
|
453
|
-
? stmt.all(sinceMs, sinceMs)
|
|
454
|
-
: stmt.all();
|
|
455
|
-
for (const row of rows) {
|
|
456
|
-
const updatedMs = parseSqliteDatetimeMs(row.updated_at);
|
|
457
|
-
if (sinceMs > 0 && updatedMs > 0 && updatedMs <= sinceMs)
|
|
458
|
-
continue;
|
|
459
|
-
// For tables without visibility, mark visibility 'org' so the
|
|
460
|
-
// server's pull-from-peers logic can still surface them across
|
|
461
|
-
// the operator's own devices.
|
|
462
|
-
const vis = t.hasVisibility
|
|
463
|
-
? String(row.cross_project_visibility ?? 'within')
|
|
464
|
-
: 'org';
|
|
465
|
-
out.push({
|
|
466
|
-
kind: t.kind,
|
|
467
|
-
row_id: (row.id ?? '?'),
|
|
468
|
-
visibility: vis,
|
|
469
|
-
row,
|
|
470
|
-
updated_at: updatedMs || Date.now(),
|
|
471
|
-
});
|
|
472
|
-
}
|
|
473
|
-
}
|
|
474
|
-
catch (err) {
|
|
475
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
476
|
-
if (/no such table/i.test(msg))
|
|
477
|
-
continue;
|
|
478
|
-
errors.push(`collect ${t.table}: ${msg}`);
|
|
479
|
-
}
|
|
480
|
-
}
|
|
481
|
-
// Final pass: order by updated_at ACROSS tables too. The cursor
|
|
482
|
-
// advances on per-chunk-success; if rows from table A and B interleave
|
|
483
|
-
// by updated_at, we need them globally ordered so a failed B-chunk
|
|
484
|
-
// doesn't strand A-rows on the next iteration.
|
|
485
|
-
out.sort((a, b) => (a.updated_at ?? 0) - (b.updated_at ?? 0));
|
|
486
|
-
return out;
|
|
487
|
-
}
|
|
488
|
-
function collectPendingTombstones(db) {
|
|
489
|
-
const raw = db.getDatabase();
|
|
490
|
-
try {
|
|
491
|
-
return raw.prepare(`SELECT id, kind, row_id, deleted_at_ms
|
|
13
|
+
`)}function ie(e,t,s){if(typeof t!="string"||t.length===0||t.length>64)return;N(e),e.getDatabase().prepare(`INSERT OR IGNORE INTO sync_tombstones (kind, row_id, deleted_at_ms)
|
|
14
|
+
VALUES (?, ?, ?)`).run(t,String(s),Date.now())}class q extends Error{constructor(t){super(t),this.name="CopiedSessionError"}}function Y(e){return!!e||process.env.WYRM_ALLOW_COPIED_SESSION==="1"}function G(e,t,s){const r=x(e.machine_fp);if(r.state==="match")return;if(r.state==="adopt"){t(P());return}const o=["","\u26A0 WYRM CLOUD: this session looks COPIED from another machine.","",` Stored machine fingerprint: ${r.stored.slice(0,12)}\u2026`,` This machine's fingerprint: ${r.current.slice(0,12)}\u2026`,""," Both machines now share ONE device_id. The cloud pull query skips a"," device's own deltas, so each machine filters the OTHER's changes out:"," push works but pull silently returns 0 rows (peer memory looks MISSING).",""," FIX \u2014 give this machine its own identity (keep your encryption key):"," rm ~/.wyrm/cloud.json ~/.wyrm/cloud-cursor.json # NOT cloud.key"," wyrm cloud login",""," If this is a legitimate change (e.g. you renamed this host), re-run with:"," wyrm cloud sync --force (or set WYRM_ALLOW_COPIED_SESSION=1)",""];if(Y(s)){console.error(o.join(`
|
|
15
|
+
`)),console.error(" Proceeding anyway (--force / WYRM_ALLOW_COPIED_SESSION=1); fingerprint updated."),console.error(""),t(r.current);return}throw console.error(o.join(`
|
|
16
|
+
`)),new q("Cloud session looks copied from another machine (device_id collision). Re-login or pass --force.")}async function ae(e={}){const t=L();if(!t||!t.device_id)throw new Error("Not logged in or device not registered. Run `wyrm cloud login`.");G(t,i=>{A({...t,machine_fp:i})},e.force);const s=new C(t.base,t.session),r=H(),o=new $;N(o);const n={pushed:0,pulled:0,deleted_local:0,errors:[]},l=e.all?F(o):b,_=J(o,l,r.last_push_updated_ms,!!e.all,n.errors),d=K(o),c=[..._.map(i=>({kind:i.kind,row_id:String(i.row_id),visibility:i.visibility,payload_b64:S(JSON.stringify(i.row)),updated_at:i.updated_at})),...d.map(i=>({kind:"tombstone",row_id:`${i.kind}:${i.row_id}`,visibility:"org",payload_b64:S(JSON.stringify({kind:i.kind,row_id:i.row_id,deleted_at_ms:i.deleted_at_ms})),updated_at:i.deleted_at_ms}))];if(c.length>0&&!e.dryRun){let m=r.last_push_updated_ms;for(let a=0;a<c.length;a+=50){const p=c.slice(a,a+50);try{const u=await s.syncPush(t.device_id,p);if(n.pushed+=u.accepted,u.rejected>0)for(const f of u.rejected_details)n.errors.push(`push: ${f.reason} on ${f.id.slice(0,8)}\u2026`);for(const f of p)f.updated_at&&f.updated_at>m&&(m=f.updated_at)}catch(u){n.errors.push(`push chunk failed: ${u instanceof Error?u.message:u}`)}}r.last_push_updated_ms=m,r.last_push_at=Date.now(),d.length>0&&X(o,d.map(a=>a.id))}else n.pushed=c.length;if(!e.dryRun){let i=r.pull_cursor,m=0;e:for(;m<50;){let a;try{a=await s.syncPull(t.device_id,i,100)}catch(p){n.errors.push(`pull failed: ${p instanceof Error?p.message:p}`);break}if(a.deltas.length===0)break;for(const p of a.deltas)try{const u=j(p.payload_b64);if(p.kind==="tombstone"){const{kind:f,row_id:w}=JSON.parse(u);Q(o,l,f,w)&&n.deleted_local++}else{const f=JSON.parse(u);z(o,l,p.kind,p.row_id,f),n.pulled++}}catch(u){n.errors.push(`apply ${p.kind}:${p.row_id}: ${u instanceof Error?u.message:u}`);break e}if(i=a.next_cursor,!a.has_more)break;m++}r.pull_cursor=i,r.last_pull_at=Date.now()}return V(r),n}function B(e){if(typeof e!="string"||e.length===0)return 0;const t=e.includes("T")?e:e.replace(" ","T")+"Z",s=Date.parse(t);return Number.isFinite(s)?s:0}function g(e,t,s){try{return e.getDatabase().prepare(`PRAGMA table_info(${t})`).all().some(n=>n.name===s)}catch{return!1}}function J(e,t,s,r,o){const n=[],l=e.getDatabase(),_=g(e,"projects","sync_policy");for(const d of t)if(h.test(d.table)&&!(!r&&!d.hasVisibility))try{const c=g(e,d.table,"updated_at"),i=d.hasVisibility?"WHERE cross_project_visibility IN ('org', 'public')":"WHERE 1=1",m=_&&g(e,d.table,"project_id")?"AND (project_id IS NULL OR project_id IN (SELECT id FROM projects WHERE sync_policy IN ('cloud', 'team')))":"",a=c?"AND (? = 0 OR strftime('%s', updated_at) * 1000 > ?)":"",p=c?"ORDER BY updated_at ASC":"",u=`SELECT * FROM ${d.table} ${i} ${m} ${a} ${p}`,f=l.prepare(u),w=c?f.all(s,s):f.all();for(const y of w){const E=B(y.updated_at);if(s>0&&E>0&&E<=s)continue;const R=d.hasVisibility?String(y.cross_project_visibility??"within"):"org";n.push({kind:d.kind,row_id:y.id??"?",visibility:R,row:y,updated_at:E||Date.now()})}}catch(c){const i=c instanceof Error?c.message:String(c);if(/no such table/i.test(i))continue;o.push(`collect ${d.table}: ${i}`)}return n.sort((d,c)=>(d.updated_at??0)-(c.updated_at??0)),n}function K(e){const t=e.getDatabase();try{return t.prepare(`SELECT id, kind, row_id, deleted_at_ms
|
|
492
17
|
FROM sync_tombstones
|
|
493
18
|
WHERE pushed_at_ms IS NULL
|
|
494
19
|
ORDER BY id ASC
|
|
495
|
-
LIMIT 500`).all();
|
|
496
|
-
}
|
|
497
|
-
catch {
|
|
498
|
-
return [];
|
|
499
|
-
}
|
|
500
|
-
}
|
|
501
|
-
function markTombstonesPushed(db, ids) {
|
|
502
|
-
if (ids.length === 0)
|
|
503
|
-
return;
|
|
504
|
-
const raw = db.getDatabase();
|
|
505
|
-
const now = Date.now();
|
|
506
|
-
const stmt = raw.prepare(`UPDATE sync_tombstones SET pushed_at_ms = ? WHERE id = ?`);
|
|
507
|
-
const tx = raw.transaction((tIds) => {
|
|
508
|
-
for (const id of tIds)
|
|
509
|
-
stmt.run(now, id);
|
|
510
|
-
});
|
|
511
|
-
try {
|
|
512
|
-
tx(ids);
|
|
513
|
-
}
|
|
514
|
-
catch { /* best-effort */ }
|
|
515
|
-
}
|
|
516
|
-
// ── Apply a pulled row (last-write-wins) ──────────────────────────────────
|
|
517
|
-
function applyRow(db, tables, kind, _rowId, row) {
|
|
518
|
-
const target = tables.find((t) => t.kind === kind);
|
|
519
|
-
if (!target)
|
|
520
|
-
return; // unknown kind — peer is on a newer/different schema
|
|
521
|
-
if (!SAFE_IDENT.test(target.table)) {
|
|
522
|
-
throw new Error(`apply ${kind}: refusing unsafe table name ${target.table}`);
|
|
523
|
-
}
|
|
524
|
-
// Build the column whitelist dynamically from the LOCAL schema. Local
|
|
525
|
-
// schema is trusted; the peer's payload is not. This blocks any
|
|
526
|
-
// crafted column name that doesn't exist locally — including SQL
|
|
527
|
-
// injection attempts like `id) VALUES (...); DROP TABLE x;--`.
|
|
528
|
-
const allowed = columnWhitelist(db, target.table);
|
|
529
|
-
if (allowed.size === 0) {
|
|
530
|
-
throw new Error(`apply ${kind}: ${target.table} has no readable schema`);
|
|
531
|
-
}
|
|
532
|
-
const cols = Object.keys(row).filter((c) => allowed.has(c) && SAFE_IDENT.test(c));
|
|
533
|
-
if (cols.length === 0) {
|
|
534
|
-
throw new Error(`apply ${kind}: no columns from peer payload matched local schema`);
|
|
535
|
-
}
|
|
536
|
-
const rawDb = db.getDatabase();
|
|
537
|
-
const placeholders = cols.map(() => '?').join(',');
|
|
538
|
-
const colList = cols.join(',');
|
|
539
|
-
const values = cols.map((c) => row[c]);
|
|
540
|
-
try {
|
|
541
|
-
rawDb.prepare(`INSERT OR REPLACE INTO ${target.table} (${colList}) VALUES (${placeholders})`).run(...values);
|
|
542
|
-
}
|
|
543
|
-
catch (err) {
|
|
544
|
-
throw new Error(`apply ${kind} failed: ${err instanceof Error ? err.message : err}`);
|
|
545
|
-
}
|
|
546
|
-
}
|
|
547
|
-
/** Returns true if a row was actually deleted (vs. already gone). Throws on real DB errors. */
|
|
548
|
-
function applyTombstone(db, tables, kind, rowId) {
|
|
549
|
-
const target = tables.find((t) => t.kind === kind);
|
|
550
|
-
if (!target || !SAFE_IDENT.test(target.table))
|
|
551
|
-
return false;
|
|
552
|
-
const raw = db.getDatabase();
|
|
553
|
-
const res = raw.prepare(`DELETE FROM ${target.table} WHERE id = ?`).run(rowId);
|
|
554
|
-
return res.changes > 0;
|
|
555
|
-
}
|
|
556
|
-
//# sourceMappingURL=sync-engine.js.map
|
|
20
|
+
LIMIT 500`).all()}catch{return[]}}function X(e,t){if(t.length===0)return;const s=e.getDatabase(),r=Date.now(),o=s.prepare("UPDATE sync_tombstones SET pushed_at_ms = ? WHERE id = ?"),n=s.transaction(l=>{for(const _ of l)o.run(r,_)});try{n(t)}catch{}}function z(e,t,s,r,o){const n=t.find(a=>a.kind===s);if(!n)return;if(!h.test(n.table))throw new Error(`apply ${s}: refusing unsafe table name ${n.table}`);const l=M(e,n.table);if(l.size===0)throw new Error(`apply ${s}: ${n.table} has no readable schema`);const _=Object.keys(o).filter(a=>l.has(a)&&h.test(a));if(_.length===0)throw new Error(`apply ${s}: no columns from peer payload matched local schema`);const d=e.getDatabase(),c=_.map(()=>"?").join(","),i=_.join(","),m=_.map(a=>o[a]);try{d.prepare(`INSERT OR REPLACE INTO ${n.table} (${i}) VALUES (${c})`).run(...m)}catch(a){throw new Error(`apply ${s} failed: ${a instanceof Error?a.message:a}`)}}function Q(e,t,s,r){const o=t.find(_=>_.kind===s);return!o||!h.test(o.table)?!1:e.getDatabase().prepare(`DELETE FROM ${o.table} WHERE id = ?`).run(r).changes>0}export{q as CopiedSessionError,b as DEFAULT_TABLES,J as collectChangedRows,G as guardCopiedSession,ie as recordTombstone,ae as runSync};
|