Skip to content

Commit 8747a0f

Browse files
committed
add Upstash Redis adapter and demo seed
Support Upstash REST as an alternative Redis driver and seed a demo sheet. Env schema now accepts UPSTASH_REDIS_REST_URL/TOKEN (and validates at least one Redis binding is present). The API entrypoint prefers createUpstashQueueClient when Upstash creds are present, falls back to ioredis for local dev, and logs the selected driver. Added an Upstash REST adapter implementing the QueueRedisClient contract (simulates blocking by sleeping when stream empty). Added a migration (0001_demo_seed.sql) to seed demo user/project/sheet rows and updated the migrations journal. Processor now skips the demo sheet (drained by demo-processor) to avoid double-processing.
1 parent ea2115f commit 8747a0f

7 files changed

Lines changed: 209 additions & 18 deletions

File tree

apps/api/src/env.ts

Lines changed: 31 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,36 @@
11
import { z } from 'zod';
22

3-
export const ApiEnvSchema = z.object({
4-
PORT: z.coerce.number().int().default(3001),
5-
DATABASE_URL: z.string().url(),
6-
REDIS_URL: z.string().min(1),
7-
GOOGLE_OAUTH_CLIENT_ID: z.string().min(1),
8-
GOOGLE_OAUTH_CLIENT_SECRET: z.string().min(1),
9-
GOOGLE_OAUTH_REDIRECT_URL: z.string().url(),
10-
SESSION_JWT_SECRET: z.string().min(32),
11-
PUBLIC_BASE_URL: z.string().url().default('http://localhost:3001'),
12-
WEB_BASE_URL: z.string().url().default('http://localhost:3000'),
13-
PROCESSOR_ENABLED: z
14-
.union([z.literal('true'), z.literal('false')])
15-
.default('true')
16-
.transform((v) => v === 'true'),
17-
PROCESSOR_TICK_MS: z.coerce.number().int().positive().default(1000),
18-
});
3+
export const ApiEnvSchema = z
4+
.object({
5+
PORT: z.coerce.number().int().default(3001),
6+
DATABASE_URL: z.string().url(),
7+
// One of the Redis bindings must be present; validated in the refinement
8+
// below. Upstash REST takes precedence when both are set.
9+
REDIS_URL: z.string().min(1).optional(),
10+
UPSTASH_REDIS_REST_URL: z.string().url().optional(),
11+
UPSTASH_REDIS_REST_TOKEN: z.string().min(1).optional(),
12+
GOOGLE_OAUTH_CLIENT_ID: z.string().min(1),
13+
GOOGLE_OAUTH_CLIENT_SECRET: z.string().min(1),
14+
GOOGLE_OAUTH_REDIRECT_URL: z.string().url(),
15+
SESSION_JWT_SECRET: z.string().min(32),
16+
PUBLIC_BASE_URL: z.string().url().default('http://localhost:3001'),
17+
WEB_BASE_URL: z.string().url().default('http://localhost:3000'),
18+
PROCESSOR_ENABLED: z
19+
.union([z.literal('true'), z.literal('false')])
20+
.default('true')
21+
.transform((v) => v === 'true'),
22+
PROCESSOR_TICK_MS: z.coerce.number().int().positive().default(1000),
23+
})
24+
.refine(
25+
(v) =>
26+
(v.UPSTASH_REDIS_REST_URL && v.UPSTASH_REDIS_REST_TOKEN) ||
27+
(v.REDIS_URL && v.REDIS_URL.length > 0),
28+
{
29+
message:
30+
'Redis not configured — set UPSTASH_REDIS_REST_URL + UPSTASH_REDIS_REST_TOKEN, or REDIS_URL',
31+
path: ['REDIS_URL'],
32+
},
33+
);
1934

2035
export type ApiEnv = z.infer<typeof ApiEnvSchema>;
2136

apps/api/src/index.ts

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
import { createDb } from '@sheetforge/shared-db';
22
import { createLogger } from '@sheetforge/shared-logger';
3-
import { createIoredisQueueClient } from '@sheetforge/shared-redis';
3+
import {
4+
createIoredisQueueClient,
5+
createUpstashQueueClient,
6+
} from '@sheetforge/shared-redis';
47
import { createRouter } from '@sheetforge/slice-rest-api';
58
import { serve } from '@hono/node-server';
69
import { demoProcessorTick } from './demo-processor.js';
@@ -11,7 +14,26 @@ const env = loadEnv();
1114
const log = createLogger({ service: 'api' });
1215

