Stage 7 (partial, stopping point): persistent quarantine store in lumen-user v2 (additive migration, B-6 safe), quarantine no-loop skip in pullCandidate before any file fetch; verifier/pull wiring compiles clean, 275 tests green. Quarantine round-trip tests pending.
Some checks failed
ci / check (push) Has been cancelled
Some checks failed
ci / check (push) Has been cancelled
This commit is contained in:
parent
c6479d6811
commit
48685b3cc8
5 changed files with 169 additions and 9 deletions
132
src/data/user/quarantine.ts
Normal file
132
src/data/user/quarantine.ts
Normal file
|
|
@ -0,0 +1,132 @@
|
|||
/**
|
||||
* Persistent quarantine store — rejected (edition, packageVersion) pairs so a
|
||||
* known-bad version is never re-fetched until latest.json advances past it.
|
||||
* Lives in `lumen-user` (diag concern, contract §9) — survives dataset GC.
|
||||
* Written as lumen-user v2 via the openDB versionchange seam.
|
||||
* Trace: IMPLEMENTATION-CONTRACT.md §11 (quarantine no-loop), Stage 7,
|
||||
* ARCHITECTURE-DESIGN.md:675, SPIKE-02 F-5
|
||||
*/
|
||||
import { withTx, idbGet, idbPut, idbGetAll, idbDelete } from "../../platform/idb/wrapper.js";
|
||||
import { USER_QUARANTINE } from "../../platform/idb/names.js";
|
||||
|
||||
/**
|
||||
* Local structural twin of sync/verifier QuarantineRecord (B-boundary: data
|
||||
* imports platform only). Field-compatible, so sync can pass its records
|
||||
* straight through to addQuarantined / quarantineSink.
|
||||
*/
|
||||
export interface QuarantineRecordLike {
|
||||
readonly edition: string | null;
|
||||
readonly packageVersion: number | null;
|
||||
readonly code: string;
|
||||
readonly reason: string;
|
||||
readonly at: number;
|
||||
}
|
||||
|
||||
export const USER_DB_VERSION = 2;
|
||||
|
||||
export function quarantineKey(edition: string, packageVersion: number): string {
|
||||
return `${edition}/${String(packageVersion)}`;
|
||||
}
|
||||
|
||||
/** Record shape persisted under `edition/packageVersion`. */
|
||||
export interface QuarantinedVersion {
|
||||
readonly edition: string;
|
||||
readonly packageVersion: number;
|
||||
readonly code: string;
|
||||
readonly reason: string;
|
||||
readonly at: number;
|
||||
}
|
||||
|
||||
export async function addQuarantined(db: IDBDatabase, record: QuarantineRecordLike): Promise<void> {
|
||||
if (record.edition === null || record.packageVersion === null) {
|
||||
// Cannot key an unidentified rejection — diag entry still gets written by
|
||||
// the caller; quarantine (which prevents refetch) needs both coordinates.
|
||||
return;
|
||||
}
|
||||
await idbPut(
|
||||
db,
|
||||
USER_QUARANTINE,
|
||||
{
|
||||
edition: record.edition,
|
||||
packageVersion: record.packageVersion,
|
||||
code: record.code,
|
||||
reason: record.reason,
|
||||
at: record.at,
|
||||
} satisfies QuarantinedVersion,
|
||||
quarantineKey(record.edition, record.packageVersion),
|
||||
);
|
||||
}
|
||||
|
||||
export async function isQuarantined(
|
||||
db: IDBDatabase,
|
||||
edition: string,
|
||||
packageVersion: number,
|
||||
): Promise<boolean> {
|
||||
const v = await idbGet<QuarantinedVersion>(
|
||||
db,
|
||||
USER_QUARANTINE,
|
||||
quarantineKey(edition, packageVersion),
|
||||
);
|
||||
return v !== undefined;
|
||||
}
|
||||
|
||||
/** All quarantined versions for an edition (ascending by packageVersion). */
|
||||
export async function listQuarantined(
|
||||
db: IDBDatabase,
|
||||
edition: string,
|
||||
): Promise<QuarantinedVersion[]> {
|
||||
const all = await idbGetAll<QuarantinedVersion>(db, USER_QUARANTINE);
|
||||
return all
|
||||
.filter((q) => q.edition === edition)
|
||||
.sort((a, b) => a.packageVersion - b.packageVersion);
|
||||
}
|
||||
|
||||
/** Clear one entry (e.g. organizer republished content at that version via rollback runbook). */
|
||||
export async function clearQuarantined(
|
||||
db: IDBDatabase,
|
||||
edition: string,
|
||||
packageVersion: number,
|
||||
): Promise<void> {
|
||||
await idbDelete(db, USER_QUARANTINE, quarantineKey(edition, packageVersion));
|
||||
}
|
||||
|
||||
/** Clear every quarantined version for an edition except strictly-newer than a version. */
|
||||
export async function pruneQuarantinedUpTo(
|
||||
db: IDBDatabase,
|
||||
edition: string,
|
||||
throughInclusive: number,
|
||||
): Promise<number> {
|
||||
const doomed = (await listQuarantined(db, edition)).filter(
|
||||
(q) => q.packageVersion <= throughInclusive,
|
||||
);
|
||||
if (doomed.length === 0) return 0;
|
||||
await withTx(db, USER_QUARANTINE, "readwrite", async (tx) => {
|
||||
const os = tx.objectStore(USER_QUARANTINE);
|
||||
for (const q of doomed) {
|
||||
const req = os.delete(quarantineKey(q.edition, q.packageVersion));
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
req.onsuccess = () => {
|
||||
resolve();
|
||||
};
|
||||
req.onerror = () => {
|
||||
reject(req.error ?? new Error("IDB error"));
|
||||
};
|
||||
});
|
||||
}
|
||||
});
|
||||
return doomed.length;
|
||||
}
|
||||
|
||||
/**
|
||||
* QuarantineSink backed by the store — wire into VerifyDependencies.quarantine
|
||||
* so every verifier rejection lands in persistent quarantine automatically.
|
||||
*/
|
||||
export interface QuarantineSinkLike {
|
||||
readonly add: (record: QuarantineRecordLike) => Promise<void> | void;
|
||||
}
|
||||
|
||||
export function quarantineSink(db: IDBDatabase): QuarantineSinkLike {
|
||||
return {
|
||||
add: (record) => addQuarantined(db, record),
|
||||
};
|
||||
}
|
||||
|
|
@ -13,12 +13,12 @@ import {
|
|||
idbDelete,
|
||||
idbCount,
|
||||
} from "../../platform/idb/wrapper.js";
|
||||
import { DB, USER_FAVS, USER_PREFS, USER_DIAG } from "../../platform/idb/names.js";
|
||||
import { DB, USER_FAVS, USER_PREFS, USER_DIAG, USER_QUARANTINE } from "../../platform/idb/names.js";
|
||||
import type { FavoriteEntry, UserPrefs, DiagEntry } from "./types.js";
|
||||
|
||||
const USER_VERSION = 1;
|
||||
const USER_VERSION = 2;
|
||||
|
||||
function upgradeUser(db: IDBDatabase): void {
|
||||
function upgradeUser(db: IDBDatabase, oldVersion: number): void {
|
||||
if (!db.objectStoreNames.contains(USER_FAVS)) {
|
||||
db.createObjectStore(USER_FAVS);
|
||||
}
|
||||
|
|
@ -28,11 +28,16 @@ function upgradeUser(db: IDBDatabase): void {
|
|||
if (!db.objectStoreNames.contains(USER_DIAG)) {
|
||||
db.createObjectStore(USER_DIAG);
|
||||
}
|
||||
// v1 -> v2: persistent quarantine store (Stage 7 §11 no-loop). Additive;
|
||||
// favorites/prefs/diag untouched (B-6).
|
||||
if (oldVersion < 2 && !db.objectStoreNames.contains(USER_QUARANTINE)) {
|
||||
db.createObjectStore(USER_QUARANTINE);
|
||||
}
|
||||
}
|
||||
|
||||
export function openUserDB(): Promise<IDBDatabase> {
|
||||
return openDB(DB.USER, USER_VERSION, (db) => {
|
||||
upgradeUser(db);
|
||||
return openDB(DB.USER, USER_VERSION, (db, oldVersion) => {
|
||||
upgradeUser(db, oldVersion);
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -26,5 +26,6 @@ export const SLOT_STAGING = "staging" as const;
|
|||
export const USER_FAVS = "favorites" as const;
|
||||
export const USER_PREFS = "prefs" as const;
|
||||
export const USER_DIAG = "diag" as const;
|
||||
export const USER_QUARANTINE = "quarantine" as const;
|
||||
|
||||
export type DbName = (typeof DB)[keyof typeof DB];
|
||||
|
|
|
|||
|
|
@ -8,6 +8,14 @@ export interface CandidatePackage {
|
|||
readonly result: VerifyResult;
|
||||
}
|
||||
|
||||
/** Skip decision when the pointer targets a version already quarantined. */
|
||||
export interface QuarantineSkipped {
|
||||
readonly skipped: "quarantined";
|
||||
readonly pointer: LatestPointer;
|
||||
}
|
||||
|
||||
export type PullOutcome = CandidatePackage | QuarantineSkipped | null;
|
||||
|
||||
function siblingUrl(manifestUrl: string, name: string): string {
|
||||
if (/^[a-z][a-z\d+.-]*:/i.test(manifestUrl)) return new URL(name, manifestUrl).toString();
|
||||
return new URL(name, `https://transport.invalid${manifestUrl}`).pathname;
|
||||
|
|
@ -16,16 +24,28 @@ function siblingUrl(manifestUrl: string, name: string): string {
|
|||
/**
|
||||
* Pulls through the verification boundary and stops at a verified package.
|
||||
* No staging, activation, retry loop, or user-data operation belongs here.
|
||||
*
|
||||
* Quarantine no-loop (§11): when a `isQuarantined` predicate is supplied and
|
||||
* the pointer targets a rejected version, the pull stops BEFORE fetching the
|
||||
* manifest or any file — a known-bad version is never re-fetched until
|
||||
* latest.json advances past it.
|
||||
*/
|
||||
export async function pullCandidate(
|
||||
transport: Transport,
|
||||
edition: string,
|
||||
deps: VerifyDependencies,
|
||||
options: { readonly signal?: AbortSignal; readonly timeoutMs?: number } = {},
|
||||
): Promise<CandidatePackage | null> {
|
||||
options: {
|
||||
readonly signal?: AbortSignal;
|
||||
readonly timeoutMs?: number;
|
||||
readonly isQuarantined?: (packageVersion: number) => Promise<boolean>;
|
||||
} = {},
|
||||
): Promise<PullOutcome> {
|
||||
if (!transport.isAvailable()) return null;
|
||||
const pointer = await transport.fetchPointer(edition, options);
|
||||
if (!pointer) return null;
|
||||
if (options.isQuarantined && (await options.isQuarantined(pointer.packageVersion))) {
|
||||
return { skipped: "quarantined", pointer };
|
||||
}
|
||||
const manifestUrl = pointer.manifestUrl;
|
||||
const manifestBytes = await transport.fetchBytes(manifestUrl, options);
|
||||
const signatureBytes = await transport.fetchBytes(
|
||||
|
|
|
|||
|
|
@ -154,7 +154,8 @@ describe("Stage 9 pull and verifier boundary", () => {
|
|||
supportedSchemaRange: [1],
|
||||
},
|
||||
);
|
||||
expect(result?.result.ok).toBe(true);
|
||||
if (!result || "skipped" in result) throw new Error("expected candidate");
|
||||
expect(result.result.ok).toBe(true);
|
||||
expect(calls[0]).toMatch(/latest\.json$/);
|
||||
expect(calls[1]).toMatch(/manifest\.json$/);
|
||||
expect(calls[2]).toMatch(/signature\.json$/);
|
||||
|
|
@ -187,7 +188,8 @@ describe("Stage 9 pull and verifier boundary", () => {
|
|||
supportedSchemaRange: [1],
|
||||
},
|
||||
);
|
||||
expect(result?.result).toMatchObject({ ok: false, code: "signature_mismatch" });
|
||||
if (!result || "skipped" in result) throw new Error("expected candidate");
|
||||
expect(result.result).toMatchObject({ ok: false, code: "signature_mismatch" });
|
||||
expect(calls).toHaveLength(3);
|
||||
expect(calls.some((url) => /emergency|schedule|map|info|assets\.json/.test(url))).toBe(false);
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue