@singularityfm/contracts 0.0.0-stage → 0.1.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.
@@ -0,0 +1,15 @@
1
+ /**
2
+ * The command center's session cookie: a signed end time, made by the admin gate (apps/admin/src/gate/gate.ts) at sign-in and
3
+ * read by whoever must know the owner is signed in, which is the gate and the reader's two report
4
+ * routes (apps/web/src/server/adminGuard.ts). Web Crypto only, so it runs in Vercel Routing
5
+ * Middleware, a function and the reader's server alike.
6
+ */
7
+ export declare const COOKIE = "singularity_admin";
8
+ export declare const SESSION_SECONDS: number;
9
+ export declare function hmac(secret: string, message: string): Promise<string>;
10
+ /** Equal strings, compared without stopping at the first difference. */
11
+ export declare function same(a: string, b: string): boolean;
12
+ /** A session value: when it ends, and the signature of that moment. */
13
+ export declare function createSession(secret: string, now: number): Promise<string>;
14
+ export declare function sessionValid(value: string | undefined, secret: string, now: number): Promise<boolean>;
15
+ export declare function cookieOf(request: Request, name: string): string | undefined;
@@ -0,0 +1,46 @@
1
+ /**
2
+ * The command center's session cookie: a signed end time, made by the admin gate (apps/admin/src/gate/gate.ts) at sign-in and
3
+ * read by whoever must know the owner is signed in, which is the gate and the reader's two report
4
+ * routes (apps/web/src/server/adminGuard.ts). Web Crypto only, so it runs in Vercel Routing
5
+ * Middleware, a function and the reader's server alike.
6
+ */
7
+ export const COOKIE = "singularity_admin";
8
+ export const SESSION_SECONDS = 30 * 24 * 60 * 60;
9
+ const encoder = new TextEncoder();
10
+ export async function hmac(secret, message) {
11
+ const key = await crypto.subtle.importKey("raw", encoder.encode(secret), { name: "HMAC", hash: "SHA-256" }, false, ["sign"]);
12
+ const bytes = new Uint8Array(await crypto.subtle.sign("HMAC", key, encoder.encode(message)));
13
+ return Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("");
14
+ }
15
+ /** Equal strings, compared without stopping at the first difference. */
16
+ export function same(a, b) {
17
+ if (a.length !== b.length)
18
+ return false;
19
+ let diff = 0;
20
+ for (let i = 0; i < a.length; i += 1)
21
+ diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
22
+ return diff === 0;
23
+ }
24
+ /** A session value: when it ends, and the signature of that moment. */
25
+ export async function createSession(secret, now) {
26
+ const ends = Math.floor(now / 1000) + SESSION_SECONDS;
27
+ return `${ends}.${await hmac(secret, `session:${ends}`)}`;
28
+ }
29
+ export async function sessionValid(value, secret, now) {
30
+ if (!value)
31
+ return false;
32
+ const [ends, signature] = value.split(".");
33
+ if (!ends || !signature || !/^\d+$/.test(ends))
34
+ return false;
35
+ if (Number(ends) * 1000 <= now)
36
+ return false;
37
+ return same(signature, await hmac(secret, `session:${ends}`));
38
+ }
39
+ export function cookieOf(request, name) {
40
+ for (const part of (request.headers.get("cookie") ?? "").split(";")) {
41
+ const [key, ...rest] = part.trim().split("=");
42
+ if (key === name)
43
+ return rest.join("=");
44
+ }
45
+ return undefined;
46
+ }
@@ -0,0 +1,10 @@
1
+ /**
2
+ * The store readers the admin app needs, as one entry the published package compiles (D382 step 3).
3
+ * The engine owns these files and keeps writing through them; this entry only names what a reader
4
+ * outside this repository may import.
5
+ */
6
+ export * from "../../engine/src/store/configPaths.js";
7
+ export * from "../../engine/src/store/experimentFlags.js";
8
+ export * from "../../engine/src/store/experimentHits.js";
9
+ export * from "../../engine/src/store/neon.js";
10
+ export * from "../../engine/src/store/runsStore.js";
@@ -0,0 +1,10 @@
1
+ /**
2
+ * The store readers the admin app needs, as one entry the published package compiles (D382 step 3).
3
+ * The engine owns these files and keeps writing through them; this entry only names what a reader
4
+ * outside this repository may import.
5
+ */
6
+ export * from "../../engine/src/store/configPaths.js";
7
+ export * from "../../engine/src/store/experimentFlags.js";
8
+ export * from "../../engine/src/store/experimentHits.js";
9
+ export * from "../../engine/src/store/neon.js";
10
+ export * from "../../engine/src/store/runsStore.js";
@@ -0,0 +1,59 @@
1
+ /**
2
+ * The config store's names and switch (D149), with no database code: the CLI, the write guard and
3
+ * the site's route import this, so it loads no Neon driver itself; `connectConfigStore` does, on
4
+ * call (the config store, D149, and the site's rate-limit counter whenever DATABASE_URL is set, D150).
5
+ */
6
+ /**
7
+ * The files the database may hold, and the only paths a row may ever be written to. They are the
8
+ * owner's own config and are not in git (D296); `config/examples` holds the public defaults.
9
+ */
10
+ export declare const CONFIG_DOCUMENT_PATHS: readonly ["config/topics.json", "config/sources.user.json", "config/queries.json", "config/preferences.json", "config/schedule.json", "config/structure.json", "config/writer.json", "config/profiles/owner.json", "state/source-candidates.json"];
11
+ export type ConfigDocumentPath = (typeof CONFIG_DOCUMENT_PATHS)[number];
12
+ /** What the store needs from a SQL client: one parameterised query, rows back as objects. */
13
+ export interface SqlClient {
14
+ query(text: string, params?: unknown[]): Promise<Record<string, unknown>[]>;
15
+ }
16
+ export interface ConfigDocument {
17
+ path: string;
18
+ content: string;
19
+ version: number;
20
+ /** When it was last written, in epoch milliseconds (absent from a test's fake rows). */
21
+ updatedAt?: number;
22
+ }
23
+ export interface DocumentWrite {
24
+ path: string;
25
+ content: string;
26
+ /** The version the edit was made on; null for a document the store does not hold yet. */
27
+ version: number | null;
28
+ }
29
+ /** The URL to use when the store is switched on, or null: the URL alone never turns it on. */
30
+ export declare function configStoreUrl(env: {
31
+ DATABASE_URL?: string | undefined;
32
+ SINGULARITY_CONFIG_STORE?: string | undefined;
33
+ }): string | null;
34
+ /**
35
+ * The URL to use when the run state store is switched on, or null (D381). Its own switch, beside
36
+ * the config store's, so state can be turned on and off without touching the owner's config.
37
+ */
38
+ export declare function stateStoreUrl(env: {
39
+ DATABASE_URL?: string | undefined;
40
+ SINGULARITY_STATE_STORE?: string | undefined;
41
+ }): string | null;
42
+ /** The URL and its password, decoded: an error that names either must be logged without it. */
43
+ export declare function databaseSecrets(databaseUrl: string): string[];
44
+ /** Opens the store over Neon's HTTP driver, which is loaded only on this call. */
45
+ export declare function connectConfigStore(databaseUrl: string): Promise<SqlClient>;
46
+ /**
47
+ * Whether a command reads the owner's config at its start: a whole run or the gate stage that opens
48
+ * one (the later stages of the same run, and its failure record, keep what the gate copied), the
49
+ * weekly podcast, ingest, which reads the sources the owner has now (D233), and `health`, which
50
+ * checks the config. Not `publish` (audio.yml): that job has no database access by design (SEC-03).
51
+ */
52
+ export declare function readsConfigAtStart(command: string, argv: readonly string[]): boolean;
53
+ /**
54
+ * Whether a command copies the run state (state/*.json) from the store at its start (D381). The
55
+ * same commands as the config, except that a podcast does it in its first stage only: its later
56
+ * stages run in the same checkout and hold spend and timings that are not committed yet, which a
57
+ * second pull would write over.
58
+ */
59
+ export declare function readsStateAtStart(command: string, argv: readonly string[]): boolean;
@@ -0,0 +1,97 @@
1
+ /**
2
+ * The config store's names and switch (D149), with no database code: the CLI, the write guard and
3
+ * the site's route import this, so it loads no Neon driver itself; `connectConfigStore` does, on
4
+ * call (the config store, D149, and the site's rate-limit counter whenever DATABASE_URL is set, D150).
5
+ */
6
+ /**
7
+ * The files the database may hold, and the only paths a row may ever be written to. They are the
8
+ * owner's own config and are not in git (D296); `config/examples` holds the public defaults.
9
+ */
10
+ export const CONFIG_DOCUMENT_PATHS = [
11
+ "config/topics.json",
12
+ "config/sources.user.json",
13
+ "config/queries.json",
14
+ "config/preferences.json",
15
+ "config/schedule.json",
16
+ "config/structure.json",
17
+ "config/writer.json",
18
+ "config/profiles/owner.json",
19
+ // Suggested sources: topic.remove drops a removed topic's, so they live beside the topics.
20
+ "state/source-candidates.json",
21
+ ];
22
+ /** The URL to use when the store is switched on, or null: the URL alone never turns it on. */
23
+ export function configStoreUrl(env) {
24
+ const store = env.SINGULARITY_CONFIG_STORE;
25
+ return env.DATABASE_URL !== undefined && env.DATABASE_URL !== "" && store === "postgres"
26
+ ? env.DATABASE_URL
27
+ : null;
28
+ }
29
+ /**
30
+ * The URL to use when the run state store is switched on, or null (D381). Its own switch, beside
31
+ * the config store's, so state can be turned on and off without touching the owner's config.
32
+ */
33
+ export function stateStoreUrl(env) {
34
+ return env.DATABASE_URL !== undefined &&
35
+ env.DATABASE_URL !== "" &&
36
+ env.SINGULARITY_STATE_STORE === "postgres"
37
+ ? env.DATABASE_URL
38
+ : null;
39
+ }
40
+ /** The URL and its password, decoded: an error that names either must be logged without it. */
41
+ export function databaseSecrets(databaseUrl) {
42
+ try {
43
+ return [databaseUrl, decodeURIComponent(new URL(databaseUrl).password)];
44
+ }
45
+ catch {
46
+ return [databaseUrl];
47
+ }
48
+ }
49
+ /** Opens the store over Neon's HTTP driver, which is loaded only on this call. */
50
+ export async function connectConfigStore(databaseUrl) {
51
+ const { connectNeon } = await import("./neon.js");
52
+ return connectNeon(databaseUrl);
53
+ }
54
+ /**
55
+ * Whether a command reads the owner's config at its start: a whole run or the gate stage that opens
56
+ * one (the later stages of the same run, and its failure record, keep what the gate copied), the
57
+ * weekly podcast, ingest, which reads the sources the owner has now (D233), and `health`, which
58
+ * checks the config. Not `publish` (audio.yml): that job has no database access by design (SEC-03).
59
+ */
60
+ export function readsConfigAtStart(command, argv) {
61
+ if (command === "podcast" || command === "ingest")
62
+ return true;
63
+ if (command === "health")
64
+ return true;
65
+ if (command !== "run")
66
+ return false;
67
+ // A run's failure record commits after the gate copied the store; it must leave the gate's note.
68
+ if (argv.includes("--record-failed"))
69
+ return false;
70
+ const at = argv.findIndex((arg) => arg === "--stage" || arg.startsWith("--stage="));
71
+ if (at === -1)
72
+ return true;
73
+ const flag = argv[at] ?? "";
74
+ const stage = flag.includes("=") ? flag.slice(flag.indexOf("=") + 1) : argv[at + 1];
75
+ return stage === "gate";
76
+ }
77
+ /**
78
+ * Whether a command copies the run state (state/*.json) from the store at its start (D381). The
79
+ * same commands as the config, except that a podcast does it in its first stage only: its later
80
+ * stages run in the same checkout and hold spend and timings that are not committed yet, which a
81
+ * second pull would write over.
82
+ */
83
+ export function readsStateAtStart(command, argv) {
84
+ // The audio job reads no config (SEC-03) but pulls its cost rows through its own login.
85
+ if (command === "publish")
86
+ return true;
87
+ if (!readsConfigAtStart(command, argv))
88
+ return false;
89
+ if (command !== "podcast")
90
+ return true;
91
+ const at = argv.findIndex((arg) => arg === "--stage" || arg.startsWith("--stage="));
92
+ if (at === -1)
93
+ return true;
94
+ const flag = argv[at] ?? "";
95
+ const stage = flag.includes("=") ? flag.slice(flag.indexOf("=") + 1) : argv[at + 1];
96
+ return stage === "select";
97
+ }
@@ -0,0 +1,28 @@
1
+ import type { SqlClient } from "./configPaths.js";
2
+ /**
3
+ * The owner's switches for the experiments in the admin app: one row per experiment in Neon, read
4
+ * by the engine when a run starts and written by the admin app. A row overrides the `enabled` the
5
+ * experiment's config file holds in git, so switching one costs no commit and no deploy; no row
6
+ * means the file decides. `promoted_at` marks an experiment the owner has accepted as a feature; its
7
+ * flag stays on until the code that holds the switch is deleted. The table is created by its first
8
+ * write. No driver here: see neon.ts.
9
+ */
10
+ export declare const EXPERIMENT_FLAGS_SCHEMA_SQL: readonly ["create table if not exists experiment_flags (\n id text primary key,\n enabled boolean not null,\n promoted_at timestamptz,\n updated_at timestamptz not null default now()\n)"];
11
+ export interface ExperimentFlag {
12
+ enabled: boolean;
13
+ /** When the owner promoted it, in epoch milliseconds; null while it is still an experiment. */
14
+ promoted_at: number | null;
15
+ }
16
+ export type ExperimentFlags = Record<string, ExperimentFlag>;
17
+ /** Every stored flag by experiment id. A table that does not exist yet holds none. */
18
+ export declare function readExperimentFlags(sql: SqlClient): Promise<ExperimentFlags>;
19
+ /**
20
+ * Switches an experiment on or off; its promotion, if any, is kept. A promoted experiment stays
21
+ * on, so the write that would turn one off touches no row and answers false.
22
+ */
23
+ export declare function setExperimentEnabled(sql: SqlClient, id: string, enabled: boolean): Promise<boolean>;
24
+ /**
25
+ * Marks an experiment promoted, which also turns it on; `false` takes the mark back (Undo) and keeps
26
+ * its switch. Taking it back never creates a row: no row means the file still decides.
27
+ */
28
+ export declare function setExperimentPromoted(sql: SqlClient, id: string, promoted: boolean): Promise<void>;
@@ -0,0 +1,63 @@
1
+ import { missingTable, withTable } from "./pgErrors.js";
2
+ /**
3
+ * The owner's switches for the experiments in the admin app: one row per experiment in Neon, read
4
+ * by the engine when a run starts and written by the admin app. A row overrides the `enabled` the
5
+ * experiment's config file holds in git, so switching one costs no commit and no deploy; no row
6
+ * means the file decides. `promoted_at` marks an experiment the owner has accepted as a feature; its
7
+ * flag stays on until the code that holds the switch is deleted. The table is created by its first
8
+ * write. No driver here: see neon.ts.
9
+ */
10
+ export const EXPERIMENT_FLAGS_SCHEMA_SQL = [
11
+ `create table if not exists experiment_flags (
12
+ id text primary key,
13
+ enabled boolean not null,
14
+ promoted_at timestamptz,
15
+ updated_at timestamptz not null default now()
16
+ )`,
17
+ ];
18
+ /** Every stored flag by experiment id. A table that does not exist yet holds none. */
19
+ export async function readExperimentFlags(sql) {
20
+ let rows;
21
+ try {
22
+ rows = await sql.query("select id, enabled, (extract(epoch from promoted_at) * 1000)::float8 as promoted_at from experiment_flags");
23
+ }
24
+ catch (error) {
25
+ if (missingTable(error))
26
+ return {};
27
+ throw error;
28
+ }
29
+ const flags = {};
30
+ for (const row of rows) {
31
+ flags[String(row.id)] = {
32
+ enabled: row.enabled === true,
33
+ promoted_at: row.promoted_at === null ? null : Number(row.promoted_at),
34
+ };
35
+ }
36
+ return flags;
37
+ }
38
+ /**
39
+ * Switches an experiment on or off; its promotion, if any, is kept. A promoted experiment stays
40
+ * on, so the write that would turn one off touches no row and answers false.
41
+ */
42
+ export async function setExperimentEnabled(sql, id, enabled) {
43
+ const rows = await withTable(sql, EXPERIMENT_FLAGS_SCHEMA_SQL, () => sql.query(`insert into experiment_flags (id, enabled) values ($1, $2)
44
+ on conflict (id) do update set enabled = excluded.enabled, updated_at = now()
45
+ where experiment_flags.promoted_at is null or excluded.enabled
46
+ returning id`, [id, enabled]));
47
+ return rows.length > 0;
48
+ }
49
+ /**
50
+ * Marks an experiment promoted, which also turns it on; `false` takes the mark back (Undo) and keeps
51
+ * its switch. Taking it back never creates a row: no row means the file still decides.
52
+ */
53
+ export async function setExperimentPromoted(sql, id, promoted) {
54
+ if (!promoted) {
55
+ await withTable(sql, EXPERIMENT_FLAGS_SCHEMA_SQL, () => sql.query("update experiment_flags set promoted_at = null, updated_at = now() where id = $1", [id]));
56
+ return;
57
+ }
58
+ await withTable(sql, EXPERIMENT_FLAGS_SCHEMA_SQL, () => sql.query(`insert into experiment_flags (id, enabled, promoted_at) values ($1, true, now())
59
+ on conflict (id) do update set
60
+ enabled = true,
61
+ promoted_at = now(),
62
+ updated_at = now()`, [id]));
63
+ }
@@ -0,0 +1,25 @@
1
+ import type { SqlClient } from "./configPaths.js";
2
+ /**
3
+ * What an experiment in shadow would have added, per pass of a run, kept in Neon beside the owner's
4
+ * switches (store/experimentFlags.ts) so the admin app can show it. A row is one pass's count and may
5
+ * be 0: a shadow that found nothing is evidence too, and no row at all means it has not run yet. Every
6
+ * write deletes what fell out of the window. No driver here: see neon.ts.
7
+ */
8
+ /** How far back the admin app reads, and how long a row is kept. */
9
+ export declare const EXPERIMENT_HITS_WINDOW_DAYS = 7;
10
+ export declare const EXPERIMENT_HITS_SCHEMA_SQL: readonly ["create table if not exists experiment_hits (\n at timestamptz not null,\n experiment_id text not null,\n pass text not null,\n stories integer not null\n)", "create index if not exists experiment_hits_at on experiment_hits (at)"];
11
+ /** One pass's count for one experiment. */
12
+ export interface ExperimentHit {
13
+ id: string;
14
+ pass: string;
15
+ stories: number;
16
+ }
17
+ /** The window's total for one experiment, and how many passes it was counted over. */
18
+ export interface ExperimentHitTotal {
19
+ stories: number;
20
+ passes: number;
21
+ }
22
+ /** Stores a run's counts at `now` (epoch ms) and drops the rows past the window. Throws when the database does. */
23
+ export declare function recordExperimentHits(sql: SqlClient, hits: readonly ExperimentHit[], now: number): Promise<void>;
24
+ /** Each experiment's window total; an experiment with no row has not run in shadow. A table that does not exist yet holds none. */
25
+ export declare function readExperimentHits(sql: SqlClient, now: number): Promise<Record<string, ExperimentHitTotal>>;
@@ -0,0 +1,69 @@
1
+ import { missingTable, withTable } from "./pgErrors.js";
2
+ /**
3
+ * What an experiment in shadow would have added, per pass of a run, kept in Neon beside the owner's
4
+ * switches (store/experimentFlags.ts) so the admin app can show it. A row is one pass's count and may
5
+ * be 0: a shadow that found nothing is evidence too, and no row at all means it has not run yet. Every
6
+ * write deletes what fell out of the window. No driver here: see neon.ts.
7
+ */
8
+ /** How far back the admin app reads, and how long a row is kept. */
9
+ export const EXPERIMENT_HITS_WINDOW_DAYS = 7;
10
+ export const EXPERIMENT_HITS_SCHEMA_SQL = [
11
+ `create table if not exists experiment_hits (
12
+ at timestamptz not null,
13
+ experiment_id text not null,
14
+ pass text not null,
15
+ stories integer not null
16
+ )`,
17
+ "create index if not exists experiment_hits_at on experiment_hits (at)",
18
+ ];
19
+ const PRUNE_SQL = `delete from experiment_hits
20
+ where at <= to_timestamp($1::float8 / 1000) - $2::int * interval '1 day'`;
21
+ /** Stores a run's counts at `now` (epoch ms) and drops the rows past the window. Throws when the database does. */
22
+ export async function recordExperimentHits(sql, hits, now) {
23
+ if (hits.length === 0) {
24
+ // Both lists are on: nothing to add, but what fell out of the window still goes.
25
+ try {
26
+ await sql.query(PRUNE_SQL, [now, EXPERIMENT_HITS_WINDOW_DAYS]);
27
+ }
28
+ catch (error) {
29
+ if (!missingTable(error))
30
+ throw error;
31
+ }
32
+ return;
33
+ }
34
+ await withTable(sql, EXPERIMENT_HITS_SCHEMA_SQL, () => sql.query(`with gone as (
35
+ delete from experiment_hits where at <= to_timestamp($1::float8 / 1000) - $5::int * interval '1 day'
36
+ )
37
+ insert into experiment_hits (at, experiment_id, pass, stories)
38
+ select to_timestamp($1::float8 / 1000), hit.id, hit.pass, hit.stories
39
+ from unnest($2::text[], $3::text[], $4::int[]) as hit(id, pass, stories)`, [
40
+ now,
41
+ hits.map((hit) => hit.id),
42
+ hits.map((hit) => hit.pass),
43
+ hits.map((hit) => hit.stories),
44
+ EXPERIMENT_HITS_WINDOW_DAYS,
45
+ ]));
46
+ }
47
+ /** Each experiment's window total; an experiment with no row has not run in shadow. A table that does not exist yet holds none. */
48
+ export async function readExperimentHits(sql, now) {
49
+ let rows;
50
+ try {
51
+ rows = await sql.query(`select experiment_id, sum(stories)::int as stories, count(*)::int as passes
52
+ from experiment_hits
53
+ where at > to_timestamp($1::float8 / 1000) - $2::int * interval '1 day'
54
+ group by experiment_id`, [now, EXPERIMENT_HITS_WINDOW_DAYS]);
55
+ }
56
+ catch (error) {
57
+ if (missingTable(error))
58
+ return {};
59
+ throw error;
60
+ }
61
+ const totals = {};
62
+ for (const row of rows) {
63
+ totals[String(row.experiment_id)] = {
64
+ stories: Number(row.stories),
65
+ passes: Number(row.passes),
66
+ };
67
+ }
68
+ return totals;
69
+ }
@@ -0,0 +1,8 @@
1
+ import type { SqlClient } from "./configPaths.js";
2
+ /**
3
+ * A client over Neon's HTTP endpoint: one fetch per query, nothing to close. The only module that
4
+ * imports the driver. It is loaded on demand: behind the store switch for config (D149), and on the
5
+ * site whenever DATABASE_URL is set, for the shared rate-limit counter (D150). The import is static
6
+ * within this module so Vercel's file tracing finds the package for the functions that use it.
7
+ */
8
+ export declare function connectNeon(databaseUrl: string): SqlClient;
@@ -0,0 +1,13 @@
1
+ import { neon } from "@neondatabase/serverless";
2
+ /**
3
+ * A client over Neon's HTTP endpoint: one fetch per query, nothing to close. The only module that
4
+ * imports the driver. It is loaded on demand: behind the store switch for config (D149), and on the
5
+ * site whenever DATABASE_URL is set, for the shared rate-limit counter (D150). The import is static
6
+ * within this module so Vercel's file tracing finds the package for the functions that use it.
7
+ */
8
+ export function connectNeon(databaseUrl) {
9
+ const sql = neon(databaseUrl);
10
+ return {
11
+ query: async (text, params) => (await sql.query(text, params ?? [])),
12
+ };
13
+ }
@@ -0,0 +1,11 @@
1
+ import type { SqlClient } from "./configPaths.js";
2
+ /** The table is not there yet (42P01): create it and try once more. */
3
+ export declare const missingTable: (error: unknown) => boolean;
4
+ /** Whether `error` is the duplicate a lost `create ... if not exists` race answers. */
5
+ export declare const lostCreateRace: (error: unknown) => boolean;
6
+ /**
7
+ * Runs `write`, and when the table is not there yet creates it (`schema`, each statement `if not
8
+ * exists`) and runs it once more. Two first writes at once can race to create it, and Postgres then
9
+ * refuses one with a duplicate; the write after says whether the table is there.
10
+ */
11
+ export declare function withTable<T>(sql: SqlClient, schema: readonly string[], write: () => Promise<T>): Promise<T>;
@@ -0,0 +1,29 @@
1
+ /** The Postgres error codes the stores that create their own table on first use act on. */
2
+ const errorCode = (error) => error?.code;
3
+ /** The table is not there yet (42P01): create it and try once more. */
4
+ export const missingTable = (error) => errorCode(error) === "42P01";
5
+ /** What Postgres answers the loser of two `create ... if not exists` racing: a duplicate. */
6
+ const DUPLICATE = new Set(["23505", "42P07"]);
7
+ /** Whether `error` is the duplicate a lost `create ... if not exists` race answers. */
8
+ export const lostCreateRace = (error) => DUPLICATE.has(String(errorCode(error)));
9
+ /**
10
+ * Runs `write`, and when the table is not there yet creates it (`schema`, each statement `if not
11
+ * exists`) and runs it once more. Two first writes at once can race to create it, and Postgres then
12
+ * refuses one with a duplicate; the write after says whether the table is there.
13
+ */
14
+ export async function withTable(sql, schema, write) {
15
+ try {
16
+ return await write();
17
+ }
18
+ catch (error) {
19
+ if (!missingTable(error))
20
+ throw error;
21
+ for (const statement of schema) {
22
+ await sql.query(statement).catch((raced) => {
23
+ if (!lostCreateRace(raced))
24
+ throw raced;
25
+ });
26
+ }
27
+ return write();
28
+ }
29
+ }
@@ -0,0 +1,69 @@
1
+ import type { SqlClient } from "./configPaths.js";
2
+ /**
3
+ * Every generation run in Neon (D221): one row in `pipeline_runs` and one per stage in
4
+ * `pipeline_stages`, written by the engine when a run ends, whatever its end (published, nothing,
5
+ * failed) and whatever its kind (a dry run, a regeneration, a Catch up). It is the measuring layer
6
+ * of the store-as-truth plan: timings and cost per stage that the committed run metrics and the
7
+ * cost ledger only hold for runs that published, and that Health reads without opening git.
8
+ * Writes are best effort and idempotent (a run id is written again with its newer end); the first
9
+ * write creates the tables. No driver here: see neon.ts.
10
+ */
11
+ export declare const RUNS_SCHEMA_SQL: readonly ["create table if not exists pipeline_runs (\n run_id text primary key,\n workflow text not null,\n kind text not null,\n mode text not null,\n replaces text,\n dry_run boolean not null,\n outcome text not null,\n podcasts text[] not null,\n started_at timestamptz not null,\n finished_at timestamptz not null,\n work_ms integer not null,\n total_ms integer not null,\n usd double precision not null,\n failure_stage text,\n failure_reason text,\n github_run_id text\n)", "create index if not exists pipeline_runs_started on pipeline_runs (started_at)", "create table if not exists pipeline_stages (\n run_id text not null,\n stage text not null,\n ms integer not null,\n usd double precision not null,\n primary key (run_id, stage)\n)"];
12
+ export type RunOutcome = "published" | "nothing" | "failed" | "cancelled";
13
+ /** One stage of a run: its wall time and what its paid calls cost, priced from config/prices.json. */
14
+ export interface StageRecord {
15
+ stage: string;
16
+ ms: number;
17
+ usd: number;
18
+ }
19
+ export interface RunRecord {
20
+ /** The cost ledger's run id (the plan's `created_at`, or an episode id), so the two join. */
21
+ run_id: string;
22
+ /** `run` (run.yml) or `podcast` (podcast.yml). */
23
+ workflow: "run" | "podcast";
24
+ /** The ledger's kind: daily, weekly, on_demand or catch_up. */
25
+ kind: string;
26
+ mode: string;
27
+ /** The digest or episode this run replaced, for a regeneration. */
28
+ replaces: string | null;
29
+ dry_run: boolean;
30
+ outcome: RunOutcome;
31
+ /** The digest or episode ids the run made (or tried to). */
32
+ podcasts: readonly string[];
33
+ started_at: string;
34
+ finished_at: string;
35
+ /** The stages' time added up: generation, without the wait for the publish time. */
36
+ work_ms: number;
37
+ /** Start to finish, the wait for the publish time included. */
38
+ total_ms: number;
39
+ usd: number;
40
+ failure: {
41
+ stage: string;
42
+ reason: string;
43
+ } | null;
44
+ /** GITHUB_RUN_ID, to open the run's log from a row. */
45
+ github_run_id: string | null;
46
+ stages: readonly StageRecord[];
47
+ }
48
+ /**
49
+ * Stores a finished run and its stages. Writing the same run again replaces it, so a retry or a
50
+ * second end (a failure record after a commit) keeps one row. Throws when the database does; the
51
+ * caller decides what a failure means (the engine only warns).
52
+ */
53
+ export declare function recordPipelineRun(sql: SqlClient, run: RunRecord): Promise<void>;
54
+ /** One stage's time over a window: the median and the slowest tenth of its runs, and its cost. */
55
+ export interface StageTiming {
56
+ workflow: string;
57
+ kind: string;
58
+ stage: string;
59
+ runs: number;
60
+ p50_ms: number;
61
+ p90_ms: number;
62
+ avg_usd: number;
63
+ }
64
+ /**
65
+ * Each stage's median and p90 time over the last `days` before `now` (epoch ms), per workflow and
66
+ * kind, from runs that finished: a dry run is a test of the pipeline and a failed or cancelled run stops
67
+ * early, so none says how long a stage takes. No table yet reads as no runs. Throws when the database does.
68
+ */
69
+ export declare function readStageTimings(sql: SqlClient, now: number, days?: number): Promise<StageTiming[]>;
@@ -0,0 +1,125 @@
1
+ import { missingTable, withTable } from "./pgErrors.js";
2
+ /**
3
+ * Every generation run in Neon (D221): one row in `pipeline_runs` and one per stage in
4
+ * `pipeline_stages`, written by the engine when a run ends, whatever its end (published, nothing,
5
+ * failed) and whatever its kind (a dry run, a regeneration, a Catch up). It is the measuring layer
6
+ * of the store-as-truth plan: timings and cost per stage that the committed run metrics and the
7
+ * cost ledger only hold for runs that published, and that Health reads without opening git.
8
+ * Writes are best effort and idempotent (a run id is written again with its newer end); the first
9
+ * write creates the tables. No driver here: see neon.ts.
10
+ */
11
+ export const RUNS_SCHEMA_SQL = [
12
+ `create table if not exists pipeline_runs (
13
+ run_id text primary key,
14
+ workflow text not null,
15
+ kind text not null,
16
+ mode text not null,
17
+ replaces text,
18
+ dry_run boolean not null,
19
+ outcome text not null,
20
+ podcasts text[] not null,
21
+ started_at timestamptz not null,
22
+ finished_at timestamptz not null,
23
+ work_ms integer not null,
24
+ total_ms integer not null,
25
+ usd double precision not null,
26
+ failure_stage text,
27
+ failure_reason text,
28
+ github_run_id text
29
+ )`,
30
+ "create index if not exists pipeline_runs_started on pipeline_runs (started_at)",
31
+ `create table if not exists pipeline_stages (
32
+ run_id text not null,
33
+ stage text not null,
34
+ ms integer not null,
35
+ usd double precision not null,
36
+ primary key (run_id, stage)
37
+ )`,
38
+ ];
39
+ const RUN_SQL = `insert into pipeline_runs (run_id, workflow, kind, mode, replaces, dry_run, outcome,
40
+ podcasts, started_at, finished_at, work_ms, total_ms, usd, failure_stage, failure_reason, github_run_id)
41
+ values ($1, $2, $3, $4, $5, $6, $7, $8::text[], $9::timestamptz, $10::timestamptz, $11, $12, $13, $14, $15, $16)
42
+ on conflict (run_id) do update set
43
+ started_at = least(pipeline_runs.started_at, excluded.started_at),
44
+ kind = excluded.kind, mode = excluded.mode, replaces = excluded.replaces,
45
+ dry_run = excluded.dry_run, outcome = excluded.outcome, podcasts = excluded.podcasts,
46
+ finished_at = excluded.finished_at, work_ms = excluded.work_ms,
47
+ total_ms = greatest(excluded.total_ms, least(2147483647, extract(epoch from excluded.finished_at
48
+ - least(pipeline_runs.started_at, excluded.started_at)) * 1000)::int),
49
+ usd = excluded.usd, failure_stage = excluded.failure_stage,
50
+ failure_reason = excluded.failure_reason, github_run_id = excluded.github_run_id`;
51
+ const STAGES_SQL = `insert into pipeline_stages (run_id, stage, ms, usd)
52
+ select $1, stage.name, stage.ms, stage.usd
53
+ from unnest($2::text[], $3::int[], $4::float8[]) as stage(name, ms, usd)
54
+ on conflict (run_id, stage) do update set ms = excluded.ms, usd = excluded.usd`;
55
+ /** Whole milliseconds, never negative, and inside Postgres' integer. */
56
+ const wholeMs = (value) => Math.min(2_147_483_647, Math.max(0, Math.round(Number.isFinite(value) ? value : 0)));
57
+ /**
58
+ * Stores a finished run and its stages. Writing the same run again replaces it, so a retry or a
59
+ * second end (a failure record after a commit) keeps one row. Throws when the database does; the
60
+ * caller decides what a failure means (the engine only warns).
61
+ */
62
+ export async function recordPipelineRun(sql, run) {
63
+ await withTable(sql, RUNS_SCHEMA_SQL, async () => {
64
+ await sql.query(RUN_SQL, [
65
+ run.run_id,
66
+ run.workflow,
67
+ run.kind,
68
+ run.mode,
69
+ run.replaces,
70
+ run.dry_run,
71
+ run.outcome,
72
+ [...run.podcasts],
73
+ run.started_at,
74
+ run.finished_at,
75
+ wholeMs(run.work_ms),
76
+ wholeMs(run.total_ms),
77
+ Math.round(run.usd * 1e6) / 1e6,
78
+ run.failure?.stage ?? null,
79
+ run.failure?.reason.slice(0, 280) ?? null,
80
+ run.github_run_id,
81
+ ]);
82
+ if (run.stages.length > 0) {
83
+ await sql.query(STAGES_SQL, [
84
+ run.run_id,
85
+ run.stages.map((stage) => stage.stage),
86
+ run.stages.map((stage) => wholeMs(stage.ms)),
87
+ run.stages.map((stage) => Math.round(stage.usd * 1e6) / 1e6),
88
+ ]);
89
+ }
90
+ });
91
+ }
92
+ const TIMINGS_SQL = `select r.workflow, r.kind, s.stage, count(*)::int as runs,
93
+ percentile_disc(0.5) within group (order by s.ms) as p50_ms,
94
+ percentile_disc(0.9) within group (order by s.ms) as p90_ms,
95
+ avg(s.usd) as avg_usd
96
+ from pipeline_stages s join pipeline_runs r using (run_id)
97
+ where r.started_at > to_timestamp($1::float8 / 1000) - $2::int * interval '1 day'
98
+ and r.dry_run = false and r.outcome in ('published', 'nothing')
99
+ group by r.workflow, r.kind, s.stage
100
+ order by r.workflow, r.kind, s.stage`;
101
+ /**
102
+ * Each stage's median and p90 time over the last `days` before `now` (epoch ms), per workflow and
103
+ * kind, from runs that finished: a dry run is a test of the pipeline and a failed or cancelled run stops
104
+ * early, so none says how long a stage takes. No table yet reads as no runs. Throws when the database does.
105
+ */
106
+ export async function readStageTimings(sql, now, days = 14) {
107
+ let rows;
108
+ try {
109
+ rows = await sql.query(TIMINGS_SQL, [now, days]);
110
+ }
111
+ catch (error) {
112
+ if (missingTable(error))
113
+ return [];
114
+ throw error;
115
+ }
116
+ return rows.map((row) => ({
117
+ workflow: String(row.workflow),
118
+ kind: String(row.kind),
119
+ stage: String(row.stage),
120
+ runs: Number(row.runs),
121
+ p50_ms: Number(row.p50_ms),
122
+ p90_ms: Number(row.p90_ms),
123
+ avg_usd: Number(row.avg_usd),
124
+ }));
125
+ }
package/package.json CHANGED
@@ -1,6 +1,35 @@
1
1
  {
2
2
  "name": "@singularityfm/contracts",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "0.1.0",
4
+ "description": "Singularity's store readers and admin session cookie, compiled for apps outside the main repository.",
5
+ "license": "UNLICENSED",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/somesh-ks/singularityfm-app.git",
9
+ "directory": "packages/contracts"
10
+ },
11
+ "type": "module",
12
+ "sideEffects": false,
13
+ "exports": {
14
+ "./session": {
15
+ "types": "./dist/contracts/src/session.d.ts",
16
+ "default": "./dist/contracts/src/session.js"
17
+ },
18
+ "./store": {
19
+ "types": "./dist/contracts/src/store.d.ts",
20
+ "default": "./dist/contracts/src/store.js"
21
+ }
22
+ },
23
+ "publishConfig": {
24
+ "access": "public"
25
+ },
26
+ "files": [
27
+ "dist"
28
+ ],
29
+ "dependencies": {
30
+ "@neondatabase/serverless": "1.2.0"
31
+ },
32
+ "scripts": {
33
+ "build": "tsc -p tsconfig.build.json"
34
+ }
6
35
  }
package/README.md DELETED
@@ -1,3 +0,0 @@
1
- # Temporary Holding Version
2
-
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.