@devosurf/tesser-server 0.1.0-alpha.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.
Files changed (43) hide show
  1. package/LICENSE +661 -0
  2. package/README.md +18 -0
  3. package/bin/tesser-server.mjs +2 -0
  4. package/dist/main.js +6296 -0
  5. package/dist/main.js.map +7 -0
  6. package/package.json +42 -0
  7. package/src/broker/broker.ts +332 -0
  8. package/src/broker/connect.ts +224 -0
  9. package/src/broker/connections.ts +278 -0
  10. package/src/broker/crypto.ts +39 -0
  11. package/src/broker/masking.ts +32 -0
  12. package/src/broker/oauth.ts +170 -0
  13. package/src/config.ts +128 -0
  14. package/src/db/db.ts +114 -0
  15. package/src/db/migrate.ts +35 -0
  16. package/src/db/migrations.ts +302 -0
  17. package/src/engine/executor.ts +536 -0
  18. package/src/engine/runs.ts +83 -0
  19. package/src/engine/signals.ts +18 -0
  20. package/src/engine/types.ts +53 -0
  21. package/src/events/fanout.ts +73 -0
  22. package/src/gitsync/build.ts +102 -0
  23. package/src/gitsync/deploy-keys.ts +59 -0
  24. package/src/gitsync/reconciler.ts +429 -0
  25. package/src/http/api.ts +425 -0
  26. package/src/http/app.ts +33 -0
  27. package/src/http/connect-view.ts +290 -0
  28. package/src/http/connect.ts +351 -0
  29. package/src/http/ingress.ts +204 -0
  30. package/src/http/status.ts +171 -0
  31. package/src/http/tokens.ts +46 -0
  32. package/src/index.ts +20 -0
  33. package/src/main.ts +26 -0
  34. package/src/queue/queue.ts +133 -0
  35. package/src/queue/worker.ts +85 -0
  36. package/src/registry/loader.ts +41 -0
  37. package/src/scheduler/cron.ts +115 -0
  38. package/src/scheduler/reaper.ts +105 -0
  39. package/src/server.ts +162 -0
  40. package/src/triggers/ingress.ts +154 -0
  41. package/src/triggers/poll.ts +167 -0
  42. package/src/triggers/registrar.ts +274 -0
  43. package/src/triggers/shared.ts +188 -0
