Skip to content

Commit 0afd2e2

Browse files
authored
Merge pull request #7 from DevelopmentFinanceObservatory/release/website
Sync dfo-website@4bf8656d50a0
2 parents 9f19868 + 8ce1d70 commit 0afd2e2

236 files changed

Lines changed: 19250 additions & 1529 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎.env.example‎

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,15 @@ NEXT_PUBLIC_SEARCH_API_BASE=
1818
STUDIO_API_BASE=
1919
STUDIO_TOKEN=
2020

21+
# Live Studio shares. LIVE_REFRESH_PRINCIPAL is the credential dfo-apis and
22+
# dfo-ai ask of a card refresh. It has the same value on all three services
23+
# and, when deployed, is mounted from Secret Manager by dfo-infra. Empty
24+
# means the Share dialog offers no Live Data and every Studio share is a
25+
# snapshot. LIVE_SHARE_WINDOW_S is how long a refreshed card's answer stands
26+
# before the next viewer asks again (default 300, at most 900).
27+
LIVE_REFRESH_PRINCIPAL=
28+
# LIVE_SHARE_WINDOW_S=300
29+
2130
# Accounts and saved conversations (Postgres). Local development only --
2231
# run `just db-up && just db-migrate` first, then this connects as the
2332
# `website_runtime` role db/migrate-local.ts creates. Deployed services
@@ -71,12 +80,15 @@ SENTRY_AUTH_TOKEN=
7180
# --- Platform proxy -------------------------------------------------------------
7281
# The DFO APIs and the MCP servers are mirrored 1:1 under this site's domain
7382
# (/api/search|datasets|graph/* -> dfo-apis, /api/studio/* -> dfo-ai, and
74-
# /mcp -> dfo-ai's combined MCP server at /mcp/). /api/* has a per-IP rate
75-
# limit shared through Redis, and /mcp has none. Without REDIS_URL the proxy
76-
# runs without limits.
83+
# /mcp -> dfo-ai's combined MCP server at /mcp/). /api/* and /mcp have a
84+
# per-IP rate limit shared through Redis. Without REDIS_URL the proxy runs
85+
# without limits and logs one warning at startup.
7786
REDIS_URL=
7887
# RATE_LIMIT_WINDOW_S=60
7988
# RATE_LIMIT_API=120
89+
# RATE_LIMIT_PIN=10
90+
# /mcp is higher because a vendor's address can carry many users.
91+
# RATE_LIMIT_MCP=300
8092
# The harness's own token for its MCP server (MCP_API_TOKEN on dfo-ai);
8193
# attached by the proxy so outside MCP clients don't need it. Unset: the
8294
# caller's own Authorization header is passed through.
@@ -89,3 +101,7 @@ CALLER_HMAC_KEY=
89101
# default) is the rightmost, the one Cloud Run's front end appends; add one
90102
# for each load balancer in front of the service.
91103
# TRUSTED_PROXY_HOPS=0
104+
# OpenAI's domain-verification token for the app directory. GET
105+
# /.well-known/openai-apps-challenge returns it as plain text, and 404 while
106+
# it is unset.
107+
OPENAI_APPS_CHALLENGE=