1316
const db = createDb(env.DATABASE_URL);
14-
const redis = createIoredisQueueClient({ url: env.REDIS_URL });
17+
18+
// Prefer Upstash REST when configured — works over HTTPS so the same code
19+
// runs on CF Workers later. Fall back to ioredis for local dev without
20+
// Upstash creds.
21+
const redis =
22+
env.UPSTASH_REDIS_REST_URL && env.UPSTASH_REDIS_REST_TOKEN
23+
? createUpstashQueueClient({
24+
url: env.UPSTASH_REDIS_REST_URL,
25+
token: env.UPSTASH_REDIS_REST_TOKEN,
26+
})
27+
: createIoredisQueueClient({ url: env.REDIS_URL! });
28+
log.info(
29+
{
30+
driver:
31+
env.UPSTASH_REDIS_REST_URL && env.UPSTASH_REDIS_REST_TOKEN
32+
? 'upstash-rest'
33+
: 'ioredis',
34+
},
35+
'redis-driver-selected',
36+
);
1537

1638
const app = createRouter({
1739
db,

apps/api/src/processor.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import type { Db } from '@sheetforge/shared-db';
33
import { type AppendSafeRow, createSheetsClient } from '@sheetforge/shared-google';
44
import { createLogger } from '@sheetforge/shared-logger';
55
import { getAccessTokenForUser } from '@sheetforge/slice-auth';
6+
import { DEMO_SHEET_ID } from '@sheetforge/slice-demo';
67
import { getProjectUnscoped } from '@sheetforge/slice-projects';
78
import { getLatestSchema } from '@sheetforge/slice-schema';
89
import { listAllSheetsForProcessor } from '@sheetforge/slice-sheets';
@@ -29,6 +30,8 @@ export async function processorTick({
2930
}): Promise<void> {
3031
const sheets = await listAllSheetsForProcessor({ db });
3132
for (const sheet of sheets) {
33+
// The demo sheet is drained by demo-processor.ts with a noop sink.
34+
if (sheet.id === DEMO_SHEET_ID) continue;
3235
try {
3336
await processOneSheet({ db, redis, env, sheet });
3437
} catch (err) {
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
-- Seed synthetic user/project/sheet rows so the public hammer demo has
2+
-- something to reference via the write_ledger FK chain. Idempotent — safe to
3+
-- re-run in any environment.
4+
INSERT INTO "users" ("id", "email")
5+
VALUES ('00000000-0000-0000-0000-0000000d3001', 'demo@sheetforge.dev')
6+
ON CONFLICT ("id") DO NOTHING;
7+
--> statement-breakpoint
8+
INSERT INTO "projects" ("id", "user_id", "name")
9+
VALUES (
10+
'00000000-0000-0000-0000-0000000d3002',
11+
'00000000-0000-0000-0000-0000000d3001',
12+
'sheetforge public demo'
13+
)
14+
ON CONFLICT ("id") DO NOTHING;
15+
--> statement-breakpoint
16+
INSERT INTO "sheets" ("id", "project_id", "google_sheet_id", "tab_name")
17+
VALUES (
18+
'00000000-0000-0000-0000-0000000d3000',
19+
'00000000-0000-0000-0000-0000000d3002',
20+
'__demo__',
21+
'demo'
22+
)
23+
ON CONFLICT ("id") DO NOTHING;

shared/db/migrations/meta/_journal.json

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,13 @@
88
"when": 1776313657872,
99
"tag": "0000_unknown_spyke",
1010
"breakpoints": true
11+
},
12+
{
13+
"idx": 1,
14+
"version": "7",
15+
"when": 1776383000000,
16+
"tag": "0001_demo_seed",
17+
"breakpoints": true
1118
}
1219
]
1320
}

shared/redis/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
11
export { createIoredisQueueClient } from './ioredis-adapter.js';
2+
export { createUpstashQueueClient } from './upstash-adapter.js';
23
export type { QueueRedisClient } from '@sheetforge/queue';
Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
import type { QueueRedisClient } from '@sheetforge/queue';
2+
3+
/**
4+
* Upstash REST adapter — implements the QueueRedisClient contract via raw
5+
* fetch calls to the Upstash REST endpoint. No @upstash/redis dep so we can
6+
* stay version-proof and keep the shared/redis package light.
7+
*
8+
* Upstash REST does not support XREADGROUP BLOCK (HTTP doesn't long-poll
9+
* here), so the adapter simulates blocking semantics by sleeping for blockMs
10+
* when the stream is empty. Fine for the V0 inline processor; the long-term
11+
* story is Cloudflare Workers where short polls are idiomatic anyway.
12+
*/
13+
export function createUpstashQueueClient({
14+
url,
15+
token,
16+
}: {
17+
url: string;
18+
token: string;
19+
}): QueueRedisClient & { disconnect: () => Promise<void> } {
20+
const endpoint = url.replace(/\/+$/, '');
21+
22+
async function exec<T = unknown>(...args: (string | number)[]): Promise<T> {
23+
const res = await fetch(endpoint, {
24+
method: 'POST',
25+
headers: {
26+
Authorization: `Bearer ${token}`,
27+
'Content-Type': 'application/json',
28+
},
29+
body: JSON.stringify(args.map(String)),
30+
});
31+
if (!res.ok) {
32+
throw new Error(`Upstash HTTP ${res.status}: ${await res.text()}`);
33+
}
34+
const body = (await res.json()) as { result?: T; error?: string };
35+
if (body.error) throw new Error(`Upstash: ${body.error}`);
36+
return body.result as T;
37+
}
38+
39+
return {
40+
async xadd(key, fields) {
41+
const flat: string[] = [];
42+
for (const [k, v] of Object.entries(fields)) flat.push(k, v);
43+
const id = await exec<string>('XADD', key, '*', ...flat);
44+
return id;
45+
},
46+
47+
async xreadgroupSingle({ group, consumer, key, blockMs }) {
48+
// Upstash REST doesn't long-poll. Call once, sleep blockMs if empty.
49+
const result = (await exec(
50+
'XREADGROUP',
51+
'GROUP',
52+
group,
53+
consumer,
54+
'COUNT',
55+
'1',
56+
'STREAMS',
57+
key,
58+
'>',
59+
)) as Array<[string, Array<[string, string[]]>]> | null;
60+
61+
if (!result || result.length === 0) {
62+
if (blockMs && blockMs > 0) {
63+
await new Promise((res) => setTimeout(res, blockMs));
64+
}
65+
return null;
66+
}
67+
const stream = result[0];
68+
if (!stream) return null;
69+
const entries = stream[1];
70+
if (!entries || entries.length === 0) return null;
71+
const entry = entries[0];
72+
if (!entry) return null;
73+
const [id, flat] = entry;
74+
const out: Record<string, string> = {};
75+
for (let i = 0; i < flat.length; i += 2) {
76+
const k = flat[i];
77+
const v = flat[i + 1];
78+
if (k !== undefined && v !== undefined) out[k] = v;
79+
}
80+
return { id, fields: out };
81+
},
82+
83+
async xack(key, group, id) {
84+
await exec('XACK', key, group, id);
85+
},
86+
87+
async xgroupCreateMkstream(key, group) {
88+
try {
89+
await exec('XGROUP', 'CREATE', key, group, '$', 'MKSTREAM');
90+
} catch (err) {
91+
const msg = err instanceof Error ? err.message : String(err);
92+
if (!/BUSYGROUP/.test(msg)) throw err;
93+
}
94+
},
95+
96+
async xtrimMaxlenApprox(key, maxLen) {
97+
await exec('XTRIM', key, 'MAXLEN', '~', maxLen);
98+
},
99+
100+
async setNxPx(key, value, ttlMs) {
101+
const result = await exec<'OK' | null>(
102+
'SET',
103+
key,
104+
value,
105+
'NX',
106+
'PX',
107+
ttlMs,
108+
);
109+
return result === 'OK';
110+
},
111+
112+
async get(key) {
113+
return (await exec<string | null>('GET', key)) ?? null;
114+
},
115+
116+
async disconnect() {
117+
// REST adapter holds no long-lived connections.
118+
},
119+
};
120+
}

0 commit comments

Comments
 (0)