package/src/db/db.ts ADDED
@@ -0,0 +1,114 @@
1
+ // One thin Db seam over plain Postgres (ADR-0003): pg.Pool in production, embedded
2
+ // PGlite for `tesser dev` and tests — same SQL, same semantics (PGlite is real Postgres
3
+ // compiled to wasm; it is single-connection, so SKIP LOCKED contention is simply absent).
4
+
5
+ export interface QueryResult<R> {
6
+ rows: R[];
7
+ rowCount: number;
8
+ }
9
+
10
+ export interface DbClient {
11
+ query<R = Record<string, unknown>>(text: string, params?: unknown[]): Promise<QueryResult<R>>;
12
+ }
13
+
14
+ export interface Db extends DbClient {
15
+ readonly kind: "pg" | "pglite";
16
+ tx<T>(fn: (client: DbClient) => Promise<T>): Promise<T>;
17
+ close(): Promise<void>;
18
+ }
19
+
20
+ export interface DbConfig {
21
+ /** postgres:// URL → pg; absent → PGlite at dataDir (or in-memory). */
22
+ databaseUrl?: string | undefined;
23
+ /** PGlite data directory; "memory://" or undefined = ephemeral in-memory. */
24
+ dataDir?: string | undefined;
25
+ }
26
+
27
+ export async function createDb(config: DbConfig): Promise<Db> {
28
+ if (config.databaseUrl) {
29
+ const { default: pg } = await import("pg");
30
+ const pool = new pg.Pool({ connectionString: config.databaseUrl, max: 10 });
31
+ // Multi-statement simple-protocol queries yield an array of results — take the last.
32
+ const normalize = <R>(res: unknown): QueryResult<R> => {
33
+ const r = (Array.isArray(res) ? res[res.length - 1] : res) as {
34
+ rows?: R[];
35
+ rowCount?: number | null;
36
+ };
37
+ const rows = r?.rows ?? [];
38
+ return { rows, rowCount: r?.rowCount ?? rows.length };
39
+ };
40
+ return {
41
+ kind: "pg",
42
+ async query(text, params) {
43
+ return normalize(await pool.query(text, params as never[]));
44
+ },
45
+ async tx(fn) {
46
+ const client = await pool.connect();
47
+ try {
48
+ await client.query("BEGIN");
49
+ const result = await fn({
50
+ async query(text, params) {
51
+ return normalize(await client.query(text, params as never[]));
52
+ },
53
+ });
54
+ await client.query("COMMIT");
55
+ return result;
56
+ } catch (err) {
57
+ await client.query("ROLLBACK").catch(() => {});
58
+ throw err;
59
+ } finally {
60
+ client.release();
61
+ }
62
+ },
63
+ async close() {
64
+ await pool.end();
65
+ },
66
+ };
67
+ }
68
+
69
+ const { PGlite } = await import("@electric-sql/pglite");
70
+ let pglite: InstanceType<typeof PGlite>;
71
+ if (config.dataDir && config.dataDir !== "memory://") {
72
+ const { mkdirSync } = await import("node:fs");
73
+ mkdirSync(config.dataDir, { recursive: true });
74
+ pglite = new PGlite(config.dataDir);
75
+ } else {
76
+ pglite = new PGlite();
77
+ }
78
+ await pglite.waitReady;
79
+
80
+ // PGlite is one connection: serialize interactive transactions behind a mutex so a
81
+ // worker tx and an API tx never interleave statements.
82
+ let mutex: Promise<unknown> = Promise.resolve();
83
+ const withMutex = <T>(fn: () => Promise<T>): Promise<T> => {
84
+ const next = mutex.then(fn, fn);
85
+ mutex = next.catch(() => {});
86
+ return next;
87
+ };
88
+
89
+ return {
90
+ kind: "pglite",
91
+ async query(text, params) {
92
+ const res = await withMutex(() => pglite.query(text, params as never[]));
93
+ return { rows: res.rows as never[], rowCount: (res as { affectedRows?: number }).affectedRows ?? res.rows.length };
94
+ },
95
+ async tx(fn) {
96
+ return withMutex(() =>
97
+ pglite.transaction(async (t) => {
98
+ return fn({
99
+ async query(text, params) {
100
+ const res = await t.query(text, params as never[]);
101
+ return {
102
+ rows: res.rows as never[],
103
+ rowCount: (res as { affectedRows?: number }).affectedRows ?? res.rows.length,
104
+ };
105
+ },
106
+ });
107
+ }),
108
+ ) as Promise<never>;
109
+ },
110
+ async close() {
111
+ await pglite.close();
112
+ },
113
+ };
114
+ }
@@ -0,0 +1,35 @@
1
+ import type { Db } from "./db.js";
2
+ import { migrations } from "./migrations.js";
3
+
4
+ export async function migrate(db: Db): Promise<string[]> {
5
+ await db.query(
6
+ `CREATE TABLE IF NOT EXISTS schema_migrations (
7
+ id text PRIMARY KEY,
8
+ applied_at timestamptz NOT NULL DEFAULT now()
9
+ )`,
10
+ );
11
+ const { rows } = await db.query<{ id: string }>(`SELECT id FROM schema_migrations`);
12
+ const applied = new Set(rows.map((r) => r.id));
13
+ const ran: string[] = [];
14
+ for (const m of migrations) {
15
+ if (applied.has(m.id)) continue;
16
+ await db.tx(async (c) => {
17
+ // PGlite/pg both accept multi-statement strings via simple query protocol — but the
18
+ // pg extended protocol (with params) does not; migrations carry no params.
19
+ for (const statement of splitStatements(m.sql)) {
20
+ await c.query(statement);
21
+ }
22
+ await c.query(`INSERT INTO schema_migrations (id) VALUES ($1)`, [m.id]);
23
+ });
24
+ ran.push(m.id);
25
+ }
26
+ return ran;
27
+ }
28
+
29
+ /** Split on top-level semicolons (none of our DDL contains procedural bodies). */
30
+ function splitStatements(sql: string): string[] {
31
+ return sql
32
+ .split(/;\s*(?=\n|$)/)
33
+ .map((s) => s.trim())
34
+ .filter((s) => s.length > 0 && !s.startsWith("--"));
35
+ }
@@ -0,0 +1,302 @@
1
+ // Schema migrations — applied in order, recorded in schema_migrations. Postgres is the
2
+ // single stateful dependency (ADR-0003): metadata, queue, journal, encrypted secrets.
3
+
4
+ export interface Migration {
5
+ id: string;
6
+ sql: string;
7
+ }
8
+
9
+ export const migrations: Migration[] = [
10
+ {
11
+ id: "0001-core",
12
+ sql: /* sql */ `
13
+ CREATE TABLE workspaces (
14
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
15
+ name text NOT NULL,
16
+ data_key_cipher text NOT NULL,
17
+ created_at timestamptz NOT NULL DEFAULT now()
18
+ );
19
+
20
+ CREATE TABLE api_tokens (
21
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
22
+ workspace_id uuid NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
23
+ name text NOT NULL,
24
+ token_hash text NOT NULL UNIQUE,
25
+ created_at timestamptz NOT NULL DEFAULT now(),
26
+ last_used_at timestamptz
27
+ );
28
+
29
+ CREATE TABLE projects (
30
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
31
+ workspace_id uuid NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
32
+ name text NOT NULL,
33
+ repo_url text,
34
+ deploy_key_public text,
35
+ deploy_key_private_cipher text,
36
+ prod_branch text NOT NULL DEFAULT 'main',
37
+ push_webhook_secret text,
38
+ poll_interval_s integer,
39
+ created_at timestamptz NOT NULL DEFAULT now(),
40
+ UNIQUE (workspace_id, name)
41
+ );
42
+
43
+ CREATE TABLE repo_state (
44
+ project_id uuid PRIMARY KEY REFERENCES projects(id) ON DELETE CASCADE,
45
+ branch text NOT NULL DEFAULT 'main',
46
+ last_sha text,
47
+ status text NOT NULL DEFAULT 'idle', -- idle|syncing|synced|halted-credentials|failed
48
+ error text,
49
+ report jsonb,
50
+ last_synced_at timestamptz
51
+ );
52
+
53
+ CREATE TABLE automation_versions (
54
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
55
+ project_id uuid NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
56
+ automation_id text NOT NULL,
57
+ version integer NOT NULL,
58
+ content_hash text NOT NULL,
59
+ bundle_path text NOT NULL,
60
+ manifest jsonb NOT NULL,
61
+ git_sha text,
62
+ branch text NOT NULL DEFAULT 'main',
63
+ status text NOT NULL DEFAULT 'staged', -- staged|live|superseded|failed
64
+ test_report jsonb,
65
+ created_at timestamptz NOT NULL DEFAULT now(),
66
+ UNIQUE (project_id, automation_id, version)
67
+ );
68
+
69
+ CREATE TABLE aliases (
70
+ project_id uuid NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
71
+ automation_id text NOT NULL,
72
+ env text NOT NULL, -- 'production' | 'preview:<branch>'
73
+ version_id uuid NOT NULL REFERENCES automation_versions(id),
74
+ updated_at timestamptz NOT NULL DEFAULT now(),
75
+ PRIMARY KEY (project_id, automation_id, env)
76
+ );
77
+
78
+ CREATE TABLE runs (
79
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
80
+ project_id uuid NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
81
+ automation_id text NOT NULL,
82
+ version_id uuid REFERENCES automation_versions(id) ON DELETE SET NULL,
83
+ env text NOT NULL DEFAULT 'production',
84
+ status text NOT NULL DEFAULT 'queued', -- queued|running|suspended|completed|failed|cancelled
85
+ trigger jsonb NOT NULL,
86
+ input jsonb,
87
+ output jsonb,
88
+ error jsonb,
89
+ attempt integer NOT NULL DEFAULT 0,
90
+ concurrency_key text,
91
+ wake_at timestamptz,
92
+ waiting_signal text,
93
+ created_at timestamptz NOT NULL DEFAULT now(),
94
+ started_at timestamptz,
95
+ finished_at timestamptz
96
+ );
97
+ CREATE INDEX runs_by_automation ON runs (project_id, automation_id, created_at DESC);
98
+ CREATE INDEX runs_by_status ON runs (status) WHERE status IN ('queued','running','suspended');
99
+ CREATE INDEX runs_concurrency ON runs (project_id, automation_id, concurrency_key)
100
+ WHERE status IN ('queued','running','suspended');
101
+
102
+ CREATE TABLE run_steps (
103
+ run_id uuid NOT NULL REFERENCES runs(id) ON DELETE CASCADE,
104
+ name text NOT NULL,
105
+ occurrence integer NOT NULL,
106
+ status text NOT NULL, -- running|completed|failed
107
+ attempts integer NOT NULL DEFAULT 0,
108
+ result jsonb,
109
+ error jsonb,
110
+ has_undo boolean NOT NULL DEFAULT false,
111
+ undone boolean NOT NULL DEFAULT false,
112
+ started_at timestamptz NOT NULL DEFAULT now(),
113
+ finished_at timestamptz,
114
+ PRIMARY KEY (run_id, name, occurrence)
115
+ );
116
+
117
+ CREATE TABLE queue_jobs (
118
+ id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
119
+ kind text NOT NULL,
120
+ payload jsonb NOT NULL,
121
+ status text NOT NULL DEFAULT 'ready', -- ready|dead
122
+ run_at timestamptz NOT NULL DEFAULT now(),
123
+ priority integer NOT NULL DEFAULT 0,
124
+ lease_until timestamptz,
125
+ attempts integer NOT NULL DEFAULT 0,
126
+ max_attempts integer NOT NULL DEFAULT 10,
127
+ tag text,
128
+ dedupe_key text,
129
+ last_error text,
130
+ created_at timestamptz NOT NULL DEFAULT now()
131
+ );
132
+ CREATE INDEX queue_ready ON queue_jobs (run_at, priority DESC) WHERE status = 'ready';
133
+ CREATE UNIQUE INDEX queue_dedupe ON queue_jobs (dedupe_key) WHERE dedupe_key IS NOT NULL;
134
+
135
+ CREATE TABLE schedules (
136
+ project_id uuid NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
137
+ automation_id text NOT NULL,
138
+ env text NOT NULL,
139
+ cron text NOT NULL,
140
+ tz text,
141
+ enabled boolean NOT NULL DEFAULT true,
142
+ next_fire timestamptz,
143
+ PRIMARY KEY (project_id, automation_id, env)
144
+ );
145
+
146
+ CREATE TABLE events (
147
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
148
+ project_id uuid NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
149
+ env text NOT NULL DEFAULT 'production',
150
+ name text NOT NULL,
151
+ payload jsonb,
152
+ emitted_by_run uuid,
153
+ created_at timestamptz NOT NULL DEFAULT now()
154
+ );
155
+
156
+ CREATE TABLE event_subscriptions (
157
+ project_id uuid NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
158
+ env text NOT NULL,
159
+ event_name text NOT NULL,
160
+ automation_id text NOT NULL,
161
+ PRIMARY KEY (project_id, env, event_name, automation_id)
162
+ );
163
+
164
+ CREATE TABLE signals (
165
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
166
+ run_id uuid NOT NULL REFERENCES runs(id) ON DELETE CASCADE,
167
+ name text NOT NULL,
168
+ payload jsonb,
169
+ consumed boolean NOT NULL DEFAULT false,
170
+ created_at timestamptz NOT NULL DEFAULT now()
171
+ );
172
+ CREATE INDEX signals_by_run ON signals (run_id, name) WHERE NOT consumed;
173
+
174
+ CREATE TABLE connections (
175
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
176
+ workspace_id uuid NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
177
+ connector_id text NOT NULL,
178
+ provider text,
179
+ auth_mode text NOT NULL DEFAULT 'default',
180
+ scope text NOT NULL DEFAULT 'workspace', -- workspace|per_user
181
+ end_user_id text,
182
+ label text,
183
+ credential_cipher text,
184
+ credential_meta jsonb NOT NULL DEFAULT '{}',
185
+ status text NOT NULL DEFAULT 'pending', -- pending|ready|error
186
+ error text,
187
+ created_at timestamptz NOT NULL DEFAULT now(),
188
+ updated_at timestamptz NOT NULL DEFAULT now()
189
+ );
190
+ CREATE INDEX connections_lookup ON connections (workspace_id, connector_id, status);
191
+
192
+ CREATE TABLE requirement_bindings (
193
+ project_id uuid NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
194
+ automation_id text NOT NULL,
195
+ env text NOT NULL,
196
+ req_key text NOT NULL,
197
+ connection_id uuid NOT NULL REFERENCES connections(id) ON DELETE CASCADE,
198
+ PRIMARY KEY (project_id, automation_id, env, req_key)
199
+ );
200
+
201
+ CREATE TABLE secrets (
202
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
203
+ workspace_id uuid NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
204
+ name text NOT NULL,
205
+ value_cipher text NOT NULL,
206
+ created_at timestamptz NOT NULL DEFAULT now(),
207
+ updated_at timestamptz NOT NULL DEFAULT now(),
208
+ UNIQUE (workspace_id, name)
209
+ );
210
+
211
+ CREATE TABLE connect_links (
212
+ token text PRIMARY KEY,
213
+ workspace_id uuid NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
214
+ project_id uuid REFERENCES projects(id) ON DELETE CASCADE,
215
+ requirements jsonb NOT NULL,
216
+ status text NOT NULL DEFAULT 'pending', -- pending|completed|expired
217
+ expires_at timestamptz NOT NULL,
218
+ created_at timestamptz NOT NULL DEFAULT now(),
219
+ completed_at timestamptz
220
+ );
221
+
222
+ CREATE TABLE oauth_apps (
223
+ workspace_id uuid NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
224
+ provider text NOT NULL,
225
+ client_id text NOT NULL,
226
+ client_secret_cipher text NOT NULL,
227
+ PRIMARY KEY (workspace_id, provider)
228
+ );
229
+
230
+ CREATE TABLE oauth_states (
231
+ state text PRIMARY KEY,
232
+ connect_token text REFERENCES connect_links(token) ON DELETE CASCADE,
233
+ connection_id uuid REFERENCES connections(id) ON DELETE CASCADE,
234
+ provider text NOT NULL,
235
+ code_verifier text,
236
+ redirect_uri text NOT NULL,
237
+ created_at timestamptz NOT NULL DEFAULT now(),
238
+ expires_at timestamptz NOT NULL
239
+ );
240
+
241
+ CREATE TABLE webhook_registrations (
242
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
243
+ project_id uuid NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
244
+ automation_id text NOT NULL,
245
+ trigger_id text NOT NULL,
246
+ connector_id text NOT NULL,
247
+ connection_id uuid REFERENCES connections(id) ON DELETE SET NULL,
248
+ env text NOT NULL,
249
+ config_hash text NOT NULL,
250
+ ingress_token text NOT NULL UNIQUE,
251
+ signing_secret_cipher text,
252
+ external_id text,
253
+ state jsonb,
254
+ status text NOT NULL DEFAULT 'pending', -- pending|registered|manual-pending|failed|removed
255
+ error text,
256
+ created_at timestamptz NOT NULL DEFAULT now(),
257
+ updated_at timestamptz NOT NULL DEFAULT now(),
258
+ UNIQUE (project_id, automation_id, trigger_id, connection_id, env)
259
+ );
260
+
261
+ CREATE TABLE trigger_state (
262
+ project_id uuid NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
263
+ automation_id text NOT NULL,
264
+ trigger_id text NOT NULL,
265
+ connection_id uuid NOT NULL,
266
+ kind text NOT NULL, -- poll|webhook
267
+ seen jsonb NOT NULL DEFAULT '[]',
268
+ cursor jsonb,
269
+ seeded boolean NOT NULL DEFAULT false,
270
+ last_poll_at timestamptz,
271
+ last_delivery_at timestamptz,
272
+ PRIMARY KEY (project_id, automation_id, trigger_id, connection_id)
273
+ );
274
+
275
+ CREATE TABLE run_logs (
276
+ id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
277
+ run_id uuid NOT NULL REFERENCES runs(id) ON DELETE CASCADE,
278
+ step text,
279
+ level text NOT NULL,
280
+ msg text NOT NULL,
281
+ meta jsonb,
282
+ created_at timestamptz NOT NULL DEFAULT now()
283
+ );
284
+ CREATE INDEX run_logs_by_run ON run_logs (run_id, id);
285
+ `,
286
+ },
287
+ {
288
+ id: "0002-project-webhook-bootstrap",
289
+ sql: /* sql */ `
290
+ ALTER TABLE projects ADD COLUMN IF NOT EXISTS push_webhook_secret_cipher text;
291
+ ALTER TABLE projects ADD COLUMN IF NOT EXISTS push_webhook_setup_token_hash text;
292
+ `,
293
+ },
294
+ {
295
+ id: "0003-run-version-delete-policy",
296
+ sql: /* sql */ `
297
+ ALTER TABLE runs DROP CONSTRAINT IF EXISTS runs_version_id_fkey;
298
+ ALTER TABLE runs ADD CONSTRAINT runs_version_id_fkey
299
+ FOREIGN KEY (version_id) REFERENCES automation_versions(id) ON DELETE SET NULL;
300
+ `,
301
+ },
302
+ ];