‎db/card-ref-backfill.ts‎

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
import type { Pool } from 'pg';
2+
3+
import type { CardRef, FrameRef } from '../src/lib/chat/widgets';
4+
import type { StoredCard } from '../src/lib/studio/cardRefMatch.ts';
5+
6+
/**
7+
* The database side of the card reference backfill
8+
* (scripts/backfill-card-refs.ts): the counts it reports, the stored
9+
* `widgets` arrays it reads, and the write that gives a matched card its
10+
* `ref` and `frame`. The matching itself is src/lib/studio/cardRefMatch.ts.
11+
*/
12+
13+
/** The tables whose `data` holds Studio cards under `widgets`. */
14+
export type CardTable = 'conversation' | 'share';
15+
16+
// A `search` share holds no cards, so only the Studio kinds are read.
17+
const ROWS: Record<CardTable, string> = {
18+
conversation: 'conversation',
19+
share: `share WHERE kind IN ('conversation', 'canvas', 'card')`,
20+
};
21+
22+
export type CardCounts = {
23+
// Shares by kind and mode.
24+
shares: { kind: string; mode: string | null; count: number }[];
25+
// Per table: data cards with no component reference, and cards drawn
26+
// from a derived (`ops_`) frame by the week the row was created.
27+
tables: Record<
28+
CardTable,
29+
{ rows: number; withoutRef: number; derivedByWeek: { week: string; count: number }[] }
30+
>;
31+
};
32+
33+
const tableCounts = async (
34+
pool: Pool,
35+
table: CardTable,
36+
): Promise<CardCounts['tables'][CardTable]> => {
37+
const rows = await pool.query<{ count: number }>(
38+
`SELECT count(*)::int AS count FROM ${ROWS[table]}`,
39+
);
40+
const withoutRef = await pool.query<{ count: number }>(
41+
`SELECT count(*)::int AS count
42+
FROM (SELECT data FROM ${ROWS[table]}) s, jsonb_array_elements(s.data->'widgets') w
43+
WHERE w->>'kind' <> 'text' AND NOT (w ? 'ref')`,
44+
);
45+
const derived = await pool.query<{ week: string; count: number }>(
46+
`SELECT to_char(date_trunc('week', s.created_at), 'YYYY-MM-DD') AS week, count(*)::int AS count
47+
FROM (SELECT data, created_at FROM ${ROWS[table]}) s,
48+
jsonb_array_elements(s.data->'widgets') w
49+
WHERE w->'frame'->>'spec_id' LIKE 'ops\\_%'
50+
GROUP BY 1 ORDER BY 1`,
51+
);
52+
53+
return {
54+
rows: rows.rows[0]?.count ?? 0,
55+
withoutRef: withoutRef.rows[0]?.count ?? 0,
56+
derivedByWeek: derived.rows,
57+
};
58+
};
59+
60+
/** The counts that size the backfill, over `share` and `conversation`. */
61+
export const readCardCounts = async (pool: Pool): Promise<CardCounts> => {
62+
const shares = await pool.query<CardCounts['shares'][number]>(
63+
'SELECT kind, mode, count(*)::int AS count FROM share GROUP BY 1, 2 ORDER BY 1, 2',
64+
);
65+
66+
return {
67+
shares: shares.rows,
68+
tables: {
69+
share: await tableCounts(pool, 'share'),
70+
conversation: await tableCounts(pool, 'conversation'),
71+
},
72+
};
73+
};
74+
75+
/** The ids of a table's rows that hold cards, in id order. */
76+
export const readCardRowIds = async (pool: Pool, table: CardTable): Promise<string[]> => {
77+
const { rows } = await pool.query<{ id: string }>(`SELECT id FROM ${ROWS[table]} ORDER BY id`);
78+
79+
return rows.map((row) => row.id);
80+
};
81+
82+
/** One row's stored `widgets`, or undefined when the row is gone. */
83+
export const readWidgets = async (pool: Pool, table: CardTable, id: string): Promise<unknown> => {
84+
const { rows } = await pool.query<{ widgets: unknown }>(
85+
`SELECT data->'widgets' AS widgets FROM ${table} WHERE id = $1`,
86+
[id],
87+
);
88+
89+
return rows[0]?.widgets;
90+
};
91+
92+
/** A matched card of one row: where it sits in `widgets`, the card as it
93+
* was read, and what the match gives it. */
94+
export type CardPatch = { index: number; card: StoredCard; ref: CardRef; frame: FrameRef };
95+
96+
/**
97+
* Gives each patched card of one row its `ref` and `frame`, in one
98+
* transaction, and resolves how many cards it wrote.
99+
*
100+
* A card is written only while the stored card at its index still equals
101+
* the card the match read. That card had no `ref`, so a card that has one
102+
* is never written, a second run writes nothing, and a row saved again
103+
* since the read keeps the cards it changed. No other member of `data` and
104+
* no other column is written.
105+
*/
106+
export const writeCardRefs = async (
107+
pool: Pool,
108+
table: CardTable,
109+
id: string,
110+
patches: CardPatch[],
111+
): Promise<number> => {
112+
const client = await pool.connect();
113+
114+
try {
115+
await client.query('BEGIN');
116+
117+
let written = 0;
118+
119+
for (const { index, card, ref, frame } of patches) {
120+
const result = await client.query(
121+
`UPDATE ${table}
122+
SET data = jsonb_set(data, ARRAY['widgets', ($2::int)::text],
123+
(data->'widgets'->$2::int) || $3::jsonb)
124+
WHERE id = $1 AND data->'widgets'->$2::int = $4::jsonb`,
125+
[id, index, JSON.stringify({ ref, frame }), JSON.stringify(card)],
126+
);
127+
128+
written += result.rowCount ?? 0;
129+
}
130+
131+
await client.query('COMMIT');
132+
133+
return written;
134+
} catch (error) {
135+
await client.query('ROLLBACK');
136+
137+
throw error;
138+
} finally {
139+
client.release();
140+
}
141+
};
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
CREATE TABLE "share_card_result" (
2+
"share_id" text NOT NULL,
3+
"card_id" text NOT NULL,
4+
"status" text,
5+
"card" jsonb,
6+
"frame" jsonb,
7+
"code" text,
8+
"retryable" boolean,
9+
"retry_after_s" integer,
10+
"checked_at" timestamp with time zone,
11+
"fresh_until" timestamp with time zone,
12+
"lease_until" timestamp with time zone,
13+
CONSTRAINT "share_card_result_share_id_card_id_pk" PRIMARY KEY("share_id","card_id")
14+
);
15+
--> statement-breakpoint
16+
ALTER TABLE "share_card_result" ADD CONSTRAINT "share_card_result_share_id_share_id_fk" FOREIGN KEY ("share_id") REFERENCES "public"."share"("id") ON DELETE cascade ON UPDATE no action;

0 commit comments

Comments
 (0)