Skip to content

Commit e43fee1

Browse files
author
StackMemory Bot (CLI)
committed
feat(q2): cloud sync protocol + Provenant API scaffold
Local↔cloud sync engine for Provenant hosted product: - Wire protocol types (cloud-sync-types.ts) - CloudSyncEngine with push/pull/status, delta collection, generational projection (young=full, mature=digest, old=anchors), offline resilience - CloudSyncManager lifecycle wrapper with debounce + periodic sync - Sync state schema (cloud_sync_state, cloud_sync_cursors tables) - 3 MCP tools: cloud_sync_push, cloud_sync_pull, cloud_sync_status - CLI: stackmemory sync push/pull/status - Config extension: apiKey, autoSync, syncIntervalMinutes - 22 unit tests covering engine, projection, conflicts, cursors Cloud-side scaffold (packages/provenant-api/): - CF Worker with /v1/sync/push, /v1/sync/pull, /v1/sync/status - Neon Postgres schema migration (api_keys, sync_entities, sync_cursors) - API key auth middleware
1 parent dd799d0 commit e43fee1

17 files changed

Lines changed: 2569 additions & 0 deletions

File tree

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
# Copy to .dev.vars and fill in values
2+
DATABASE_URL=postgresql://user:password@ep-xxx.us-east-2.aws.neon.tech/provenant?sslmode=require
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
{
2+
"name": "@stackmemoryai/provenant-api",
3+
"private": true,
4+
"version": "0.1.0",
5+
"type": "module",
6+
"description": "Provenant hosted sync API — Cloudflare Workers + Neon Postgres",
7+
"scripts": {
8+
"dev": "wrangler dev",
9+
"deploy": "wrangler deploy",
10+
"db:migrate": "node src/migrate.js",
11+
"typegen": "wrangler types"
12+
},
13+
"dependencies": {
14+
"@neondatabase/serverless": "^1.0.0"
15+
},
16+
"devDependencies": {
17+
"wrangler": "^4.41.0"
18+
}
19+
}

