Entity graphs on managed Postgres
The working example serves its read surface through
an edge cache, so it keeps answering from stale responses even while the
disposable database behind it is destroyed - x-pggraph-cache on every
response says which. The measurements below remain the reference for the
design, and two corpora now sit under them: seven public-domain US federal
documents (1521 citation entities, 10016 mentions, 14233 edges) and 103
Australian local-government public documents - one council’s minutes,
committee papers, contracts registers and tender notices across 2015-2026 -
which added the people, organisations and registry identifiers the editorial
questions actually ask about (4787 entities, 28778 edges total). No graph
database anywhere in it.
That line is a claim about capability, not category, so here is the boundary. The requirement this answers is a set of graph questions - who is connected to whom, by how strong a chain, clustered how, traceable to which source - not a graph product. A dedicated graph store is the right tool when the requirement is one of these instead: Cypher-grade pattern matching as the team’s primary query language, unbounded or deep variable-length traversal under concurrent load at much larger scale, or an existing investment in a graph-native ecosystem. This workload is none of those: read-heavy, batch-loaded, bounded-hop and provenance-heavy. If it eventually becomes one of those, the relational work below is not wasted - the extraction, resolution and provenance layers are the parts every pipeline keeps regardless of which store sits at the end.
To build it, follow the implementation guide
- same system, ordered steps, one verification per part.
Everything below was measured on disposable projects: Postgres 17.6,
medium compute (2 vCPU / 4 GB), in ap-southeast-1 between 2026-08-10 and
2026-08-11, and in ap-southeast-2 on 2026-08-14 for the editorial corpus
run. Query
latencies are EXPLAIN ANALYZE’s own Execution Time with the cold first run
discarded, so they are database work rather than round trip. Read the compute as
a floor - a real deployment provisions more. Claims that are documented rather
than measured are marked in Evidence.
TL;DR:
- No property-graph store. Apache AGE is absent from the 78 available extensions, and there is no Cypher.1 Say so plainly.
pgrouting3.4.1 is available and is not geospatial-only: 209pgr_*functions, including Dijkstra, breadth- and depth-first search, k-shortest paths, connected components, articulation points, bridges and transitive closure, over ordinary relational edge tables.2- Recursive CTE traversal mostly settles the question. Depth-3 neighbourhood over 100000 entities / 400000 edges runs in about a fifth of a millisecond with two indexes present, against roughly 167.59 milliseconds without them, on a 602 millisecond index build,3 and holds flat to 64 concurrent readers (p50 ~0.3 ms, zero errors).
- Two name collisions derail this discussion.
pg_graphqlis a GraphQL API over tables.4ltreemodels single-parent hierarchies, and an entity graph is not a tree.5 - SQL/PGQ is committed for PG19 and does not change the engine - it is rewritten to a standard relational query in the rewriter.6
- Extraction belongs outside the database, and which runtime you pick moves the ceiling by 5.6x.
- Do not size a corpus from its PDF bytes. The extracted-text ratio measured 0.048 to 1.0885 across seven documents.
- Measured on a fixed corpus. The evidence below separates measured behaviour from documented claims. The person and organization extractor is a candidate-generation example, not a production named-entity recognizer: a 24-row review of 561 persons and 2284 organizations found truncated names, labels split at line breaks and occasional sentence-level over-captures.
- Cross-document bridging is the payoff, and the corpus has to be built for it. 20 of the 1521 US citation entities span documents; the council corpus engineered for overlap puts sitting councillors in 86-93 of its 103 documents.
- Registry identifiers make resolution arithmetic, not similarity. Every ABN candidate shape in the corpus (148 of them) was checked against the mod-89 checksum at extraction: 14 valid mentions, 134 rejected, zero false positives surviving.
- Real co-occurrence graphs have hubs, and hubs break naive walks. A path-set recursive walk (UNION ALL) took 65721.220 ms at depth 3 on the 28778-edge real graph; level-set semantics (UNION) answers the same query in 154.924 ms. The synthetic benchmark never showed it: its average degree is 8.
Topology
Section titled “Topology”The three stages on the left are yours. That split is not a limitation of Postgres so much as how document-to-graph pipelines are built generally: the graph store is the last and most swappable stage, and the hard parts are extraction, entity resolution and provenance.
Which traversal option
Section titled “Which traversal option”| Question | Use | Measured |
|---|---|---|
| Who is within N hops | Recursive CTE written as a level set (UNION), indexes on both edge columns | 0.22 ms at depth 3 on the synthetic graph (average degree 8); 154.924 ms on the real 28778-edge graph, and 65721.220 ms if the walk is a path set (UNION ALL) |
| What connects these two, how strongly | pgr_dijkstra, cost as inverse evidence weight | 1092.61 ms over 400000 edges |
| Does the data cluster | pgr_connectedComponents | 1098.11 ms |
| What is structurally critical | pgr_articulationPoints, pgr_bridges | 9883.44 ms, 254 bridges |
| Which things are named similarly | pg_trgm + fuzzystrmatch | index-backed |
| Which documents mention X | STORED tsvector generated column + GIN, queried with websearch_to_tsquery | 112.6 ms median inner query, GIN 3192 kB, on 7 rows |
| Which things mean something similar | pgvector as halfvec with an HNSW index | 0.43 ms HNSW query against 46.13 ms IVFFlat (lists=100) over 30000 x 1536 synthetic vectors; recall unmeasured - see Agent memory store |
The CTE expands a bounded neighbourhood; pgr_dijkstra computes a global
shortest path across the whole edge set.
The edge-table contract
Section titled “The edge-table contract”pgr_* functions require an inner query exposing columns named exactly id,
source, target and cost, optionally reverse_cost. The first three are
typed ANY-INTEGER and the costs ANY-NUMERICAL.7 Two
consequences:
- A
uuidprimary key cannot be handed topgr_*. Carry a surrogate integer key on entities if you want the algorithms. - Naming your columns
source_idandtarget_id, the natural relational choice, forces an aliasing subquery at every call site.
Setting cost to the inverse of an evidence weight makes the cheapest path the
most strongly evidenced one rather than the shortest hop count.
What the extension catalogue holds
Section titled “What the extension catalogue holds”78 extensions available. Read the graph answer as two facts, because either alone misleads:
| Capability | Status |
|---|---|
| Property-graph store: node/edge storage plus a traversal language | None available. No age, agensgraph, apache_age, sqlg |
| Graph-algorithm library: algorithms over relational edge tables | pgrouting 3.4.1, requiring postgis |
“No graph database” overstates the gap. “There is a graph extension” overstates the capability. Both sentences are needed.
Also present, none of them graph stores but several of them load-bearing below:
| Extension | Version | Role in this design |
|---|---|---|
vector | 0.8.2 | semantic similarity; storage, build and query latency measured (G05), recall not |
pg_trgm | 1.6 | name resolution, fuzzy fallback |
fuzzystrmatch | 1.2 | name resolution, fuzzy fallback |
unaccent | 1.1 | name normalisation |
ltree | 1.3 | hierarchies only - not the answer here |
rum | 1.3 | ranking indexes |
pgroonga | 3.2.5 | full-text search |
pgmq | 1.5.1 | ingestion queue |
pg_cron | 1.6.4 | scheduling |
pg_net | 0.20.4 | outbound HTTP from SQL |
http | 1.6 | outbound HTTP from SQL |
pg_partman | 5.3.1 | table partitioning |
hypopg | 1.4.1 | hypothetical indexes |
The two name collisions
Section titled “The two name collisions”pg_graphqlexposes a GraphQL API over relational tables.4 It shares nothing with a graph database but the word. Anyone grepping an extension list forgraphfinds it and reports the question closed.ltreemodels labelled paths through single-parent hierarchies.5 A tree is not a graph; it fails on the first node with two parents.
Apache AGE and SQL/PGQ
Section titled “Apache AGE and SQL/PGQ”Both get offered as the answer. Neither is, today.
Apache AGE is not installable on managed Postgres here. It is absent from
the 78 available extensions, so CREATE EXTENSION age cannot succeed. Upstream
is active - releases exist for PG17 and PG18, and a PG19 branch exists - but
those are release candidates, and availability is decided by whether the
provider packages it.8 That makes it a packaging request, not a design
option.
SQL/PGQ is real, committed, and syntax rather than an engine. It was
committed on 2026-03-16 into the PG19 cycle, implementing the SQL:2023 standard’s
GRAPH_TABLE pattern matching plus CREATE PROPERTY GRAPH DDL.6
9 Three things follow:
- PG19 is unreleased. The newest supported major series is PG18,10 and the platform measured here runs 17.6 - so the wait is PG19 general availability and then provider adoption.
- The commit notes the security-definer variant is not implemented.
- A property graph is a new relkind that, in the commit’s own words, “acts like a view in many ways” and “is rewritten to a standard relational query in the rewriter.”
That third point decides it. PGQ replaces hand-rolled recursive CTEs with declarative graph patterns, which is a real improvement in how queries are written. The plan underneath is the relational plan measured above. Waiting buys ergonomics; the capability is already here.
Extraction, and where it can run
Section titled “Extraction, and where it can run”PDF-to-text and OCR extensions exist. pgpdf adds a pdf type castable from
bytea, parsing via poppler.11 pg_ocr runs an ONNX pipeline
in-database.12 Neither is reachable on managed Postgres: neither is in the
catalogue, and pg_ocr needs plpython3u while the only procedural languages
available are plpgsql and plpgsql_check. Untrusted PL is arbitrary code
execution as the database user, so its absence is a deliberate provider position.
It would not pay off anyway. Extracting all seven documents with pdftotext
took about eleven seconds, a few percent of the pipeline, and pgpdf uses
poppler too - so the best case is the same work on the database’s CPU instead of
a stateless worker’s. Parsing is also the stage that most wants to scale
horizontally, and the database is the component that cannot.
One boundary belongs in writing before anyone discovers it: scanned documents extract to nothing, silently. A corpus spanning decades includes scans, and a born-digital pipeline produces zero entities from them and looks broken. Detection is measured: an image-only scan yields 0.5 characters per page against 2710-3242 for born-digital references, so anything under roughly 270 characters per page routes to OCR. OCR itself is another external stage, not a database one.
The runtime ceiling moves by 5.6x
Section titled “The runtime ceiling moves by 5.6x”Same task, same fixtures, same unpdf/pdf.js build, both V8 isolates - so this
measures the runtime envelope, not the parser.
| Fixture bytes | Supabase Edge Function | Cloudflare Worker |
|---|---|---|
| 191290 | ok | ok - 40 pages |
| 2504695 | ok, its ceiling | ok - 188 pages |
| 3595043 | HTTP 546 | ok - 894 pages |
| 6073678 | HTTP 546 | ok - 492 pages |
| 14034445 | HTTP 546 | ok - 2780 pages |
| 14930674 | HTTP 546 | 1102 / HTTP 503 |
Cloudflare’s failure was disambiguated rather than left as “resource limit”:
error 1102 covers CPU or memory indistinguishably from the HTTP response, and
the analytics API reported exceededMemory. So it is the 128 MB per-isolate
limit, not CPU - the CPU ceiling was raised to the documented paid-plan
maximum.13
The Edge Function has a second bound the fixtures never reached: a 150 s idle
wall clock, after which the invoke dies with 504 IDLE_TIMEOUT (edge-resilience
W13, 2026-08-15). The two 14 MB fixtures failed with the 546 at 57165 ms and
60136 ms, well inside 150 s, so on this corpus the resource limit binds before
the idle clock.
Hosting the read UI
Section titled “Hosting the read UI”A static read UI can live anywhere, since every data call goes browser to
PostgREST with an anon key. Supabase Storage is not one of those places: it
records index.html as text/html and serves it as text/plain with
x-content-type-options: nosniff, so a browser renders the source as text. JS
and CSS serve with correct types, so the downgrade is HTML-specific and
deliberate - a public bucket takes arbitrary uploads, and serving HTML as HTML
would make it a stored-XSS surface on the project’s own origin.
The live demo is therefore a Cloudflare Workers static-assets deployment, which
serves text/html correctly. The same worker also proxies the read RPCs through
the documented Cache-API pattern for POSTs - body hashed into a synthetic GET
cache key - which buys two properties the demo’s disposable database needs.
Repeated reads (stats, the document inventory, the bridging list) answer at
the edge in single-digit milliseconds rather than round-tripping to the project
region. And when the project is destroyed between engagements, the demo keeps
answering from stale cache entries instead of 502ing: freshness is tracked with
an x-cached-at stamp against a six-hour TTL, and any origin failure falls back
to whatever is stored, marked x-pggraph-cache: STALE. Two implementation notes
cost real time to learn: the Cache API rejects put() of any response carrying
Set-Cookie (Cloudflare’s own edge adds __cf_bm, so strip it on the stored
copy or every request is a silent permanent MISS), and the store is
per-colocation with an asynchronous write, so two back-to-back probes can both
MISS without anything being broken.
Driving ingestion: a queue in the database, workers outside it
Section titled “Driving ingestion: a queue in the database, workers outside it”Once extraction lives outside the database, something has to hand work to it and
collect the results. pgmq is available on the platform, alongside pg_cron,
pg_net and http,1516 and the pattern of queue-in-Postgres plus
an external consumer is already documented for embedding
generation.1718
What makes it a good fit here:
- The queue and the data are one database, so a consumer can delete the message and insert the extracted rows in a single transaction. A separate broker reintroduces the dual-write problem and needs idempotency keys to cover it.
- Consumers are stateless, which is what parsing wants: it is the stage that parallelises, and the database is the component that cannot be scaled out by adding replicas.
- Scale-to-zero consumers make a backfill cost nothing between runs.19
- Region matters: put consumers in the region hosting the database, not wherever is convenient.20
The documented version of this pattern uses serverless functions as the consumer. For PDF work that substitution is wrong on the measurements above - the platform’s own function runtime stopped at 2504695 bytes. A long-running machine has neither that ceiling nor the 128 MB isolate limit.
Three more things to set deliberately:
- Bulk writes should not go through the transaction pooler. It silently drops connection-time options, and large TOASTed writes are not what it is for. Use the direct connection for ingestion and keep the pooler for the read API.
- Visibility timeout must exceed the real p99 job duration. Measure first. Too low duplicates work; too high means a crashed consumer holds its message that long.
- A queue can be over-engineering. For a one-time historical backfill with a light ongoing tail, a bounded parallel runner is enough - fetching 40 MB across seven documents took about a minute at seven-way concurrency. Reach for the queue when arrival is continuous and unpredictable, or when per-document retry and audit are requirements.
Sizing a corpus you have not extracted yet
Section titled “Sizing a corpus you have not extracted yet”The shortcut is to assume extracted text is a small fraction of the PDF and multiply. Measured across seven public-domain federal documents:
| Genre | extracted / source | TOAST / logical |
|---|---|---|
| Positioned form | 0.048 | 0.4765 |
| Standard, control catalogue | 0.2573 | 0.3744 |
| Budget narrative | 0.2775 | 0.4132 |
| Legislation | 0.5799 | 0.3464 |
| Annotated constitution | 0.8490 | 0.4668 |
| Budget appendix | 0.9049 | 0.3387 |
| Dense regulation | 1.0885 | 0.3896 |
| Aggregate | 0.7633 | 0.3965 |
- Extracted text can exceed its own PDF. Dense regulation came out above 1.0, because PDF already compresses its content streams.
- Genre does not predict the ratio. The two regulation documents landed at 0.8490 and 1.0885; the two budget documents at 0.2775 and 0.9049. Text density and prior compression predict it.
- TOAST roughly halves the stored size again, so logical bytes and database footprint are also different numbers.
A projection built on 5% is wrong by roughly 6x at the aggregate and by more than 20x on the least favourable document. Extract a genre-spanning sample and measure. Quote the result per document: a corpus projection stated per month of infrastructure cannot be compared against anything priced per document, and the comparison gets made in whatever units are quoted.
A worked figure from the council corpus (G12, inputs measured 2026-08-14; the per-GB rates are Supabase list prices dated 2026-08-102122): a minutes-like document ran about 430 KB of source PDF to about 11 KB of logical text and about 5.4 KB on disk after TOAST. At $0.125/GB/mo for database disk and $0.0213/GB/mo for the raw PDF in Storage, that is about a cent per thousand documents per month on the Supabase side, compute not priced. A 4 TB source corpus at that ratio projects to about 102 GB logical and about 50 GB on disk - roughly $6/mo of disk plus $85/mo of raw storage, about $92/mo; the same 4 TB at the US federal corpus’s 0.7633 aggregate ratio projected to about $239/mo. Genre moved the projection 2.6x between two measured corpora, which is why the per-document figure has to come from a sample of the reader’s own genres.
People, organisations and time
Section titled “People, organisations and time”The citation layer proves the machinery; the editorial question is about people and organisations. Four properties of the loaded corpus matter more than its size.
Bridging is engineered, not found
Section titled “Bridging is engineered, not found”20 of the 1521 US citation entities appear in more than one document - 1.3%,
which is arithmetic rather than a flaw: seven documents across five genres
share almost no citation surface. A corpus built to demonstrate bridging is
one issuing body’s ordinary minutes, committee minutes, contracts registers
and tender notices across consecutive years, where the same councillors and
contractors recur by construction. Measured on the loaded graph: the top
bridging entities are the sitting councillors at 86-93 of 103 documents each.
The query side is cross_document_entities() - every entity spanning two or
more documents, with the slugs attached.
Person and organization extraction is the noisy stage
Section titled “Person and organization extraction is the noisy stage”The deterministic honorific and suffix patterns found 292 distinct persons and 123 organisations across the council documents (the 2026-08-14 run with the AU patterns; the 561 / 2284 counts in the TL;DR come from the 2026-08-11 US-pattern extractor over the earlier corpus and are recorded in this doc only, not in the RUNLOG). A graded 12+12 sample shows what to expect of them:
- over-captured leading words (“Council accept the tender from Stabilicorp Pty Ltd”)
- line-break splits and newline-glued names
- honorific-only rows (“Mrs”)
- job titles as people - “Director Corporate” spans 92 documents and is not one
Two organic lessons the US review could not teach: council resolutions are set
in ALL CAPS, so the first Pty Ltd suffix pattern missed 3 of 103 documents
until the suffix was made case-flexible, and a source-document typo (“Cr Jo
Willians”) survives as its own entity - the fuzzy-resolution case arriving on
its own. A production pipeline puts a model pass and a review step here; the
demo’s job is the machinery, with the precision labelled rather than tuned
toward plausible.
The time axis is a date on every mention and edge
Section titled “The time axis is a date on every mention and edge”The editorial question is temporal - dealt with, five years ago, and in twenty
other places. Minutes and filings are dated, so the metadata is free at
ingestion: doc_date sits on every mention and edge, filled by a BEFORE
INSERT trigger so no extractor needs to know. An entity’s history is
entity_timeline() in document-date order, and every read has an as-at form:
bridges_as_at() and neighbourhood_as_at() traverse only edges whose
documents existed by the date. Measured discrimination on the recurring local
builder: 0 neighbours as at 2020-01-01 against 171 as at 2026-12-31. The US
documents carry no dates and are simply absent from as-at answers, which is
the honest reading of “as at”: only what was known by then.
Registry identifiers make resolution arithmetic
Section titled “Registry identifiers make resolution arithmetic”pg_trgm and fuzzystrmatch stay as the fallback. Australian documents print
ABNs, and an ABN carries a checksum - subtract 1 from the first digit, weight
by 10,1,3,5,7,9,11,13,15,17,19, and the sum must divide by 89. A checksum
turns extraction into proof: 148 eleven-digit candidate shapes across the
corpus, 14 valid mentions, 3 distinct entities, 134 rejected by arithmetic,
zero false positives surviving. The join is exact where a key exists: the
organisation WHITE ROCK WIND FARM PTY LTD co-occurs in a 2015 committee paper
with its printed ABN, so a co-proximity edge pins the identity
(entity_registry_ids() surfaces it), while the 2022 minutes name the same
venture’s community fund through a councillor appointment. No fuzzy match in
the pin; the trigram only links the name variants. Zero ACN tokens exist in
this corpus, so no acn kind was built - an example of the corpus deciding
the scope.
The visual is a renderer, not a build
Section titled “The visual is a renderer, not a build”The traversal, bridging and cluster queries already existed, so the graph
view is a radial node-link renderer over subgraph() output - concentric
rings by CTE depth, no force layout, no dependency. Two discipline notes from
doing it: hub nodes make full edge sets unreadable (the view draws the top 24
nodes by weight, top 3 edges per node, and says so), and the proof the graph
is real is the table beside the picture, not the picture.
What to do about it
Section titled “What to do about it”The practices the measurements support. Module ids are pdf-corpus-graph
RUNLOG sections in supabase-lab
unless named otherwise.
| Practice | Rests on |
|---|---|
Write the recursive walk with UNION (level set), never UNION ALL, and cap the depth: a degree-917 hub took the path-set walk to about 65721 ms at depth 3 on the 28778-edge graph; the level-set walk answers in 155-159 ms warm over the pooler | the fusion run, 2026-08-14 (RUNLOG figures) |
Carry a surrogate integer key and name the edge columns id, source, target, cost; set cost to the inverse evidence weight | G07; the pgRouting contract7 |
Store embeddings as halfvec (0.5042 of vector bytes on identical columns) and index with HNSW (0.43 ms query against 46.13 ms IVFFlat at lists=100; build 6950 ms against 4130 ms); make no recall claim from synthetic vectors | G05 |
Provision disk throughput deliberately for a read-heavy corpus with vector indexes; it is a separate addon field (baseline_disk_io_mbs=347, max_disk_io_mbs=2085), not a function of disk size | G06; Cost shape |
| Quote cost per document from measured per-genre ratios with a worked example, and label the per-GB rates as list prices with their date; genre moved the same 4 TB from about $92/mo to about $239/mo | G12 |
| Set per-document fetch timeouts independent of byte size and cache fetched bytes: fetch was 9788 ms of an 11601 ms request at 2504695 bytes, and failures were not monotonic in size (6073678 bytes in 4105 ms, 3595043 bytes in 16906 ms, the 14 MB fixtures about a minute) | G02 |
Give keyword search its own row: a STORED tsvector generated column with a GIN index, queried with websearch_to_tsquery so raw user input does not error (GIN 3192 kB; 112.6 ms median inner query; sequential scan chosen at 7 rows) | search tier |
When profiling, EXPLAIN ANALYZE the inner query or set pg_stat_statements.track = all; security-definer RPC bodies do not inline and are invisible under track = top | search tier; sbperf audit |
Use the direct connection or the session pooler for ingestion and set bulk-load timeouts in-session: through the transaction pooler show statement_timeout returns the 2min role default whether or not you set it, because connection-time options are dropped | method notes |
Treat the Edge Function as bounded by a resource limit (HTTP 546 WORKER_RESOURCE_LIMIT, CPU or memory not distinguished) somewhere between 2504695 and 3595043 bytes, and by a 150 s idle wall clock (504 IDLE_TIMEOUT); on this corpus the resource limit binds first | G02; edge-resilience W13 |
| Route anything under about 270 characters per page to OCR outside the database | G09 |
Consumer count, long-poll interval and visibility timeout in the ingestion section are stated as rules with no run behind them; the 400-character edge window and the renderer cut are editorial choices.
Reading the numbers
Section titled “Reading the numbers”- The index ratio generalises. Two indexes turning a 167.59 ms depth-3 traversal into 0.22 ms is a statement about access paths. Absolute values scale with graph size and compute.
- The traversal latencies are a floor. Taken on 2 vCPU / 4 GB.
- The expansion ratios are corpus-specific; the SPREAD is the point. Do not reuse 0.7633. Reuse the finding that one ratio is not safe.
- The CTE traversal holds under concurrency. The depth-3 neighbourhood was pulled at concurrency 1, 4, 16 and 64 on the 100000/400000 graph: p50 flat at 0.3-0.7 ms, p95 at 2.9-3.6 ms, zero errors in 85 queries, on the same 2-vCPU medium - no knee, because the graph fits working memory. The pgrouting global algorithms are still single-query, uncontended numbers.
- The two halves of the evidence met on 2026-08-14. Until then the traversal and pgrouting latencies came from a synthetic 100000/400000 graph and the extraction pipeline had run over seven real documents, so “it works” and “it is fast” were two claims resting on two artifacts. The run over roughly 100 real documents (111: 103 council documents, the seven US fixtures and the scan fixture) fused them and falsified the transfer - a degree-917 hub turned the path-set walk into about 65721 ms at depth 3 on the 28778-edge graph, and the level-set rewrite answers in 155-159 ms warm over the pooler (RUNLOG figures) - and produced the per-genre extraction ratios (0.0011 to 0.0362 across the council genres) that turn the cost projection from a band into a number (G12). The pgrouting latencies are still synthetic-graph only.
- Extension availability is region-specific until probed. Everything here
was measured in
ap-southeast-1. Re-run the catalogue probe in the deployment-target region before quotingpgroutingthere.
Evidence
Section titled “Evidence”| Claim | How it was checked |
|---|---|
78 available extensions; no age, agensgraph, apache_age, sqlg | Measured - pg_available_extensions, exact-name match |
pgrouting 3.4.1 available, requires postgis; 209 pgr_* functions | Measured - catalogue query, then CREATE EXTENSION ... CASCADE and a pg_proc count |
| pgrouting algorithms run on a non-geospatial graph with integer ids | Measured - Dijkstra, components and bridges over a generated 100000/400000 graph, no geometry column |
| Depth-3 CTE 0.22 ms indexed against 167.59 ms unindexed, 602 ms build | Measured - EXPLAIN ANALYZE median, cold run discarded, both index states |
pgr_dijkstra column contract and ANY-INTEGER typing | Documented - pgRouting manual7 |
| Extracted/source 0.048 to 1.0885, aggregate 0.7633, TOAST 0.3965 | Measured - seven documents extracted and loaded, byte counts from relation size |
| Edge Function ceiling 2504695 B; Worker ceiling 14034445 B | Measured - same fixtures and parser on both runtimes |
| The Worker failure is memory, not CPU | Measured - GraphQL analytics reported exceededMemory; the 128 MB isolate limit is documented13 |
Date.now() does not advance during Worker CPU execution | Documented14 and observed as 0 ms extract time |
Storage serves HTML as text/plain with nosniff | Measured - response headers plus a screenshot of raw source rendering |
| SQL/PGQ committed 2026-03-16, rewritten in the rewriter | Documented - commit message and commitfest entry69 |
| PG18 is the newest supported major series | Documented10 |
| Apache AGE has PG17/PG18 release candidates and a PG19 branch | Documented - upstream tags and branches8 |
pgpdf parses via poppler; pg_ocr needs plpython3u | Documented1112 |
Only plpgsql and plpgsql_check procedural languages available | Measured - catalogue query for ^pl |
| Maximum disk size for a single project | Not discoverable via the Management API. Billing/addons surfaces probed again 2026-08-11 (3 endpoints; only throughput fields exist). The ceiling question stays open - the experiment project itself measured 88% disk full at the 2026-08-11 audit |
| Person/organisation extraction quality | Measured - 561 persons + 2284 orgs extracted live 2026-08-11; a 24-row sample showed name truncation, newline-split labels and sentence over-capture. NOISY, recorded as a finding rather than tuned toward plausible |
| 20 of 1521 citation entities span 2+ documents (18 in two, 2 in three) | Measured - cross_document_entities() over the loaded corpus, 2026-08-11 |
| Dates on edges, registry-identifier resolution, the 111-document real-pipeline run | Measured, 2026-08-14 - doc_date on mentions and edges via a BEFORE INSERT trigger, a hub entity resolving to 0 neighbours as at 2020-01-01 and 171 as at 2026-12-31; 148 ABN-shaped candidates gated by the mod-89 checksum to 14 valid mentions and 3 entities; 103 AU council documents loaded and extracted (292 persons / 123 organisations, 31.6 s wall clock for re-extract plus edge build, per-genre ratios 0.0011-0.0362, TOAST 0.4893) |
| Hub traversal semantics on a real co-occurrence graph | Measured - a degree-917 hub turned the path-set (UNION ALL) depth-3 walk into 65721.220 ms server-side; the level-set (UNION) walk answers the same query in 154.924 ms warm, same project, 2026-08-14 |
| Edge-cached read surface | Measured - the worker caches POST bodies under synthetic GET cache keys, 6 h freshness via x-cached-at, serve-stale on origin failure; x-pggraph-cache HIT/MISS/STALE verified on the deployed domain, 2026-08-14 |
| Functions created by hand in a live database do not survive a rebuild | Measured - demo.documents() existed in no committed file, only in an older live project, and a fresh rebuild shipped a UI whose corpus table 404’d (PGRST202). It is defined in demo/db/04-api.sql now |
| Browser-level end-to-end proof of the deployed demo | Measured - Playwright suite (demo/e2e/) over the live domain: landing, as-at bridging, entity graph + timeline + deep-link persistence, the registry-pin search flow, and the origin-down diagnostic state |
| Recall or quality of any vector index | Not measured. Synthetic vectors cannot support a recall claim |
halfvec is 0.5042 of vector on identical columns; HNSW build 6950 ms and query 0.43 ms against IVFFlat build 4130 ms and query 46.13 ms at lists=100 | Measured - G05, 30000 chunks x 1536 dimensions, synthetic values, 2026-08-10; storage, build time and latency are value-independent, recall is not |
Disk throughput is a separate addon field, not a function of disk size: baseline_disk_io_mbs=347, max_disk_io_mbs=2085 | Measured - G06, /projects/{ref}/billing/addons, 2026-08-10 |
| Cost per document: about 430 KB source to about 11 KB logical to about 5.4 KB on disk for council minutes; a 4 TB corpus at about $92/mo against about $239/mo at the US federal ratio | Measured inputs (G12, 2026-08-14) times Supabase list prices dated 2026-08-10; compute not priced |
| Scanned / image-only PDFs | Detection measured - an image-only NARA scan extracted 0.5 chars/page against 2710-3242 for born-digital references, so ~270 chars/page routes a document to OCR. OCR itself remains unavailable in-database: plpython3u absent from pg_available_extensions, verified by query 2026-08-11 |
| Behaviour under concurrent traversal | Measured - depth-3 CTE at concurrency 1/4/16/64 on the 100000/400000 graph: p50 0.29 ms aggregate, p95 ~3 ms, 0 errors in 85 queries, same 2-vCPU medium, 2026-08-11. pgrouting algorithms remain single-query |
Sources
Section titled “Sources”References
Section titled “References”-
Supabase, database extensions. https://supabase.com/docs/guides/database/extensions ↩
-
pgRouting manual. https://docs.pgrouting.org/latest/en/index.html ↩
-
PostgreSQL, recursive queries with
WITH. https://www.postgresql.org/docs/current/queries-with.html ↩ -
Supabase, GraphQL via
pg_graphql. https://supabase.com/docs/guides/graphql ↩ ↩2 -
PostgreSQL,
ltree. https://www.postgresql.org/docs/current/ltree.html ↩ ↩2 -
PostgreSQL commit, SQL Property Graph Queries (SQL/PGQ), 2026-03-16. https://www.postgresql.org/message-id/E1w247I-0000Tk-2Y%40gemulon.postgresql.org ↩ ↩2 ↩3
-
pgRouting,
pgr_dijkstra, including the Edges SQL inner-query contract. https://docs.pgrouting.org/latest/en/pgr_dijkstra.html ↩ ↩2 ↩3 -
Apache AGE. https://age.apache.org/ ↩ ↩2
-
PostgreSQL commitfest entry 4904, closed Committed in PG19-Final. https://commitfest.postgresql.org/patch/4904/ ↩ ↩2
-
PostgreSQL, supported version list. https://www.postgresql.org/versions.json ↩ ↩2
-
pgpdf, a PDF type for Postgres, parsing via poppler. https://pgxn.org/dist/pgpdf/ ↩ ↩2
-
pg_ocr, OCR in Postgres via PL/Python3U and ONNX Runtime. https://github.com/Z-Xiao-M/pg_ocr ↩ ↩2
-
Cloudflare Workers, platform limits. https://developers.cloudflare.com/workers/platform/limits/ ↩ ↩2
-
Cloudflare Workers, security model - timers only advance after I/O. https://developers.cloudflare.com/workers/reference/security-model/ ↩ ↩2
-
pgmq, a Postgres message queue. https://github.com/pgmq/pgmq ↩
-
pg_cron, a job scheduler for Postgres. https://github.com/citusdata/pg_cron ↩
-
Supabase Queues. https://supabase.com/docs/guides/queues ↩
-
Supabase, automatic embeddings - the documented queue-plus-consumer architecture. https://supabase.com/docs/guides/ai/automatic-embeddings ↩
-
Fly.io, autostop and autostart machines. https://fly.io/docs/launch/autostop-autostart/ ↩
-
Fly.io regions. https://fly.io/docs/reference/regions/ ↩
-
Supabase, “Manage your usage: disk size,” Supabase Docs. https://supabase.com/docs/guides/platform/manage-your-usage/disk-size ↩
-
Supabase, “Storage pricing,” Supabase Docs. https://supabase.com/docs/guides/storage/pricing ↩