@preventive/triage 1.0.0-alpha.0 → 1.0.0-alpha.10
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/api/reap.ts +79 -0
- package/common/save-error-reason.ts +20 -7
- package/common/server-info.ts +30 -0
- package/out/brotli-fallback.js +1 -1
- package/out/client-admin.js +28 -0
- package/out/client-managed.js +1 -0
- package/out/client-sync.js +17 -10
- package/out/graph.js +5 -4
- package/out/index.html +43 -38
- package/out/prism.js +2 -2
- package/out/terminal.js +32 -28
- package/out/view.css +1 -1
- package/out/view.js +78 -51
- package/package.json +70 -49
- package/{server → server-common}/origin.ts +5 -5
- package/{server → server-e2e}/auth.ts +16 -1
- package/server-e2e/bus-receiver.ts +95 -0
- package/server-e2e/cli.js +22 -0
- package/{server → server-e2e}/config.ts +21 -8
- package/{server → server-e2e}/db-neon.ts +41 -25
- package/{server → server-e2e}/db-revision-sql.ts +15 -9
- package/{server → server-e2e}/db-stmt.ts +2 -2
- package/{server → server-e2e}/db.ts +113 -135
- package/server-e2e/http.ts +266 -0
- package/{server → server-e2e}/hub.ts +27 -8
- package/{server → server-e2e}/index.ts +185 -52
- package/{server → server-e2e}/lifecycle.ts +36 -5
- package/server-e2e/npm-proxy.ts +348 -0
- package/{server → server-e2e}/objstore/blob-fs.ts +6 -8
- package/{server → server-e2e}/objstore/blob-vercel.ts +69 -36
- package/{server → server-e2e}/objstore/blob.ts +24 -9
- package/server-e2e/objstore/fetch-mint-guard.ts +74 -0
- package/{server → server-e2e}/objstore/handlers.ts +25 -15
- package/{server → server-e2e}/objstore/init.ts +52 -12
- package/{server → server-e2e}/objstore/reaper.ts +31 -11
- package/server-e2e/objstore/rest-deny.ts +28 -0
- package/server-e2e/objstore/rest-mint.ts +224 -0
- package/{server → server-e2e}/objstore/rest.ts +119 -84
- package/{server → server-e2e}/objstore/sign.ts +105 -0
- package/{server → server-e2e}/objstore/store-neon.ts +19 -19
- package/{server → server-e2e}/objstore/store.ts +98 -118
- package/{server → server-e2e}/objstore/tokens.ts +9 -12
- package/{server → server-e2e}/peer.ts +7 -9
- package/server-e2e/pubsub.ts +394 -0
- package/{server → server-e2e}/sign.ts +12 -14
- package/server-e2e/sse-server.ts +384 -0
- package/server-e2e/sse-session.ts +216 -0
- package/{server → server-e2e}/static.ts +22 -17
- package/server-e2e/sync-handlers.ts +382 -0
- package/{server → server-e2e}/util.ts +9 -0
- package/server-e2e/ws-server.ts +276 -0
- package/strip-types-loader.js +94 -0
- package/server/http.ts +0 -142
- package/server/sync-handlers.ts +0 -311
- package/server/ws-server.ts +0 -245
- /package/{server → server-e2e}/config.example.json +0 -0
- /package/{server → server-e2e}/neon-driver.ts +0 -0
- /package/{server → server-e2e}/objstore/fs.ts +0 -0
- /package/{server → server-e2e}/validation.ts +0 -0
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// SQLite + filesystem-backed object store for the v1.objstore
|
|
2
|
-
// protocol extension. Sibling of `server/db.ts`; shares the
|
|
2
|
+
// protocol extension. Sibling of `server-e2e/db.ts`; shares the
|
|
3
3
|
// underlying DatabaseSync handle but keeps its own tables.
|
|
4
4
|
//
|
|
5
5
|
// workspace_object — one row per LIVE resource (no
|
|
@@ -79,9 +79,8 @@ const SCHEMA = `
|
|
|
79
79
|
|
|
80
80
|
// One LIVE row, exactly the shape the `workspace-subscribed` ack's
|
|
81
81
|
// `resources` array carries on the wire, minus `keyframe`-style
|
|
82
|
-
// server-only flags. `put_at` is a
|
|
83
|
-
//
|
|
84
|
-
// it via the DB but the server never volunteers it.
|
|
82
|
+
// server-only flags. `put_at` is a debug aid the wire format never
|
|
83
|
+
// includes — inspectable via the DB but the server never volunteers it.
|
|
85
84
|
export type ObjectRow = {
|
|
86
85
|
resourceTag: string
|
|
87
86
|
version: number
|
|
@@ -118,13 +117,11 @@ export type BeginPutInput = {
|
|
|
118
117
|
// PUT will reference. `workspace-full` is the per-workspace resource-
|
|
119
118
|
// count cap rejection — see `MAX_RESOURCES_PER_WORKSPACE`.
|
|
120
119
|
//
|
|
121
|
-
// `filePath` is
|
|
122
|
-
//
|
|
123
|
-
//
|
|
124
|
-
//
|
|
125
|
-
//
|
|
126
|
-
// Tests for the FS path use it as a convenience to write fixture
|
|
127
|
-
// bytes directly to the staging slot.
|
|
120
|
+
// `filePath` is the absolute on-disk staging path, set only for the
|
|
121
|
+
// FS handle; the Vercel handle omits it ("path" is meaningless against
|
|
122
|
+
// a remote store). Production (rest.ts) never reads it — it goes
|
|
123
|
+
// through `handle.blob.openStagingWriter(tag, sid)`. FS-path tests use
|
|
124
|
+
// it as a convenience to write fixture bytes to the staging slot.
|
|
128
125
|
export type BeginPutResult =
|
|
129
126
|
| { ok: true; stagingId: string; filePath?: string }
|
|
130
127
|
| { ok: false; reason: 'conflict'; conflict: ObjectRow | null }
|
|
@@ -134,17 +131,14 @@ export type CommitPutInput = {
|
|
|
134
131
|
workspaceTag: string
|
|
135
132
|
resourceTag: string
|
|
136
133
|
stagingId: string
|
|
137
|
-
// Optional
|
|
138
|
-
//
|
|
139
|
-
//
|
|
140
|
-
//
|
|
141
|
-
//
|
|
142
|
-
//
|
|
143
|
-
//
|
|
144
|
-
//
|
|
145
|
-
// (this PUT) has already completed. Tests that drive commitPut
|
|
146
|
-
// directly without the REST layer should omit this and let
|
|
147
|
-
// commitPut stat for itself.
|
|
134
|
+
// Optional storage-side byte count the caller already verified
|
|
135
|
+
// post-upload. When provided, commitPut skips the redundant
|
|
136
|
+
// `statStaging` round-trip — one fewer Vercel HEAD per PUT. Caller
|
|
137
|
+
// must ONLY pass a value it observed for THIS stagingId after its
|
|
138
|
+
// upload finished (the REST PUT path's post-upload `statStaging`).
|
|
139
|
+
// Safe without a lock: staging ids are random (no other request
|
|
140
|
+
// writes this blob) and the sole writer (this PUT) has finished.
|
|
141
|
+
// Tests driving commitPut directly omit this and let it stat.
|
|
148
142
|
observedSize?: number
|
|
149
143
|
}
|
|
150
144
|
|
|
@@ -156,7 +150,7 @@ export type DeleteResult =
|
|
|
156
150
|
| { ok: true; deletedVersion: number }
|
|
157
151
|
| { ok: false; reason: 'not-found' | 'conflict'; conflict?: ObjectRow }
|
|
158
152
|
|
|
159
|
-
// Async statement shapes are shared with server/db.ts via
|
|
153
|
+
// Async statement shapes are shared with server-e2e/db.ts via
|
|
160
154
|
// ../db-stmt.ts — same `.get/.all/.run` → Promise contract across
|
|
161
155
|
// both planes.
|
|
162
156
|
|
|
@@ -181,9 +175,9 @@ type DbRow = {
|
|
|
181
175
|
// - the reaper's age grace window + an atomic conditional staging
|
|
182
176
|
// delete (`deleteStagingIfStale`) so the stale-staging sweep
|
|
183
177
|
// can't race an upload that just finished.
|
|
184
|
-
// These hold both within a single process AND across replicas
|
|
185
|
-
//
|
|
186
|
-
// content-addressing
|
|
178
|
+
// These hold both within a single process AND across replicas, so no
|
|
179
|
+
// per-(tag, resourceTag) in-process mutex is needed — the CAS +
|
|
180
|
+
// content-addressing already cover everything one would.
|
|
187
181
|
export type Handle = {
|
|
188
182
|
// SQLite-only: the underlying `DatabaseSync`. Unset on the Neon
|
|
189
183
|
// backend (see ./store-neon.ts). Test-only fixture SQL routes
|
|
@@ -192,7 +186,7 @@ export type Handle = {
|
|
|
192
186
|
db?: DatabaseSync
|
|
193
187
|
// Byte-plane backend (local FS or Vercel Blob). All bytes-side
|
|
194
188
|
// operations go through this — there is no direct fs.* call in
|
|
195
|
-
// store / rest / reaper. Selected at boot in server/index.ts.
|
|
189
|
+
// store / rest / reaper. Selected at boot in server-e2e/index.ts.
|
|
196
190
|
blob: BlobBackend
|
|
197
191
|
// Storage root for the FS backend — set only when `blob` was
|
|
198
192
|
// constructed from `openFsBlobBackend(dir)`. Production code
|
|
@@ -212,15 +206,14 @@ export type Handle = {
|
|
|
212
206
|
selectStagingByWsSid: GetStmt<[string, string], unknown>
|
|
213
207
|
refreshStagingBegunAt: RunStmt<[number, string, string, string]>
|
|
214
208
|
deleteStaging: RunStmt<[string, string, string]>
|
|
215
|
-
// Atomic conditional staging delete
|
|
209
|
+
// Atomic conditional staging delete for the reaper's stale-row
|
|
216
210
|
// sweep. `deleteStagingIfStale(tag, res, sid, staleBefore)` deletes
|
|
217
211
|
// the row IFF its `begun_at < staleBefore`, returning `{ ok: 1 }`
|
|
218
|
-
// when a row was actually removed and `undefined` otherwise.
|
|
219
|
-
//
|
|
220
|
-
//
|
|
221
|
-
//
|
|
222
|
-
//
|
|
223
|
-
// fails atomically) and the row survives for the commit. Mirrors the
|
|
212
|
+
// when a row was actually removed and `undefined` otherwise. Lock-
|
|
213
|
+
// free race guard (PR #4 "F1"): a slow PUT that finishes calls
|
|
214
|
+
// `refreshStagingBegunAt` to bump `begun_at` fresh, so a concurrent
|
|
215
|
+
// reaper's conditional delete doesn't match (predicate fails
|
|
216
|
+
// atomically) and the row survives for the commit. Mirrors the
|
|
224
217
|
// `insertLiveIfAbsent` RETURNING pattern.
|
|
225
218
|
deleteStagingIfStale: GetStmt<[string, string, string, number], { ok: number }>
|
|
226
219
|
selectLive: AllStmt<[string], DbRow>
|
|
@@ -254,7 +247,7 @@ export type Handle = {
|
|
|
254
247
|
// returned. The reaper passes `Date.now() - stagingTtlMs` so the
|
|
255
248
|
// index `workspace_object_staging_begun_at_idx` is used and the
|
|
256
249
|
// sweep is O(stale-rows) instead of O(in-flight-uploads-cluster-
|
|
257
|
-
// wide). DB-layout audit `server/objstore/store.ts:312`.
|
|
250
|
+
// wide). DB-layout audit `server-e2e/objstore/store.ts:312`.
|
|
258
251
|
listAllStaging: AllStmt<[number], { workspace_tag: string; resource_tag: string; staging_id: string; begun_at: number }>
|
|
259
252
|
listLiveTags: AllStmt<[], { workspace_tag: string }>
|
|
260
253
|
countLive: GetStmt<[string], { c: number }>
|
|
@@ -262,7 +255,7 @@ export type Handle = {
|
|
|
262
255
|
|
|
263
256
|
// Narrowing alias for the SQLite-backed Handle: `db` is guaranteed
|
|
264
257
|
// to be set. `openObjstore` returns this so call sites (production
|
|
265
|
-
// shutdown plumbing in `server/index.ts` + the entire SQLite-only
|
|
258
|
+
// shutdown plumbing in `server-e2e/index.ts` + the entire SQLite-only
|
|
266
259
|
// test suite in `tests/server-objstore.test.js`) can reach
|
|
267
260
|
// `handle.db.prepare(...)` without an optional-chain or non-null
|
|
268
261
|
// assertion. A Neon-backed Handle (`openNeonObjstore`) keeps the
|
|
@@ -279,9 +272,6 @@ export type SqliteHandle = Handle & { db: DatabaseSync }
|
|
|
279
272
|
// transient over-shoot under high concurrency across DIFFERENT
|
|
280
273
|
// resources is bounded by `(parallel new-resource begins - 1)` and is
|
|
281
274
|
// accepted (the cap is a soft policy bound, not a security invariant).
|
|
282
|
-
// This was already the contract under the old per-resource lock —
|
|
283
|
-
// that lock keyed on (tag, resourceTag), so concurrent NEW-resource
|
|
284
|
-
// begins held DIFFERENT locks and raced the count anyway.
|
|
285
275
|
export const MAX_RESOURCES_PER_WORKSPACE = 100
|
|
286
276
|
|
|
287
277
|
// Per-upload byte cap, shared by the WS plane (rejects oversize
|
|
@@ -362,7 +352,7 @@ export function openObjstore(db: DatabaseSync, dir: string): SqliteHandle {
|
|
|
362
352
|
mkdirSync(dir, { recursive: true })
|
|
363
353
|
db.exec(SCHEMA)
|
|
364
354
|
// Fail-loud on a pre-existing non-STRICT table — same rationale as
|
|
365
|
-
// server/db.ts: `CREATE TABLE IF NOT EXISTS … STRICT` doesn't
|
|
355
|
+
// server-e2e/db.ts: `CREATE TABLE IF NOT EXISTS … STRICT` doesn't
|
|
366
356
|
// upgrade an existing non-STRICT table, and dropping strict type
|
|
367
357
|
// affinity opens an operator-attack path. PR #4 review F3.
|
|
368
358
|
for (const name of ['workspace_object', 'workspace_object_staging']) {
|
|
@@ -372,12 +362,10 @@ export function openObjstore(db: DatabaseSync, dir: string): SqliteHandle {
|
|
|
372
362
|
const blob = openFsBlobBackend(dir)
|
|
373
363
|
// No `close` method on the returned Handle: the underlying
|
|
374
364
|
// `DatabaseSync` is owned by the caller (in production, the
|
|
375
|
-
// workspace_revision handle in `server/db.ts`,
|
|
376
|
-
//
|
|
377
|
-
//
|
|
378
|
-
//
|
|
379
|
-
// either no-op'd (production) or left the connection open
|
|
380
|
-
// (tests construct their own DB and `db.close()` separately).
|
|
365
|
+
// workspace_revision handle in `server-e2e/db.ts`, closed from
|
|
366
|
+
// `shutdown()`; tests close their own DB). A `close()` here would
|
|
367
|
+
// mislead — it could only no-op or leak, never close the caller-
|
|
368
|
+
// owned connection.
|
|
381
369
|
return {
|
|
382
370
|
db,
|
|
383
371
|
blob,
|
|
@@ -522,11 +510,10 @@ export async function beginPut(handle: Handle, input: BeginPutInput): Promise<Be
|
|
|
522
510
|
if (liveVersion !== input.prevVersion || liveIncarnation !== input.prevIncarnation) {
|
|
523
511
|
return { ok: false, reason: 'conflict', conflict: live }
|
|
524
512
|
}
|
|
525
|
-
// Per-workspace resource cap
|
|
526
|
-
//
|
|
527
|
-
//
|
|
528
|
-
//
|
|
529
|
-
// bound, accepted to over-shoot under concurrent NEW-resource begins.
|
|
513
|
+
// Per-workspace resource cap, NEW resources only — re-uploads
|
|
514
|
+
// (live != null) don't change the count, so they're always allowed.
|
|
515
|
+
// Not atomic with the insert below; soft bound, see
|
|
516
|
+
// MAX_RESOURCES_PER_WORKSPACE.
|
|
530
517
|
if (!live) {
|
|
531
518
|
const count = await handle.countLive.get(input.workspaceTag) as { c: number } | undefined
|
|
532
519
|
if ((count?.c ?? 0) >= MAX_RESOURCES_PER_WORKSPACE) {
|
|
@@ -557,51 +544,48 @@ export async function beginPut(handle: Handle, input: BeginPutInput): Promise<Be
|
|
|
557
544
|
// IF ABSENT; a re-upload bumps the version IFF it still matches the
|
|
558
545
|
// precondition we read. Exactly one of N racing commits wins the CAS;
|
|
559
546
|
// the losers get `conflict` (with the current live row) and rebase.
|
|
560
|
-
//
|
|
561
|
-
//
|
|
562
|
-
//
|
|
563
|
-
//
|
|
564
|
-
//
|
|
565
|
-
//
|
|
566
|
-
//
|
|
567
|
-
//
|
|
568
|
-
//
|
|
569
|
-
//
|
|
570
|
-
//
|
|
571
|
-
// reaper-cleaned, never row-pointing-at-nothing" crash-safety contract.
|
|
547
|
+
// Each PUT is content-addressed at its OWN hash (distinct PUTs get
|
|
548
|
+
// distinct hashes — random nonce per encrypt), so N racers promote to
|
|
549
|
+
// N DIFFERENT immutable paths: no promote clobbers another's bytes, a
|
|
550
|
+
// loser's blob is just left unreferenced for the GC, and there's no
|
|
551
|
+
// metadata-vs-bytes desync to guard. The CAS gives atomicity both
|
|
552
|
+
// within a process and across replicas, so NO in-process lock is taken.
|
|
553
|
+
// A crash between the promote and the CAS leaves the staging blob/row
|
|
554
|
+
// intact alongside (at most) a stranded, unreferenced live blob — the
|
|
555
|
+
// stale-staging sweep cleans the row, the GC reaps the blob once past
|
|
556
|
+
// the grace window: the "stranded state, reaper-cleaned, never row-
|
|
557
|
+
// pointing-at-nothing" crash-safety contract.
|
|
572
558
|
//
|
|
573
|
-
// ACCEPTED TRADEOFF (lock removal):
|
|
574
|
-
//
|
|
575
|
-
//
|
|
576
|
-
// staging
|
|
577
|
-
//
|
|
578
|
-
//
|
|
579
|
-
//
|
|
580
|
-
//
|
|
581
|
-
//
|
|
582
|
-
// the TTL
|
|
583
|
-
//
|
|
584
|
-
// line").
|
|
559
|
+
// ACCEPTED TRADEOFF (lock removal): without a lock the reaper no longer
|
|
560
|
+
// waits for an in-flight upload on this key (the old lock blocked it on
|
|
561
|
+
// any in-flight upload, unbounded). An upload taking >1h FROM BEGIN
|
|
562
|
+
// (exceeding the staging TTL during the body) can have its staging row
|
|
563
|
+
// reaped mid-flight by `deleteStagingIfStale` → this commit sees no
|
|
564
|
+
// staging row → `no-staging` → REST 410. Sub-1h uploads are unaffected:
|
|
565
|
+
// `begun_at` (set at begin) stays within the TTL through the body, so
|
|
566
|
+
// the conditional delete can't match, and the after-body
|
|
567
|
+
// `refreshStagingBegunAt` re-extends the TTL to cover this commit step —
|
|
568
|
+
// matching the staging TTL's intent ("1h, comfortably over a 50 MiB
|
|
569
|
+
// upload on a slow line").
|
|
585
570
|
export async function commitPut(handle: Handle, input: CommitPutInput): Promise<CommitPutResult> {
|
|
586
571
|
const staging = await handle.selectStaging.get(input.workspaceTag, input.resourceTag, input.stagingId)
|
|
587
572
|
if (!staging) return { ok: false, reason: 'no-staging' }
|
|
588
573
|
let stagedSize: number | null
|
|
589
574
|
// statStaging failure here is a server-side issue (staging file
|
|
590
|
-
//
|
|
575
|
+
// unlinked by a racing abort / reaper, EACCES, EIO, backend
|
|
591
576
|
// unreachable, …) — not a client length-mismatch. Route through
|
|
592
577
|
// `io-error` so the REST layer returns 5xx, not 400. PR #4 review.
|
|
593
578
|
//
|
|
594
579
|
// The REST PUT layer already statted the staging blob post-upload
|
|
595
580
|
// and threads the result in via `observedSize` — skipping the
|
|
596
|
-
// round-trip saves one Vercel HEAD per PUT. The
|
|
597
|
-
//
|
|
598
|
-
//
|
|
599
|
-
//
|
|
600
|
-
//
|
|
601
|
-
//
|
|
602
|
-
//
|
|
603
|
-
//
|
|
604
|
-
// `observedSize` fall through to the explicit stat.
|
|
581
|
+
// round-trip saves one Vercel HEAD per PUT. The blob can't have been
|
|
582
|
+
// resized between that stat and here: staging ids are 16-byte random
|
|
583
|
+
// (no other request targets this blob) and the sole writer (this
|
|
584
|
+
// PUT's upload pipeline) has finished. The only other actor on a
|
|
585
|
+
// staging blob is the reaper, which UNLINKS (never resizes); a racing
|
|
586
|
+
// reaper unlink surfaces below as statStaging→io-error or a promote
|
|
587
|
+
// failure, not a wrong size. WS / test paths that omit `observedSize`
|
|
588
|
+
// fall through to the explicit stat.
|
|
605
589
|
if (input.observedSize === undefined) {
|
|
606
590
|
try { stagedSize = await handle.blob.statStaging(input.workspaceTag, input.stagingId) }
|
|
607
591
|
catch { return { ok: false, reason: 'io-error' } }
|
|
@@ -637,15 +621,14 @@ export async function commitPut(handle: Handle, input: CommitPutInput): Promise<
|
|
|
637
621
|
}
|
|
638
622
|
// Promote to the CONTENT-ADDRESSED live path `${tag}/${hash}.bin`.
|
|
639
623
|
// `promoteStagingToLive` returns false on any backend error (FS:
|
|
640
|
-
// EACCES / ENOSPC / EIO / a racing abort that already unlinked
|
|
641
|
-
//
|
|
642
|
-
//
|
|
643
|
-
//
|
|
644
|
-
//
|
|
645
|
-
//
|
|
646
|
-
//
|
|
647
|
-
//
|
|
648
|
-
// concurrent commits to the same resource write to DIFFERENT paths.)
|
|
624
|
+
// EACCES / ENOSPC / EIO / a racing abort that already unlinked the
|
|
625
|
+
// staging file; Vercel: copy failure) → 'io-error' → REST HTTP 500
|
|
626
|
+
// (server-side fault, not client-fixable). The destination path IS
|
|
627
|
+
// the content hash, so any write to it is byte-identical BY
|
|
628
|
+
// CONSTRUCTION: a retried or racing promote to the same path is an
|
|
629
|
+
// idempotent rewrite, never a clobber. (Distinct PUTs get distinct
|
|
630
|
+
// hashes — fresh random nonce per encrypt — so concurrent commits to
|
|
631
|
+
// the same resource write to DIFFERENT paths.)
|
|
649
632
|
if (!await handle.blob.promoteStagingToLive(input.workspaceTag, input.stagingId, staging.content_hash)) {
|
|
650
633
|
return { ok: false, reason: 'io-error' }
|
|
651
634
|
}
|
|
@@ -658,13 +641,12 @@ export async function commitPut(handle: Handle, input: CommitPutInput): Promise<
|
|
|
658
641
|
const committedIncarnation = staging.prev_version == null ? freshIncarnation : staging.prev_incarnation!
|
|
659
642
|
const putAt = Date.now()
|
|
660
643
|
// Version-CAS in try/catch so a Neon transient (5xx, network
|
|
661
|
-
// hiccup,
|
|
662
|
-
//
|
|
663
|
-
//
|
|
664
|
-
//
|
|
665
|
-
//
|
|
666
|
-
//
|
|
667
|
-
// GC) but the surface is a 500 the caller can retry.
|
|
644
|
+
// hiccup, pool exhaustion) doesn't throw out of commitPut and bypass
|
|
645
|
+
// the abortPut ladder. A thrown rejection would skip the REST layer's
|
|
646
|
+
// `if (!r.ok) abortPut` branch and bubble to handleRest's outer catch
|
|
647
|
+
// — live blob already promoted, staging blob + row left behind. The
|
|
648
|
+
// reaper reconciles (stale-staging sweep + unreferenced-blob GC); the
|
|
649
|
+
// surface is a 500 the caller can retry.
|
|
668
650
|
let won: { ok: number } | undefined
|
|
669
651
|
try {
|
|
670
652
|
if (staging.prev_version == null) {
|
|
@@ -753,30 +735,28 @@ export async function abortPut(handle: Handle, tag: string, resourceTag: string,
|
|
|
753
735
|
// `deletedVersion = 0` sentinel tells the broadcast path to skip.
|
|
754
736
|
//
|
|
755
737
|
// No lock: the drop is a version-CAS (`deleteLiveCAS` — DELETE WHERE
|
|
756
|
-
// version = prev), so every race resolves to exactly one winner
|
|
757
|
-
//
|
|
738
|
+
// version = prev), so every race resolves to exactly one winner with
|
|
739
|
+
// no lost update:
|
|
758
740
|
// - delete vs. a concurrent COMMIT on the same resource: whichever
|
|
759
741
|
// CAS lands first wins. A stale delete can NOT remove a row the
|
|
760
|
-
// commit just bumped —
|
|
761
|
-
//
|
|
762
|
-
//
|
|
763
|
-
// `conflict`. (
|
|
764
|
-
//
|
|
765
|
-
//
|
|
766
|
-
// version-CAS closes that.)
|
|
742
|
+
// commit just bumped — `WHERE version = prev` no longer matches, so
|
|
743
|
+
// the delete gets `conflict` (re-read sees the bumped version). If
|
|
744
|
+
// the delete wins, the commit's `updateLiveCAS` matches no row →
|
|
745
|
+
// `conflict`. (An UNCONDITIONAL delete here would let `getLive`
|
|
746
|
+
// read v1, a commit bump to v2 slip in, then the delete destroy v2:
|
|
747
|
+
// a lost update. The version-CAS closes that.)
|
|
767
748
|
// - two concurrent deletes with the same prevVersion: one CAS removes
|
|
768
749
|
// the row; the other matches no row → re-read → `not-found`.
|
|
769
750
|
//
|
|
770
751
|
// On success we ONLY drop the live row — we do NOT unlink the live
|
|
771
|
-
// blob.
|
|
752
|
+
// blob. Reclamation is deferred to the reaper's grace-window GC
|
|
772
753
|
// (unlinks once no live row references the hash AND it's older than the
|
|
773
|
-
// grace window)
|
|
774
|
-
//
|
|
775
|
-
//
|
|
776
|
-
//
|
|
777
|
-
//
|
|
778
|
-
//
|
|
779
|
-
// effectively 1:1; this delete simply orphans the blob for the GC.
|
|
754
|
+
// grace window) so the drop stays lock-free and can't race a concurrent
|
|
755
|
+
// commit's promote→CAS window (a just-promoted blob isn't referenced
|
|
756
|
+
// yet — the age grace protects it) or an in-flight GET still streaming
|
|
757
|
+
// the bytes. NOT because the hash might be shared — distinct PUTs get
|
|
758
|
+
// distinct hashes (random nonce → unique ciphertext), so the hash↔row
|
|
759
|
+
// mapping is effectively 1:1; this delete simply orphans the blob.
|
|
780
760
|
export async function deleteObject(
|
|
781
761
|
handle: Handle, tag: string, resourceTag: string, prevVersion: number | null, prevIncarnation: string | null,
|
|
782
762
|
): Promise<DeleteResult> {
|
|
@@ -112,16 +112,14 @@ function isValidPayload(v: unknown): v is TokenPayload {
|
|
|
112
112
|
const o = v as Record<string, unknown>
|
|
113
113
|
if (typeof o['tag'] !== 'string' || typeof o['res'] !== 'string') return false
|
|
114
114
|
// `Number.isSafeInteger` over `Number.isInteger`: `exp` is compared
|
|
115
|
-
// to `Date.now()` (
|
|
116
|
-
//
|
|
117
|
-
//
|
|
118
|
-
//
|
|
119
|
-
//
|
|
120
|
-
//
|
|
121
|
-
//
|
|
122
|
-
//
|
|
123
|
-
// codebase's safe-int gates (server/objstore/sign.ts,
|
|
124
|
-
// server/objstore/rest.ts, server/objstore/handlers.ts).
|
|
115
|
+
// to `Date.now()` (a safe int), `len` against the Content-Length the
|
|
116
|
+
// REST layer parsed (itself `isSafeInteger`-gated), and `ver` against
|
|
117
|
+
// SQLite's INTEGER (capacity 2^63). An unsafe-but-integer value in the
|
|
118
|
+
// token round-trips through IEEE-754 and could spoof equality with a
|
|
119
|
+
// different actual value, or — for `exp` — claim a
|
|
120
|
+
// `Number.MAX_SAFE_INTEGER + 1` expiry that compares ambiguously near
|
|
121
|
+
// the IEEE-754 boundary. Matches the codebase's other safe-int gates
|
|
122
|
+
// (server-e2e/objstore/sign.ts, rest.ts, handlers.ts).
|
|
125
123
|
if (!Number.isSafeInteger(o['exp']) || (o['exp'] as number) < 0) return false
|
|
126
124
|
if (o['op'] === 'put') {
|
|
127
125
|
return typeof o['sid'] === 'string'
|
|
@@ -138,8 +136,7 @@ function isValidPayload(v: unknown): v is TokenPayload {
|
|
|
138
136
|
return false
|
|
139
137
|
}
|
|
140
138
|
|
|
141
|
-
//
|
|
142
|
-
// the payload shape inline.
|
|
139
|
+
// Constructors so the WS handler doesn't assemble the payload inline.
|
|
143
140
|
export function mintPutToken(
|
|
144
141
|
secret: TokenSecret,
|
|
145
142
|
tag: string, res: string, sid: string, len: number,
|
|
@@ -1,9 +1,8 @@
|
|
|
1
|
-
// Per-connection server state. One `Peer` per accepted WebSocket
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
// few cross-function call sites look it up via `peers.get(socket)`.
|
|
1
|
+
// Per-connection server state. One `Peer` per accepted WebSocket. The
|
|
2
|
+
// connection handler constructs one on accept and holds it in a
|
|
3
|
+
// closure, so the hot paths (message dispatch, pong, close) touch
|
|
4
|
+
// fields directly; the few cross-function call sites look it up via
|
|
5
|
+
// `peers.get(socket)`.
|
|
7
6
|
//
|
|
8
7
|
// Held in a `WeakMap<WebSocket, Peer>` so a closed socket's state GCs
|
|
9
8
|
// with the socket; the close handler also `delete`s it explicitly
|
|
@@ -17,9 +16,8 @@ export class Peer {
|
|
|
17
16
|
// `workspace-subscribe` signature — blocks cross-connection replay
|
|
18
17
|
// of a captured subscribe frame.
|
|
19
18
|
readonly challenge: string
|
|
20
|
-
// Password-gate
|
|
21
|
-
//
|
|
22
|
-
// bypasses the new-workspace gate. `false` until then.
|
|
19
|
+
// Password-gate flag. Once the `authenticate` handshake succeeds,
|
|
20
|
+
// first-actions on this socket bypass the new-workspace gate.
|
|
23
21
|
authorized = false
|
|
24
22
|
// Heartbeat liveness. The sweep flips it `false` after each `ping()`;
|
|
25
23
|
// the `pong` listener flips it back. A socket still `false` on the
|