@preventive/triage 1.0.0-alpha.0 → 1.0.0-alpha.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/out/client-sync.js +14 -10
- package/out/graph.js +4 -4
- package/out/index.html +42 -38
- package/out/view.css +1 -1
- package/out/view.js +50 -47
- package/package.json +15 -3
- package/server/auth.ts +11 -0
- package/server/bus-receiver.ts +95 -0
- package/server/cli.js +22 -0
- package/server/db-neon.ts +39 -23
- package/server/db-revision-sql.ts +9 -0
- package/server/db.ts +18 -1
- package/server/http.ts +41 -5
- package/server/hub.ts +22 -2
- package/server/index.ts +150 -22
- package/server/lifecycle.ts +29 -2
- package/server/npm-proxy.ts +348 -0
- package/server/objstore/blob-vercel.ts +23 -2
- package/server/objstore/handlers.ts +12 -0
- package/server/objstore/init.ts +7 -1
- package/server/objstore/rest.ts +18 -0
- package/server/objstore/store-neon.ts +12 -12
- package/server/pubsub.ts +404 -0
- package/server/sse-server.ts +352 -0
- package/server/sse-session.ts +202 -0
- package/server/sync-handlers.ts +17 -1
- package/server/ws-server.ts +199 -174
- package/strip-types-loader.js +94 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@preventive/triage",
|
|
3
|
-
"version": "1.0.0-alpha.
|
|
3
|
+
"version": "1.0.0-alpha.2",
|
|
4
4
|
"description": "Client & relay server for triaging of automated reports",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": {
|
|
@@ -28,11 +28,18 @@
|
|
|
28
28
|
"#client/sync.js": "./client/sync.js",
|
|
29
29
|
"#client/sync-host.js": "./client/sync-host.js"
|
|
30
30
|
},
|
|
31
|
+
"exports": {
|
|
32
|
+
"./server": "./server/index.ts",
|
|
33
|
+
"./strip-types-loader": "./strip-types-loader.js",
|
|
34
|
+
"./package.json": "./package.json"
|
|
35
|
+
},
|
|
31
36
|
"bin": {
|
|
32
|
-
"triage-server": "server/
|
|
37
|
+
"triage-server": "server/cli.js"
|
|
33
38
|
},
|
|
34
39
|
"files": [
|
|
35
40
|
"server/auth.ts",
|
|
41
|
+
"server/bus-receiver.ts",
|
|
42
|
+
"server/cli.js",
|
|
36
43
|
"server/config.ts",
|
|
37
44
|
"server/db-neon.ts",
|
|
38
45
|
"server/db-revision-sql.ts",
|
|
@@ -43,6 +50,7 @@
|
|
|
43
50
|
"server/index.ts",
|
|
44
51
|
"server/lifecycle.ts",
|
|
45
52
|
"server/neon-driver.ts",
|
|
53
|
+
"server/npm-proxy.ts",
|
|
46
54
|
"server/objstore/blob-fs.ts",
|
|
47
55
|
"server/objstore/blob-vercel.ts",
|
|
48
56
|
"server/objstore/blob.ts",
|
|
@@ -57,7 +65,10 @@
|
|
|
57
65
|
"server/objstore/tokens.ts",
|
|
58
66
|
"server/origin.ts",
|
|
59
67
|
"server/peer.ts",
|
|
68
|
+
"server/pubsub.ts",
|
|
60
69
|
"server/sign.ts",
|
|
70
|
+
"server/sse-server.ts",
|
|
71
|
+
"server/sse-session.ts",
|
|
61
72
|
"server/static.ts",
|
|
62
73
|
"server/sync-handlers.ts",
|
|
63
74
|
"server/util.ts",
|
|
@@ -78,7 +89,8 @@
|
|
|
78
89
|
"out/terminal.js",
|
|
79
90
|
"out/view.css",
|
|
80
91
|
"out/view.html",
|
|
81
|
-
"out/view.js"
|
|
92
|
+
"out/view.js",
|
|
93
|
+
"strip-types-loader.js"
|
|
82
94
|
],
|
|
83
95
|
"scripts": {
|
|
84
96
|
"lint": "node --run lint:js && node --run lint:css && node --run lint:ts",
|
package/server/auth.ts
CHANGED
|
@@ -76,6 +76,17 @@ export function createAuth(deps: {
|
|
|
76
76
|
}
|
|
77
77
|
function handleAuthenticate(socket: WebSocket, msg: AuthenticateMsg): void {
|
|
78
78
|
if (typeof msg.password !== 'string' || msg.password.length === 0 || msg.password.length > MAX_AUTH_PASSWORD_LEN) return
|
|
79
|
+
// Already-authorized short-circuit. The SSE plane carries
|
|
80
|
+
// `body.password` on every POST (sse-server.ts:dispatchBody
|
|
81
|
+
// synthesises a fresh `authenticate` frame each time), so re-
|
|
82
|
+
// emitting `authenticated` per POST would chatter the wire AND
|
|
83
|
+
// re-fire triage-sync's `authenticated` consumer hook (its "kick
|
|
84
|
+
// deferred sends" path) on every flush. The wire signal is needed
|
|
85
|
+
// exactly once per `unauthorized → authorized` transition; after
|
|
86
|
+
// that, the client already knows. WS plane unaffected (sends
|
|
87
|
+
// authenticate at most once per socket per the runAuthFlow guard
|
|
88
|
+
// in client/sync/socket-transport.ts).
|
|
89
|
+
if (isAuthorized(socket)) return
|
|
79
90
|
// No-config short-circuit: when not gating, treat any authenticate
|
|
80
91
|
// as success so a client can cache + replay its password on
|
|
81
92
|
// reconnect even against an un-gated server (wire shape stays
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
// Cross-instance bus receiver: maps a `BusMessage` envelope (lookup
|
|
2
|
+
// hint + sender from the Postgres LISTEN/NOTIFY channel) into a local
|
|
3
|
+
// wire broadcast. Pulled out of `server/index.ts` so the rev / objput /
|
|
4
|
+
// objdel branches — including the keyframe boolean coercion and the
|
|
5
|
+
// `objectMetaWire` shape — are testable without bringing up the whole
|
|
6
|
+
// server.
|
|
7
|
+
//
|
|
8
|
+
// The receiver mirrors the publisher's commit→broadcast→publish
|
|
9
|
+
// ordering, just shifted by the bus round-trip:
|
|
10
|
+
// 1. NOTIFY lands → bus envelope arrives here.
|
|
11
|
+
// 2. For hint-only kinds (rev, objput) re-fetch the row from the
|
|
12
|
+
// shared DB; for self-contained kinds (objdel) skip the fetch.
|
|
13
|
+
// 3. Broadcast to LOCAL subscribers via `broadcastLocalRaw` with no
|
|
14
|
+
// `except` — the originator is on a DIFFERENT instance by
|
|
15
|
+
// construction, so it must NOT be filtered out (the symmetric
|
|
16
|
+
// same-instance broadcast already happened on the publisher).
|
|
17
|
+
|
|
18
|
+
import type { Handle as DbHandle } from './db.ts'
|
|
19
|
+
import { type Handle as ObjstoreHandle, getLive, objectMetaWire } from './objstore/store.ts'
|
|
20
|
+
import type { BusMessage } from './pubsub.ts'
|
|
21
|
+
|
|
22
|
+
export type BusReceiverDeps = {
|
|
23
|
+
// Read-side of the workspace_revision Handle (the bus uses only
|
|
24
|
+
// `revisionById`; the publisher path uses the full Handle). Typed
|
|
25
|
+
// as the broader Handle for ease of wiring at the call site.
|
|
26
|
+
handle: DbHandle
|
|
27
|
+
// Read-side of the objstore Handle (used for `getLive` on objput).
|
|
28
|
+
objstoreHandle: ObjstoreHandle
|
|
29
|
+
// Local-only fan-out into this instance's subscriber map. The
|
|
30
|
+
// payload is pre-serialised so the hub can stringify once for N
|
|
31
|
+
// subscribers (mirrors the existing `broadcast` fast path).
|
|
32
|
+
broadcastLocalRaw: (tag: string, payload: string) => void
|
|
33
|
+
debug: boolean
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function createBusReceiver(deps: BusReceiverDeps): (msg: BusMessage) => Promise<void> {
|
|
37
|
+
const { handle, objstoreHandle, broadcastLocalRaw, debug } = deps
|
|
38
|
+
return async (msg) => {
|
|
39
|
+
if (msg.kind === 'rev') {
|
|
40
|
+
const row = await handle.revisionById.get(msg.tag, msg.id)
|
|
41
|
+
if (!row) {
|
|
42
|
+
// Not found is expected when a peer instance published a
|
|
43
|
+
// revision id that hasn't replicated to this instance yet —
|
|
44
|
+
// shouldn't happen on a single-primary Neon endpoint, but a
|
|
45
|
+
// future read-replica deployment could see it transiently.
|
|
46
|
+
// Drop the broadcast; the client will reconcile via chain
|
|
47
|
+
// re-pull on its next subscribe.
|
|
48
|
+
if (debug) console.warn(`pubsub: revision ${msg.id.slice(0, 8)}… not found for ${msg.tag.slice(0, 12)}…`)
|
|
49
|
+
return
|
|
50
|
+
}
|
|
51
|
+
broadcastLocalRaw(msg.tag, JSON.stringify({
|
|
52
|
+
type: 'workspace-state',
|
|
53
|
+
workspaceTag: msg.tag,
|
|
54
|
+
// Mirror `chainForWire`'s strict-boolean coercion in
|
|
55
|
+
// ./sync-handlers.ts — the DB stores `keyframe` as an integer
|
|
56
|
+
// (0/1) but the wire contract uses strict `=== true`.
|
|
57
|
+
revisions: [{ ...row, keyframe: row.keyframe === 1 }],
|
|
58
|
+
}))
|
|
59
|
+
return
|
|
60
|
+
}
|
|
61
|
+
if (msg.kind === 'objput') {
|
|
62
|
+
const row = await getLive(objstoreHandle, msg.tag, msg.res)
|
|
63
|
+
if (!row) {
|
|
64
|
+
// Possible after a remote put → remote delete sequence where
|
|
65
|
+
// both NOTIFYs landed by the time we processed the put. The
|
|
66
|
+
// subsequent objdel NOTIFY will (or has) drive the right
|
|
67
|
+
// broadcast — skip silently. Also possible under a future
|
|
68
|
+
// read-replica deployment with replication lag.
|
|
69
|
+
if (debug) console.warn(`pubsub: objput ${msg.res.slice(0, 8)}… missing for ${msg.tag.slice(0, 12)}…`)
|
|
70
|
+
return
|
|
71
|
+
}
|
|
72
|
+
// Note: `getLive` returns the CURRENT live row, which may be a
|
|
73
|
+
// strictly NEWER version than the NOTIFY referred to (two
|
|
74
|
+
// closely-spaced puts on one resourceTag arrive at the receiver
|
|
75
|
+
// with the DB already showing v2 for both). Broadcasting v2 twice
|
|
76
|
+
// is sound — client `putHandlers` already absorb same-instance
|
|
77
|
+
// PUT echoes (rest.ts uses `except: null`), so handlers must be
|
|
78
|
+
// idempotent on (resourceTag, version) anyway.
|
|
79
|
+
broadcastLocalRaw(msg.tag, JSON.stringify({
|
|
80
|
+
type: 'objstore-put',
|
|
81
|
+
workspaceTag: msg.tag,
|
|
82
|
+
...objectMetaWire(row),
|
|
83
|
+
}))
|
|
84
|
+
return
|
|
85
|
+
}
|
|
86
|
+
// 'objdel' — the row is gone from workspace_object post-commit, so
|
|
87
|
+
// the bus payload itself IS the wire data; no DB lookup.
|
|
88
|
+
broadcastLocalRaw(msg.tag, JSON.stringify({
|
|
89
|
+
type: 'objstore-deleted',
|
|
90
|
+
workspaceTag: msg.tag,
|
|
91
|
+
resourceTag: msg.res,
|
|
92
|
+
version: msg.ver,
|
|
93
|
+
}))
|
|
94
|
+
}
|
|
95
|
+
}
|
package/server/cli.js
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Executable entry for the `triage-server` bin.
|
|
3
|
+
//
|
|
4
|
+
// Node refuses to strip TypeScript types from files under node_modules
|
|
5
|
+
// (ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING), so once this package is
|
|
6
|
+
// installed as a dependency, server/index.ts can't be executed directly.
|
|
7
|
+
// This launcher registers the built-in-type-stripping hook FIRST
|
|
8
|
+
// (synchronous, in-thread — see ../strip-types-loader.js), then loads the
|
|
9
|
+
// server with the hook active and starts it.
|
|
10
|
+
//
|
|
11
|
+
// The `await import()` is load-bearing: a static `import './index.ts'`
|
|
12
|
+
// would be fetched during this module's instantiation — before the body
|
|
13
|
+
// runs and before the hook is registered — and would hit the node_modules
|
|
14
|
+
// strip error. Deferring to a dynamic import runs it only after the static
|
|
15
|
+
// `../strip-types-loader.js` import has evaluated and registered the hook.
|
|
16
|
+
//
|
|
17
|
+
// Because the server is imported here (not the process entry), its
|
|
18
|
+
// `import.meta.main` auto-start gate stays off, so we call the exported
|
|
19
|
+
// `start()` ourselves.
|
|
20
|
+
import '../strip-types-loader.js'
|
|
21
|
+
const { start } = await import('./index.ts')
|
|
22
|
+
start()
|
package/server/db-neon.ts
CHANGED
|
@@ -10,9 +10,14 @@
|
|
|
10
10
|
// (`autoInstallPeers: false` in `pnpm-workspace.yaml`) and never
|
|
11
11
|
// reaches the import.
|
|
12
12
|
//
|
|
13
|
-
// API shape: `neon(connectionString)` returns a tagged-template
|
|
14
|
-
// callable
|
|
15
|
-
// `sql(text, params)
|
|
13
|
+
// API shape: `neon(connectionString)` returns a tagged-template
|
|
14
|
+
// callable with a `.query(text, params)` value-placeholder method.
|
|
15
|
+
// We use the `sql.query(text, params)` form everywhere — our queries
|
|
16
|
+
// are dynamically composed strings + parameter arrays from
|
|
17
|
+
// `./db-revision-sql.ts`, so the tagged-template form doesn't fit.
|
|
18
|
+
// The function-call form (`sql(text, params)`) that the 0.x driver
|
|
19
|
+
// accepted was removed in `@neondatabase/serverless@1.0.0`; the peer
|
|
20
|
+
// dep declares `^1.0.2`. `tryCommitNeon` (below) folds the dup-check
|
|
16
21
|
// + head-check + gated INSERT into a single pipelined
|
|
17
22
|
// `sql.transaction([...])` — NO commit-time advisory lock. The gated
|
|
18
23
|
// INSERT's head-check and `MAX(seq)` run inside one Postgres
|
|
@@ -46,7 +51,8 @@ import type { AllStmt, GetStmt, RunStmt } from './db-stmt.ts'
|
|
|
46
51
|
import { type CommitResult, type Handle, type RevisionInsert, type RevisionRow, isUniqueViolation } from './db.ts'
|
|
47
52
|
import {
|
|
48
53
|
CHAIN_AFTER_SQL, CHAIN_ALL_SQL, CHAIN_FROM_SQL, GATED_INSERT_SQL_PG, HEAD_FOR_SQL,
|
|
49
|
-
LAST_KEYFRAME_SEQ_SQL, REVISION_EXISTS_SQL, SEQ_OF_ID_SQL,
|
|
54
|
+
LAST_KEYFRAME_SEQ_SQL, REVISION_BY_ID_SQL, REVISION_EXISTS_SQL, SEQ_OF_ID_SQL,
|
|
55
|
+
mapRevisionRow, num, numOrNull,
|
|
50
56
|
} from './db-revision-sql.ts'
|
|
51
57
|
|
|
52
58
|
// `num` / `numOrNull` (safe-integer BIGINT coercion) live in the shared
|
|
@@ -70,15 +76,16 @@ export { num, numOrNull }
|
|
|
70
76
|
// side. Used by the DDL bootstrap below to prevent partial schema
|
|
71
77
|
// creation on a transient mid-batch network failure.
|
|
72
78
|
//
|
|
73
|
-
// `transaction` is typed against the query promise
|
|
79
|
+
// `transaction` is typed against the query promise `sql.query`
|
|
74
80
|
// returns (NOT plain `Promise<unknown>`) so a misuse like
|
|
75
81
|
// `sql.transaction([Promise.resolve(123)])` fails at compile time
|
|
76
82
|
// rather than failing opaquely inside the driver. The driver itself
|
|
77
83
|
// inspects each promise's internal shape and rejects non-query
|
|
78
84
|
// promises at runtime; the type narrows that to a static error.
|
|
79
|
-
type NeonQueryPromise = ReturnType<
|
|
80
|
-
type
|
|
81
|
-
export type NeonSql =
|
|
85
|
+
type NeonQueryPromise = ReturnType<NeonQueryCall>
|
|
86
|
+
type NeonQueryCall = (queryText: string, params?: readonly unknown[]) => Promise<unknown[]>
|
|
87
|
+
export type NeonSql = {
|
|
88
|
+
query: NeonQueryCall
|
|
82
89
|
transaction: (queries: NeonQueryPromise[]) => Promise<unknown[][]>
|
|
83
90
|
}
|
|
84
91
|
|
|
@@ -103,7 +110,7 @@ const DDL_LOCK_KEY_REVISION_SUB = 0x7273_6e72 // 'rsnr'
|
|
|
103
110
|
const DURABLE_SYNC_COMMIT_LEVELS = new Set(['local', 'on', 'remote_write', 'remote_apply'])
|
|
104
111
|
|
|
105
112
|
export async function assertDurableSyncCommit(sql: NeonSql): Promise<void> {
|
|
106
|
-
const rows = await sql(`SHOW synchronous_commit`, []) as Array<{ synchronous_commit?: unknown }>
|
|
113
|
+
const rows = await sql.query(`SHOW synchronous_commit`, []) as Array<{ synchronous_commit?: unknown }>
|
|
107
114
|
const level = String(rows[0]?.synchronous_commit ?? '').trim().toLowerCase()
|
|
108
115
|
if (!DURABLE_SYNC_COMMIT_LEVELS.has(level)) {
|
|
109
116
|
throw new Error(
|
|
@@ -156,7 +163,7 @@ const SCHEMA_PG = [
|
|
|
156
163
|
|
|
157
164
|
// Generic statement-builder helpers shared by both Neon planes
|
|
158
165
|
// (workspace_revision here + the objstore tables in store-neon.ts).
|
|
159
|
-
// They remove the repeated `{ run/get: async (...args) => sql(...) }`
|
|
166
|
+
// They remove the repeated `{ run/get: async (...args) => sql.query(...) }`
|
|
160
167
|
// wrapper for the statements whose positional args map straight to
|
|
161
168
|
// the query's `$1..$N` placeholders and whose rows need no coercion.
|
|
162
169
|
// Statements that coerce BIGINT (via `num`) or map snake_case rows
|
|
@@ -165,14 +172,14 @@ const SCHEMA_PG = [
|
|
|
165
172
|
|
|
166
173
|
// Trivial passthrough write: args → $1..$N in order, no result shape.
|
|
167
174
|
export function runStmt<P extends unknown[]>(sql: NeonSql, query: string): RunStmt<P> {
|
|
168
|
-
return { run: async (...args: P) => { await sql(query, args as readonly unknown[]) } }
|
|
175
|
+
return { run: async (...args: P) => { await sql.query(query, args as readonly unknown[]) } }
|
|
169
176
|
}
|
|
170
177
|
|
|
171
178
|
// First-row read with no coercion (callers needing BIGINT→number or
|
|
172
179
|
// snake_case mapping build their own). Returns `undefined` on the
|
|
173
180
|
// empty result set, matching the SQLite `wrapGet` contract.
|
|
174
181
|
export function getRowStmt<P extends unknown[], T>(sql: NeonSql, query: string): GetStmt<P, T> {
|
|
175
|
-
return { get: async (...args: P) => (await sql(query, args as readonly unknown[]) as T[])[0] }
|
|
182
|
+
return { get: async (...args: P) => (await sql.query(query, args as readonly unknown[]) as T[])[0] }
|
|
176
183
|
}
|
|
177
184
|
|
|
178
185
|
// Per-statement builders — extracted so `openNeonDb` stays small
|
|
@@ -184,7 +191,7 @@ function buildHeadFor(sql: NeonSql): GetStmt<[string], { id: string }> {
|
|
|
184
191
|
|
|
185
192
|
function buildSeqOfId(sql: NeonSql): GetStmt<[string, string], { seq: number }> {
|
|
186
193
|
return { get: async (tag, id) => {
|
|
187
|
-
const rows = await sql(SEQ_OF_ID_SQL, [tag, id]) as Array<{ seq: number | string }>
|
|
194
|
+
const rows = await sql.query(SEQ_OF_ID_SQL, [tag, id]) as Array<{ seq: number | string }>
|
|
188
195
|
const r = rows[0]
|
|
189
196
|
if (!r) return undefined
|
|
190
197
|
const n = numOrNull(r.seq)
|
|
@@ -194,7 +201,7 @@ function buildSeqOfId(sql: NeonSql): GetStmt<[string, string], { seq: number }>
|
|
|
194
201
|
|
|
195
202
|
function buildLastKeyframeSeq(sql: NeonSql): GetStmt<[string], { s: number | null }> {
|
|
196
203
|
return { get: async (tag) => {
|
|
197
|
-
const rows = await sql(LAST_KEYFRAME_SEQ_SQL, [tag]) as Array<{ s: number | string | null }>
|
|
204
|
+
const rows = await sql.query(LAST_KEYFRAME_SEQ_SQL, [tag]) as Array<{ s: number | string | null }>
|
|
198
205
|
const r = rows[0]
|
|
199
206
|
return r ? { s: numOrNull(r.s) } : undefined
|
|
200
207
|
} }
|
|
@@ -202,14 +209,14 @@ function buildLastKeyframeSeq(sql: NeonSql): GetStmt<[string], { s: number | nul
|
|
|
202
209
|
|
|
203
210
|
function buildChain(sql: NeonSql, query: string): AllStmt<[string], RevisionRow> {
|
|
204
211
|
return { all: async (tag) => {
|
|
205
|
-
const rows = await sql(query, [tag]) as Array<Record<string, unknown>>
|
|
212
|
+
const rows = await sql.query(query, [tag]) as Array<Record<string, unknown>>
|
|
206
213
|
return rows.map(mapRevisionRow)
|
|
207
214
|
} }
|
|
208
215
|
}
|
|
209
216
|
|
|
210
217
|
function buildChainSeq(sql: NeonSql, query: string): AllStmt<[string, number], RevisionRow> {
|
|
211
218
|
return { all: async (tag, seq) => {
|
|
212
|
-
const rows = await sql(query, [tag, seq]) as Array<Record<string, unknown>>
|
|
219
|
+
const rows = await sql.query(query, [tag, seq]) as Array<Record<string, unknown>>
|
|
213
220
|
return rows.map(mapRevisionRow)
|
|
214
221
|
} }
|
|
215
222
|
}
|
|
@@ -218,6 +225,14 @@ function buildRevisionExists(sql: NeonSql): GetStmt<[string, string], unknown> {
|
|
|
218
225
|
return getRowStmt(sql, REVISION_EXISTS_SQL)
|
|
219
226
|
}
|
|
220
227
|
|
|
228
|
+
function buildRevisionById(sql: NeonSql): GetStmt<[string, string], RevisionRow> {
|
|
229
|
+
return { get: async (tag, id) => {
|
|
230
|
+
const rows = await sql.query(REVISION_BY_ID_SQL, [tag, id]) as Array<Record<string, unknown>>
|
|
231
|
+
const r = rows[0]
|
|
232
|
+
return r ? mapRevisionRow(r) : undefined
|
|
233
|
+
} }
|
|
234
|
+
}
|
|
235
|
+
|
|
221
236
|
// Atomic commit of a single revision (Neon backend). Wraps the
|
|
222
237
|
// dup-check, head-check and gated INSERT in one pipelined
|
|
223
238
|
// transaction — NO commit-time advisory lock. Cross-replica
|
|
@@ -274,7 +289,7 @@ function tryCommitNeon(sql: NeonSql): (input: RevisionInsert) => Promise<CommitR
|
|
|
274
289
|
// matches `base = NULL` on the first revision against the
|
|
275
290
|
// empty-chain head (also NULL); plain `=` would be NULL → false
|
|
276
291
|
// and the first revision would never insert.
|
|
277
|
-
sql(
|
|
292
|
+
sql.query(
|
|
278
293
|
GATED_INSERT_SQL_PG,
|
|
279
294
|
[tag, id, baseNorm, keyframeCol, nonce, ciphertext, signature, createdAt],
|
|
280
295
|
),
|
|
@@ -288,8 +303,8 @@ function tryCommitNeon(sql: NeonSql): (input: RevisionInsert) => Promise<CommitR
|
|
|
288
303
|
// stable: our id present ⇒ duplicate, else the head moved ⇒
|
|
289
304
|
// stale-base. (The INSERT itself is still authoritative — its own
|
|
290
305
|
// single-statement snapshot is what prevents a chain fork.)
|
|
291
|
-
sql(REVISION_EXISTS_SQL, [tag, id]),
|
|
292
|
-
sql(HEAD_FOR_SQL, [tag]),
|
|
306
|
+
sql.query(REVISION_EXISTS_SQL, [tag, id]),
|
|
307
|
+
sql.query(HEAD_FOR_SQL, [tag]),
|
|
293
308
|
])
|
|
294
309
|
} catch (err) {
|
|
295
310
|
// A unique-violation reaches here when a cross-replica racer (or
|
|
@@ -303,9 +318,9 @@ function tryCommitNeon(sql: NeonSql): (input: RevisionInsert) => Promise<CommitR
|
|
|
303
318
|
// escaping to `handleSave`'s IIFE. Other errors (network, syntax,
|
|
304
319
|
// type mismatch) rethrow.
|
|
305
320
|
if (!isUniqueViolation(err)) throw err
|
|
306
|
-
const dupRows = await sql(REVISION_EXISTS_SQL, [tag, id]) as Array<unknown>
|
|
321
|
+
const dupRows = await sql.query(REVISION_EXISTS_SQL, [tag, id]) as Array<unknown>
|
|
307
322
|
if (dupRows.length > 0) return { kind: 'inserted' }
|
|
308
|
-
const headRows = await sql(HEAD_FOR_SQL, [tag]) as Array<{ id: string }>
|
|
323
|
+
const headRows = await sql.query(HEAD_FOR_SQL, [tag]) as Array<{ id: string }>
|
|
309
324
|
return { kind: 'stale-base', head: headRows[0]?.id ?? null }
|
|
310
325
|
}
|
|
311
326
|
const insertRows = results[0] as Array<unknown>
|
|
@@ -343,8 +358,8 @@ export async function openNeonDb(connectionString: string): Promise<Handle> {
|
|
|
343
358
|
// their DDL (the advisory lock releases at COMMIT; the
|
|
344
359
|
// `IF NOT EXISTS` clauses then make the second runner a no-op).
|
|
345
360
|
await sql.transaction([
|
|
346
|
-
sql(`SELECT pg_advisory_xact_lock($1, $2)`, [DDL_LOCK_KEY_REVISION, DDL_LOCK_KEY_REVISION_SUB]),
|
|
347
|
-
...SCHEMA_PG.map((stmt) => sql(stmt, [])),
|
|
361
|
+
sql.query(`SELECT pg_advisory_xact_lock($1, $2)`, [DDL_LOCK_KEY_REVISION, DDL_LOCK_KEY_REVISION_SUB]),
|
|
362
|
+
...SCHEMA_PG.map((stmt) => sql.query(stmt, [])),
|
|
348
363
|
])
|
|
349
364
|
|
|
350
365
|
const handle: Handle = {
|
|
@@ -359,6 +374,7 @@ export async function openNeonDb(connectionString: string): Promise<Handle> {
|
|
|
359
374
|
chainAfterSeq: buildChainSeq(sql, CHAIN_AFTER_SQL),
|
|
360
375
|
chainFromSeq: buildChainSeq(sql, CHAIN_FROM_SQL),
|
|
361
376
|
revisionExists: buildRevisionExists(sql),
|
|
377
|
+
revisionById: buildRevisionById(sql),
|
|
362
378
|
tryCommit: tryCommitNeon(sql),
|
|
363
379
|
// The serverless HTTP client is stateless — no socket to close.
|
|
364
380
|
// Async no-op so shutdown's `handle.close()` works uniformly
|
|
@@ -108,6 +108,15 @@ export const CHAIN_FROM_SQL =
|
|
|
108
108
|
FROM workspace_revision WHERE workspace_tag = $1 AND seq >= $2 ORDER BY seq ASC`
|
|
109
109
|
export const REVISION_EXISTS_SQL =
|
|
110
110
|
`SELECT 1 AS one FROM workspace_revision WHERE workspace_tag = $1 AND id = $2`
|
|
111
|
+
// Fetch a single revision row by content-addressed id. The cross-instance
|
|
112
|
+
// pubsub (server/pubsub.ts) NOTIFY payload carries only `(tag, revisionId)`
|
|
113
|
+
// because the full `workspace-state` broadcast envelope is bounded by
|
|
114
|
+
// `MAX_CIPHERTEXT_LEN` (2 MiB) and Postgres NOTIFY caps payloads at ~8 KB.
|
|
115
|
+
// The receiver re-fetches the row from this shared table to construct the
|
|
116
|
+
// wire broadcast for its local peers.
|
|
117
|
+
export const REVISION_BY_ID_SQL =
|
|
118
|
+
`SELECT base, id, keyframe, nonce, ciphertext, signature
|
|
119
|
+
FROM workspace_revision WHERE workspace_tag = $1 AND id = $2`
|
|
111
120
|
|
|
112
121
|
// The gated commit INSERT, in `$N` form. One statement folds the
|
|
113
122
|
// dup-check, the head-equals-base check, the server-assigned seq
|
package/server/db.ts
CHANGED
|
@@ -60,7 +60,8 @@ import { dirname } from 'node:path'
|
|
|
60
60
|
import { type AllStmt, type GetStmt, wrapAll, wrapGet } from './db-stmt.ts'
|
|
61
61
|
import {
|
|
62
62
|
CHAIN_AFTER_SQL, CHAIN_ALL_SQL, CHAIN_FROM_SQL, GATED_INSERT_SQL_SQLITE, HEAD_FOR_SQL,
|
|
63
|
-
LAST_KEYFRAME_SEQ_SQL, REVISION_EXISTS_SQL, SEQ_OF_ID_SQL,
|
|
63
|
+
LAST_KEYFRAME_SEQ_SQL, REVISION_BY_ID_SQL, REVISION_EXISTS_SQL, SEQ_OF_ID_SQL,
|
|
64
|
+
mapRevisionRow, toSqlitePlaceholders,
|
|
64
65
|
} from './db-revision-sql.ts'
|
|
65
66
|
|
|
66
67
|
// `CHECK (keyframe IN (0, 1))` is the value-domain guard on the
|
|
@@ -181,6 +182,10 @@ export type Handle = {
|
|
|
181
182
|
chainAfterSeq: AllStmt<[string, number], RevisionRow>
|
|
182
183
|
chainFromSeq: AllStmt<[string, number], RevisionRow>
|
|
183
184
|
revisionExists: GetStmt<[string, string], unknown>
|
|
185
|
+
// Single-revision fetch by content-addressed id. The cross-instance
|
|
186
|
+
// pubsub receiver uses this to assemble a `workspace-state` from a
|
|
187
|
+
// NOTIFY hint (see `server/pubsub.ts`).
|
|
188
|
+
revisionById: GetStmt<[string, string], RevisionRow>
|
|
184
189
|
gatedInsert?: GetStmt<[string, string, string | null, number, string, string, string, number], { seq: number }>
|
|
185
190
|
tryCommit: (input: RevisionInsert) => Promise<CommitResult>
|
|
186
191
|
close: () => Promise<void>
|
|
@@ -288,6 +293,17 @@ function openDbInner(db: DatabaseSync): SqliteHandle {
|
|
|
288
293
|
const raw = wrapAll<P, Record<string, unknown>>(db.prepare(toSqlitePlaceholders(query)))
|
|
289
294
|
return { all: async (...args: P) => (await raw.all(...args)).map(mapRevisionRow) }
|
|
290
295
|
}
|
|
296
|
+
// Reuses `wrapGet` for the prepared statement, then maps the row
|
|
297
|
+
// through the shared coercion so the returned shape matches `chainFrom`.
|
|
298
|
+
const revisionByIdStmt: GetStmt<[string, string], RevisionRow> = (() => {
|
|
299
|
+
const raw = wrapGet<[string, string], Record<string, unknown>>(
|
|
300
|
+
db.prepare(toSqlitePlaceholders(REVISION_BY_ID_SQL)),
|
|
301
|
+
)
|
|
302
|
+
return { get: async (tag, id) => {
|
|
303
|
+
const row = await raw.get(tag, id)
|
|
304
|
+
return row ? mapRevisionRow(row) : undefined
|
|
305
|
+
} }
|
|
306
|
+
})()
|
|
291
307
|
const handle: SqliteHandle = {
|
|
292
308
|
db,
|
|
293
309
|
headFor: wrapGet<[string], { id: string }>(db.prepare(toSqlitePlaceholders(HEAD_FOR_SQL))),
|
|
@@ -297,6 +313,7 @@ function openDbInner(db: DatabaseSync): SqliteHandle {
|
|
|
297
313
|
chainAfterSeq: chainStmt<[string, number]>(CHAIN_AFTER_SQL),
|
|
298
314
|
chainFromSeq: chainStmt<[string, number]>(CHAIN_FROM_SQL),
|
|
299
315
|
revisionExists: wrapGet<[string, string], unknown>(db.prepare(toSqlitePlaceholders(REVISION_EXISTS_SQL))),
|
|
316
|
+
revisionById: revisionByIdStmt,
|
|
300
317
|
// SQLite null-safe equality is `IS`; the numbered `?N` form (with
|
|
301
318
|
// reuse) maps `$1`/`$2`/`$3` to repeated positional binds. `RETURNING
|
|
302
319
|
// seq` works in node:sqlite (see objstore's `insertLiveIfAbsent`).
|
package/server/http.ts
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
// HTTP plane: the REST byte-transfer routing (`/api/objstore/...`), the
|
|
2
|
-
// static UI bundle,
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
2
|
+
// static UI bundle, the SSE+POST fallback transport for the sync
|
|
3
|
+
// protocol, and the WebSocket upgrade gate. Built once at boot with
|
|
4
|
+
// the WS server plus the lifecycle hooks it needs (`track` to drain
|
|
5
|
+
// in-flight requests on shutdown, `isShuttingDown` to gate new ones).
|
|
6
|
+
// The WS *connection* handler is wired on `wss` separately in
|
|
6
7
|
// index.ts; this module only owns the upgrade handshake.
|
|
7
8
|
|
|
8
9
|
import { type IncomingMessage as HttpRequest, type Server, type ServerResponse, createServer } from 'node:http'
|
|
@@ -10,6 +11,8 @@ import { Buffer } from 'node:buffer'
|
|
|
10
11
|
import { fileURLToPath } from 'node:url'
|
|
11
12
|
import type { WebSocketServer } from 'ws'
|
|
12
13
|
import { type ObjstoreRestDeps, handleRest, matchRoute } from './objstore/rest.ts'
|
|
14
|
+
import { SSE_OPEN_PATH, type SseServer } from './sse-server.ts'
|
|
15
|
+
import { dispatchNpmAdvisories } from './npm-proxy.ts'
|
|
13
16
|
import { loadStatic } from './static.ts'
|
|
14
17
|
import { errStack } from './util.ts'
|
|
15
18
|
|
|
@@ -31,6 +34,11 @@ type HasHeaders = { headers: HttpRequest['headers'] }
|
|
|
31
34
|
export type HttpServerDeps = {
|
|
32
35
|
wss: WebSocketServer
|
|
33
36
|
restDeps: ObjstoreRestDeps
|
|
37
|
+
// SSE+POST fallback. Owns its own session map + lifecycle; we just
|
|
38
|
+
// give it first crack at requests that match `SSE_OPEN_PATH`. Same
|
|
39
|
+
// same-origin / shutdown gates run BEFORE the dispatch so the SSE
|
|
40
|
+
// plane inherits them.
|
|
41
|
+
sseServer: SseServer
|
|
34
42
|
isOriginAllowed: (req: HasHeaders) => boolean
|
|
35
43
|
isShuttingDown: () => boolean
|
|
36
44
|
track: (promise: Promise<unknown>) => void
|
|
@@ -39,7 +47,7 @@ export type HttpServerDeps = {
|
|
|
39
47
|
}
|
|
40
48
|
|
|
41
49
|
export function createHttpServer(deps: HttpServerDeps): Server {
|
|
42
|
-
const { wss, restDeps, isOriginAllowed, isShuttingDown, track, restPutIdleTimeoutMs, debug } = deps
|
|
50
|
+
const { wss, restDeps, sseServer, isOriginAllowed, isShuttingDown, track, restPutIdleTimeoutMs, debug } = deps
|
|
43
51
|
// Static-file plane (see ./static.ts). The directory is the
|
|
44
52
|
// `build.js build` output sibling to this file; the loader handles
|
|
45
53
|
// enumeration, pre-compression, and ETag derivation. Plugged in after
|
|
@@ -47,6 +55,34 @@ export function createHttpServer(deps: HttpServerDeps): Server {
|
|
|
47
55
|
const handleStatic = loadStatic(fileURLToPath(new URL('../out', import.meta.url)))
|
|
48
56
|
|
|
49
57
|
const httpServer = createServer((req: HttpRequest, res: ServerResponse) => {
|
|
58
|
+
// SSE+POST fallback plane. Same-origin gate first (Origin header
|
|
59
|
+
// is set by the browser on cross-origin EventSource + fetch, so
|
|
60
|
+
// a hostile origin would surface here just like it does on the
|
|
61
|
+
// WS upgrade and REST plane). The sseServer.handle() function
|
|
62
|
+
// returns true iff it matched and consumed the request; on false
|
|
63
|
+
// we fall through to the REST / static / 404 ladder below.
|
|
64
|
+
if (typeof req.url === 'string' && req.url.split('?', 1)[0] === SSE_OPEN_PATH) {
|
|
65
|
+
if (!isOriginAllowed(req)) {
|
|
66
|
+
res.writeHead(403, { 'content-type': 'application/json' })
|
|
67
|
+
res.end(JSON.stringify({ error: 'origin-denied' }))
|
|
68
|
+
return
|
|
69
|
+
}
|
|
70
|
+
// sseServer.handle owns its own shutdown / cap / 503 ladder.
|
|
71
|
+
// Each POST's response stays open as the session's downstream
|
|
72
|
+
// channel until the next POST takes over — the response is
|
|
73
|
+
// long-lived even though the request is one-shot — and the
|
|
74
|
+
// shutdown gate inside sseServer prevents accepting new POSTs
|
|
75
|
+
// once shuttingDown latches, so outstanding sessions drain via
|
|
76
|
+
// the lifecycle's sseSessions() close loop.
|
|
77
|
+
if (sseServer.handle(req, res)) return
|
|
78
|
+
}
|
|
79
|
+
// npm advisories proxy — same-origin + shutdown gates live in the
|
|
80
|
+
// helper so this dispatcher stays compact. `dispatchNpmAdvisories`
|
|
81
|
+
// returns the in-flight promise (or null when the route didn't
|
|
82
|
+
// match); the lifecycle's `track` awaits it so SIGTERM drains
|
|
83
|
+
// outstanding upstream fetches.
|
|
84
|
+
const npmP = dispatchNpmAdvisories(req, res, { isOriginAllowed, isShuttingDown, debug })
|
|
85
|
+
if (npmP) { track(npmP); return }
|
|
50
86
|
if (matchRoute(req.url) != null) {
|
|
51
87
|
// Shutdown gate. The WS plane gates new messages on `shuttingDown`;
|
|
52
88
|
// REST handlers go through a separate path and must mirror it.
|
package/server/hub.ts
CHANGED
|
@@ -19,8 +19,18 @@ export type Hub = {
|
|
|
19
19
|
// `except: null` is the REST-originated path — byte transfer landed
|
|
20
20
|
// via HTTP, not a particular WS socket, so it hits every subscriber.
|
|
21
21
|
// WS-originated broadcasts pass the originator so it doesn't see its
|
|
22
|
-
// own message echoed back.
|
|
22
|
+
// own message echoed back. Local-only: callers that ALSO want a
|
|
23
|
+
// cross-instance fan-out are expected to publish to the pubsub bus
|
|
24
|
+
// alongside this call (server/pubsub.ts). The hub deliberately stays
|
|
25
|
+
// ignorant of the bus so its transport invariants (backpressure cap,
|
|
26
|
+
// stringify-once, terminate-on-overflow) are unchanged.
|
|
23
27
|
broadcast(tag: string, msg: object, except: WebSocket | null): void
|
|
28
|
+
// Broadcasts an ALREADY-SERIALISED payload to every local subscriber
|
|
29
|
+
// for `tag`. Used by the pubsub bus receiver
|
|
30
|
+
// (server/bus-receiver.ts, wired up from server/index.ts) to relay
|
|
31
|
+
// a remote-instance event into this instance's fan-out. No `except`:
|
|
32
|
+
// the originator is on a different instance by construction.
|
|
33
|
+
broadcastLocalRaw(tag: string, payload: string): void
|
|
24
34
|
}
|
|
25
35
|
|
|
26
36
|
export function createHub(deps: { peers: PeerRegistry; maxBufferedBytes: number; debug: boolean }): Hub {
|
|
@@ -82,6 +92,16 @@ export function createHub(deps: { peers: PeerRegistry; maxBufferedBytes: number;
|
|
|
82
92
|
// catch-up with a multi-MB ciphertext × N subscribers, per-recipient
|
|
83
93
|
// JSON.stringify would dominate CPU; this is the cheap win.
|
|
84
94
|
const payload = JSON.stringify(msg)
|
|
95
|
+
fanOut(set, payload, except)
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function broadcastLocalRaw(tag: string, payload: string): void {
|
|
99
|
+
const set = subscribers.get(tag)
|
|
100
|
+
if (!set) return
|
|
101
|
+
fanOut(set, payload, null)
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function fanOut(set: Set<WebSocket>, payload: string, except: WebSocket | null): void {
|
|
85
105
|
// Snapshot before iterating — `send`'s try/catch swallows
|
|
86
106
|
// socket.send errors, but a socket transitioning to CLOSED
|
|
87
107
|
// mid-broadcast triggers `unsubscribeAll` from the 'close' handler,
|
|
@@ -94,5 +114,5 @@ export function createHub(deps: { peers: PeerRegistry; maxBufferedBytes: number;
|
|
|
94
114
|
}
|
|
95
115
|
}
|
|
96
116
|
|
|
97
|
-
return { subscribe, unsubscribeAll, send, sendRaw, broadcast }
|
|
117
|
+
return { subscribe, unsubscribeAll, send, sendRaw, broadcast, broadcastLocalRaw }
|
|
98
118
|
}
|