packages/provenant-api/src/auth.js

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
/**
2+
* Auth middleware — API key validation
3+
* Reads Bearer token, hashes it, looks up in api_keys table.
4+
*/
5+
6+
import { createHash } from 'node:crypto';
7+
8+
/**
9+
* @param {Request} request
10+
* @param {{ DATABASE_URL: string }} env
11+
* @param {import('@neondatabase/serverless').NeonQueryFunction} sql
12+
* @returns {Promise<{ projectId: string; email: string } | Response>}
13+
*/
14+
export async function authenticate(request, env, sql) {
15+
const authHeader = request.headers.get('Authorization');
16+
if (!authHeader?.startsWith('Bearer ')) {
17+
return new Response(
18+
JSON.stringify({ error: 'Missing Authorization header' }),
19+
{
20+
status: 401,
21+
headers: { 'Content-Type': 'application/json' },
22+
}
23+
);
24+
}
25+
26+
const token = authHeader.slice(7);
27+
const keyHash = createHash('sha256').update(token).digest('hex');
28+
29+
const rows = await sql`
30+
SELECT id, user_email, project_id FROM api_keys
31+
WHERE key_hash = ${keyHash}
32+
AND revoked_at IS NULL
33+
`;
34+
35+
if (rows.length === 0) {
36+
return new Response(JSON.stringify({ error: 'Invalid API key' }), {
37+
status: 401,
38+
headers: { 'Content-Type': 'application/json' },
39+
});
40+
}
41+
42+
const key = rows[0];
43+
44+
// Update last_used_at (fire-and-forget)
45+
sql`UPDATE api_keys SET last_used_at = NOW() WHERE id = ${key.id}`.catch(
46+
() => {}
47+
);
48+
49+
return { projectId: key.project_id, email: key.user_email };
50+
}
Lines changed: 246 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,246 @@
1+
/**
2+
* Provenant Sync API — Cloudflare Worker
3+
*
4+
* Endpoints:
5+
* POST /v1/sync/push — Accept entities from local clients
6+
* POST /v1/sync/pull — Return entities since cursor
7+
* GET /v1/sync/status — Server-side sync status
8+
* GET /health — Health check
9+
*/
10+
11+
import { neon } from '@neondatabase/serverless';
12+
import { authenticate } from './auth.js';
13+
14+
export default {
15+
/**
16+
* @param {Request} request
17+
* @param {{ DATABASE_URL: string; SYNC_OVERFLOW: R2Bucket }} env
18+
*/
19+
async fetch(request, env) {
20+
const url = new URL(request.url);
21+
const path = url.pathname;
22+
23+
// Health check — no auth
24+
if (path === '/health' && request.method === 'GET') {
25+
return json({ status: 'ok', version: '0.1.0' });
26+
}
27+
28+
// CORS preflight
29+
if (request.method === 'OPTIONS') {
30+
return new Response(null, {
31+
status: 204,
32+
headers: corsHeaders(),
33+
});
34+
}
35+
36+
// All sync endpoints require auth
37+
const sql = neon(env.DATABASE_URL);
38+
const authResult = await authenticate(request, env, sql);
39+
if (authResult instanceof Response) return authResult;
40+
41+
const { projectId, email } = authResult;
42+
const clientId = request.headers.get('X-Client-Id') || 'unknown';
43+
44+
try {
45+
switch (`${request.method} ${path}`) {
46+
case 'POST /v1/sync/push':
47+
return await handlePush(request, sql, projectId, clientId);
48+
case 'POST /v1/sync/pull':
49+
return await handlePull(request, sql, projectId, clientId);
50+
case 'GET /v1/sync/status':
51+
return await handleStatus(sql, projectId, clientId);
52+
default:
53+
return json({ error: 'Not found' }, 404);
54+
}
55+
} catch (err) {
56+
console.error('Sync API error:', err);
57+
return json({ error: 'Internal server error' }, 500);
58+
}
59+
},
60+
};
61+
62+
/**
63+
* POST /v1/sync/push
64+
* Accept entities from a local client, upsert into Neon.
65+
*/
66+
async function handlePush(request, sql, projectId, clientId) {
67+
const body = await request.json();
68+
69+
if (body.protocolVersion !== 1) {
70+
return json({ error: 'Unsupported protocol version' }, 400);
71+
}
72+
73+
const entities = body.entities || [];
74+
if (entities.length === 0) {
75+
return json({
76+
accepted: 0,
77+
rejected: [],
78+
serverCursor: new Date().toISOString(),
79+
});
80+
}
81+
82+
const rejected = [];
83+
let accepted = 0;
84+
const conflicts = [];
85+
86+
for (const entity of entities) {
87+
try {
88+
// Check for conflict (newest_wins)
89+
const existing = await sql`
90+
SELECT version FROM sync_entities
91+
WHERE project_id = ${projectId}
92+
AND table_name = ${entity.table}
93+
AND id = ${entity.id}
94+
`;
95+
96+
if (existing.length > 0 && existing[0].version > entity.version) {
97+
conflicts.push({
98+
id: entity.id,
99+
table: entity.table,
100+
serverVersion: Number(existing[0].version),
101+
clientVersion: entity.version,
102+
});
103+
continue;
104+
}
105+
106+
// Upsert
107+
await sql`
108+
INSERT INTO sync_entities (id, project_id, table_name, version, tier, data, client_id)
109+
VALUES (${entity.id}, ${projectId}, ${entity.table}, ${entity.version}, ${entity.tier}, ${JSON.stringify(entity.data)}, ${clientId})
110+
ON CONFLICT (project_id, table_name, id)
111+
DO UPDATE SET
112+
version = EXCLUDED.version,
113+
tier = EXCLUDED.tier,
114+
data = EXCLUDED.data,
115+
client_id = EXCLUDED.client_id,
116+
pushed_at = NOW()
117+
`;
118+
accepted++;
119+
} catch (err) {
120+
rejected.push({ id: entity.id, reason: String(err) });
121+
}
122+
}
123+
124+
const serverCursor = new Date().toISOString();
125+
126+
// Update client cursor
127+
await sql`
128+
INSERT INTO sync_cursors (project_id, client_id, direction, cursor_value)
129+
VALUES (${projectId}, ${clientId}, 'push', ${serverCursor}::timestamptz)
130+
ON CONFLICT (project_id, client_id, direction)
131+
DO UPDATE SET cursor_value = EXCLUDED.cursor_value, updated_at = NOW()
132+
`;
133+
134+
return json({
135+
accepted,
136+
rejected,
137+
serverCursor,
138+
...(conflicts.length > 0 ? { conflicts } : {}),
139+
});
140+
}
141+
142+
/**
143+
* POST /v1/sync/pull
144+
* Return entities pushed by other clients since the given cursor.
145+
*/
146+
async function handlePull(request, sql, projectId, clientId) {
147+
const body = await request.json();
148+
const since = body.since || new Date(0).toISOString();
149+
const limit = Math.min(body.limit || 100, 500);
150+
const tables = body.tables; // optional filter
151+
152+
let rows;
153+
if (tables && tables.length > 0) {
154+
rows = await sql`
155+
SELECT id, table_name, version, tier, data
156+
FROM sync_entities
157+
WHERE project_id = ${projectId}
158+
AND pushed_at > ${since}::timestamptz
159+
AND client_id != ${clientId}
160+
AND table_name = ANY(${tables})
161+
ORDER BY pushed_at ASC
162+
LIMIT ${limit + 1}
163+
`;
164+
} else {
165+
rows = await sql`
166+
SELECT id, table_name, version, tier, data
167+
FROM sync_entities
168+
WHERE project_id = ${projectId}
169+
AND pushed_at > ${since}::timestamptz
170+
AND client_id != ${clientId}
171+
ORDER BY pushed_at ASC
172+
LIMIT ${limit + 1}
173+
`;
174+
}
175+
176+
const hasMore = rows.length > limit;
177+
const entities = rows.slice(0, limit).map((r) => ({
178+
table: r.table_name,
179+
id: r.id,
180+
version: Number(r.version),
181+
tier: r.tier,
182+
data: typeof r.data === 'string' ? JSON.parse(r.data) : r.data,
183+
}));
184+
185+
// Server cursor = pushed_at of last returned entity, or `since` if empty
186+
const serverCursor =
187+
entities.length > 0
188+
? new Date().toISOString() // approximate — good enough for alpha
189+
: since;
190+
191+
// Update pull cursor
192+
await sql`
193+
INSERT INTO sync_cursors (project_id, client_id, direction, cursor_value)
194+
VALUES (${projectId}, ${clientId}, 'pull', ${serverCursor}::timestamptz)
195+
ON CONFLICT (project_id, client_id, direction)
196+
DO UPDATE SET cursor_value = EXCLUDED.cursor_value, updated_at = NOW()
197+
`;
198+
199+
return json({ entities, serverCursor, hasMore });
200+
}
201+
202+
/**
203+
* GET /v1/sync/status
204+
*/
205+
async function handleStatus(sql, projectId, clientId) {
206+
const cursors = await sql`
207+
SELECT direction, cursor_value FROM sync_cursors
208+
WHERE project_id = ${projectId} AND client_id = ${clientId}
209+
`;
210+
211+
const pushCursor = cursors.find((c) => c.direction === 'push');
212+
const pullCursor = cursors.find((c) => c.direction === 'pull');
213+
214+
const entityCount = await sql`
215+
SELECT COUNT(*)::int as count FROM sync_entities
216+
WHERE project_id = ${projectId}
217+
`;
218+
219+
return json({
220+
projectId,
221+
clientId,
222+
lastPushAt: pushCursor?.cursor_value || null,
223+
lastPullAt: pullCursor?.cursor_value || null,
224+
totalEntities: entityCount[0]?.count || 0,
225+
});
226+
}
227+
228+
// --- Helpers ---
229+
230+
function json(data, status = 200) {
231+
return new Response(JSON.stringify(data), {
232+
status,
233+
headers: {
234+
'Content-Type': 'application/json',
235+
...corsHeaders(),
236+
},
237+
});
238+
}
239+
240+
function corsHeaders() {
241+
return {
242+
'Access-Control-Allow-Origin': '*',
243+
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
244+
'Access-Control-Allow-Headers': 'Content-Type, Authorization, X-Client-Id',
245+
};
246+
}

0 commit comments

Comments
 (0)