diff --git a/.github/workflows/sync-mkdocs.yml b/.github/workflows/sync-mkdocs.yml index e87ec862..4b550885 100644 --- a/.github/workflows/sync-mkdocs.yml +++ b/.github/workflows/sync-mkdocs.yml @@ -65,6 +65,35 @@ jobs: # future major-split product, add its header base name here. MAJOR_SPLIT_PRODUCTS = {'Spock'} + # ── Config: version retention policy ────────────────────────────── + # docs.pgedge.com deploys to Cloudflare Pages, which hard-caps a + # deployment at 20,000 files; publishing every SSOT version crossed + # that cap in Aug 2026. The nav therefore keeps a bounded window of + # releases per product, and this sync honors it: a version outside the + # window is NOT proposed for addition, and a published version that has + # fallen outside it is reported as a prune candidate. Pruning is never + # applied automatically — retiring a published version breaks URLs and + # needs a redirect in hooks/versioned_redirects.py (RETIRED_VERSIONS). + # + # 'group' is the line a version competes within: 'major' (1.x), 'minor' + # (1.2.x), or 'product' (all versions share one window). 'max' is how + # many nav LABELS to keep per line — labels, not raw versions, because + # several SSOT releases can collapse onto one label (control-plane's + # 0.10.1 and 0.10.0 are both 'v0.10' and occupy a single slot). + # + # Pre-releases (alpha/beta/rc) are held to the newest one per product + # and retire once a GA release supersedes them. Living/dev sources are + # outside the policy entirely — they are the 'Development' nav entries. + RETENTION_DEFAULT = {'group': 'major', 'max': 3} + RETENTION_OVERRIDES = { + # One nav entry per PG major, so majors — not minors — are the + # window. Four, so v16 stays published once PG19 reaches GA. + 'PostgreSQL': {'group': 'product', 'max': 4}, + # Two live lines published side by side: newest two of each + # (3.6.4/3.6.3 with 3.5.7/3.5.6) rather than three of 3.6 alone. + 'PostGIS': {'group': 'minor', 'max': 2}, + } + # ── Helpers ─────────────────────────────────────────────────────── def normalize_url(url): @@ -75,13 +104,23 @@ jobs: return url.rstrip('/') def version_key(v): - parts = re.split(r'[.\-]', str(v)) + """Sort key for a version string. + + A token's trailing digits are compared numerically, not as + text, so '19beta10' sorts above '19beta2' (plain string + ordering puts 'beta10' first because '1' < '2'). + """ + parts = re.split(r'[.\-]', str(v).lstrip('vV')) result = [] for p in parts: try: result.append((0, int(p))) except ValueError: - result.append((1, p.lower())) + m = re.match(r'^(.*?)(\d+)$', p) + if m: + result.append((1, m.group(1).lower(), int(m.group(2)))) + else: + result.append((1, p.lower(), 0)) return result def for_pgedge_docs(e): @@ -129,6 +168,23 @@ jobs: return kb_src, kb_tag return None, None + def is_living(v): + """Living sources (master/devel) are the 'Development' nav entries.""" + return bool(re.search(r'devel|^dev$', str(v), re.I)) + + def is_prerelease(v): + return bool(re.search(r'(alpha|beta|rc)', str(v), re.I)) + + def line_key(version, group): + if group == 'product': + return '*' + parts = str(version).lstrip('v').split('.') + return '.'.join(parts[:1] if group == 'major' else parts[:2]) + + def label_key(version, components): + """Collapse a version onto the nav label that would carry it.""" + return '.'.join(str(version).lstrip('v').split('.')[:components]) + # ── Load inputs ─────────────────────────────────────────────────── with open('/tmp/sources.yaml') as f: @@ -181,6 +237,67 @@ jobs: if base in MAJOR_SPLIT_PRODUCTS: url_header_majors[nu].add(maj.group(1)) + # ── Apply the retention policy ──────────────────────────────────── + # retained: (product name, version) pairs the nav should publish. + # Label granularity is read back from what this product already + # publishes, so ranking counts nav slots rather than raw releases. + + # Keyed by (url, ref): a ref like 'v1.0.0' is used by several + # products, so a ref-only key would let one overwrite another's + # label and mis-size that product's version slots. + ref_to_label = {(nu, ref): lbl for (nu, lbl), ref in url_label_to_ref.items()} + + def label_components(name, versions_by_name_entries): + counts = [] + for e in versions_by_name_entries: + u, r = get_import_info(e) + if not u or not r: + continue + lbl = ref_to_label.get((normalize_url(u), r)) + if lbl: + counts.append(len(lbl.lstrip('v').split('.'))) + return max(set(counts), key=counts.count) if counts else 3 + + entries_by_name = defaultdict(list) + for e in ssot['sources']: + if not for_pgedge_docs(e): + continue + v = e.get('version', '') + if not v or v == 'dev' or is_living(v): + continue + entries_by_name[e.get('name')].append(e) + + retained = set() + for name, entries in entries_by_name.items(): + cfg = RETENTION_OVERRIDES.get(name, RETENTION_DEFAULT) + comps = label_components(name, entries) + versions = {str(e.get('version')) for e in entries} + ga = [v for v in versions if not is_prerelease(v)] + pre = [v for v in versions if is_prerelease(v)] + + # Rank by label slot: every version sharing a label shares its slot. + slots = defaultdict(lambda: defaultdict(list)) + for v in ga: + slots[line_key(v, cfg['group'])][label_key(v, comps)].append(v) + for line, by_label in slots.items(): + keep = sorted(by_label, key=version_key, reverse=True)[:cfg['max']] + for lbl in keep: + for v in by_label[lbl]: + retained.add((name, v)) + + # Newest pre-release only, and only until a GA supersedes it. + if pre: + newest = sorted(pre, key=version_key, reverse=True)[0] + base = re.split(r'[-.]?(?:alpha|beta|rc)', newest, flags=re.I)[0] + if not any(version_key(v) >= version_key(base) for v in ga): + retained.add((name, newest)) + + def is_retained(entry): + v = entry.get('version', '') + if not v or v == 'dev' or is_living(v): + return True + return (entry.get('name'), str(v)) in retained + # ── Build the expected set from SSOT ────────────────────────────── # expected_refs[nu]: normalized_base_url → set of refs SSOT still produces # expected_versions[nu]: normalized_base_url → set of versions SSOT still lists @@ -266,6 +383,8 @@ jobs: missing_entries = [] # product has a block; this version is absent new_products = [] # no nav block exists yet — requires manual placement new_sections = [] # product exists but this MAJOR needs its own section + prune_candidates = [] # published, but outside the retention window + retention_skipped = [] # in SSOT, outside the window — not proposed ref_updates = [] # label exists but its ref is stale (e.g. v0.8 → release/v0.8.1) for entry in ssot['sources']: @@ -279,6 +398,23 @@ jobs: continue nu = normalize_url(git_url) + + # Retention policy. Outside the window a version is never + # proposed for addition; if it is still published it is + # reported as a prune candidate for a human to action. + if not is_retained(entry): + bucket = (prune_candidates + if ref in url_ref_map.get(nu, set()) + else retention_skipped) + bucket.append({ + 'entry' : entry, + 'git_url': git_url, + 'ref' : ref, + 'version': version, + 'label' : ref_to_label.get((nu, ref), f"v{version}"), + }) + continue + if ref in url_ref_map.get(nu, set()): continue # already present @@ -476,7 +612,7 @@ jobs: lines = ['## mkdocs.yml Drift Report — pgedge-docs\n'] clean = (not missing_entries and not new_products and not ssh_urls and not ref_updates and not cp_path_update and not stale_entries - and not new_sections) + and not new_sections and not prune_candidates) if ssh_urls: lines += ['### SSH URLs detected in mkdocs.yml\n', @@ -533,6 +669,28 @@ jobs: ) lines.append('') + if prune_candidates: + lines += ['### Prune candidates (published, outside the retention window)\n', + ' Not removed automatically. Retiring one means deleting its nav\n' + ' entry AND adding a redirect to `RETIRED_VERSIONS` in\n' + ' `hooks/versioned_redirects.py`.\n'] + for item in prune_candidates: + e = item['entry'] + lines.append( + f" - **PRUNE?** `{e['id']}` ({e['name']} {e.get('version', '')}) " + f"— label `{item['label']}`" + ) + lines.append('') + + if retention_skipped: + lines += ['### Skipped by retention policy (in SSOT, intentionally not added)\n'] + for item in retention_skipped: + e = item['entry'] + lines.append( + f" - **SKIPPED** `{e['id']}` ({e['name']} {e.get('version', '')})" + ) + lines.append('') + if cp_path_update: lines += ['### Asset path version bump\n', f" - `/control-plane/{cp_path_update['old']}/scripts/generate-stack.js`" @@ -547,18 +705,43 @@ jobs: f'{len(missing_entries)} missing, ' f'{len(stale_entries)} stale, ' f'{len(new_products)} new product(s), ' - f'{len(new_sections)} new section(s)*'] + f'{len(new_sections)} new section(s), ' + f'{len(prune_candidates)} prune candidate(s), ' + f'{len(retention_skipped)} skipped by retention*'] report = '\n'.join(lines) + '\n' with open('/tmp/drift-report.md', 'w') as f: f.write(report) print(report) + # The report reaches a PR body only when there is something to + # apply. Prune candidates are report-only, so a run that finds + # nothing else would leave them in transient job logs. Write the + # report to the run summary every time, and annotate prune + # candidates so they are visible on the run without opening logs. + summary_path = os.environ.get('GITHUB_STEP_SUMMARY') + if summary_path: + with open(summary_path, 'a') as fh: + fh.write(report) + + if prune_candidates: + labels = ', '.join( + f"{i['entry'].get('name')} {i['entry'].get('version')}" + for i in prune_candidates + ) + print( + f"::warning title=Retention prune candidates::" + f"{len(prune_candidates)} published version(s) are outside " + f"the retention window and need a nav removal plus a " + f"RETIRED_VERSIONS redirect: {labels}" + ) + actionable = bool(missing_entries or ssh_urls or ref_updates or cp_path_update or stale_entries) with open(os.environ['GITHUB_OUTPUT'], 'a') as fh: fh.write(f'actionable={"true" if actionable else "false"}\n') fh.write(f'clean={"true" if clean else "false"}\n') + fh.write(f'prune_candidates={len(prune_candidates)}\n') if not actionable: sys.exit(0) diff --git a/hooks/versioned_redirects.py b/hooks/versioned_redirects.py index 22b33234..e1c2da1b 100644 --- a/hooks/versioned_redirects.py +++ b/hooks/versioned_redirects.py @@ -29,6 +29,48 @@ 'pgedge-postgres-mcp': 'pgedge-postgres-mcp-server', } +# Versions that were retired from the nav to stay under Cloudflare Pages' +# 20,000-file-per-deployment limit. Maps the retired version path -> the +# nearest surviving version. Emitted as splat rules so deep links keep +# working where the page still exists in the surviving version. +# +# Cloudflare Pages allows 100 dynamic (splat) rules per deployment; these +# plus LEGACY_PREFIXES are well inside that budget. +DYNAMIC_RULE_BUDGET = 100 # Cloudflare Pages' per-deployment splat-rule limit + +RETIRED_VERSIONS = { + 'ace/v1-7-2': 'ace/v1-8-0', + 'ace/v1-7-1': 'ace/v1-8-0', + 'ace/v1-7-0': 'ace/v1-8-0', + 'ace/v1-6-0': 'ace/v1-8-0', + 'ace/v1-5-5': 'ace/v1-8-0', + 'ace/v1-5-4': 'ace/v1-8-0', + 'ace/v1-5-3': 'ace/v1-8-0', + 'ace/v1-5-2': 'ace/v1-8-0', + 'ace/v1-5-1': 'ace/v1-8-0', + 'ace/v1-4-2': 'ace/v1-8-0', + 'ace/v1-4-1': 'ace/v1-8-0', + 'ace/v1-4-0': 'ace/v1-8-0', + 'coldfront/v1-0-0-beta1': 'coldfront/v1-0-0-beta2', + 'control-plane/v0-7': 'control-plane/v0-8', + 'control-plane/v0-6': 'control-plane/v0-8', + 'pgadmin-4/v9-11': 'pgadmin-4/v9-12', + 'pgvector/v0-8-0': 'pgvector/v0-8-1', + 'postgis/v3-5-5': 'postgis/v3-5-6', + 'postgis/v3-6-2': 'postgis/v3-6-3', + 'postgrest/v14-7': 'postgrest/v14-8', + 'postgrest/v14-6': 'postgrest/v14-8', + 'postgrest/v14-5': 'postgrest/v14-8', + 'radar/v0-3-0': 'radar/v0-4-0', + 'radar/v0-2-3': 'radar/v0-4-0', + 'radar/v0-2-2': 'radar/v0-4-0', + 'radar/v0-1-0': 'radar/v0-4-0', + 'spock-v5/v5-0-8': 'spock-v5/v5-0-9', + 'spock-v5/v5-0-6': 'spock-v5/v5-0-9', + 'spock-v5/v5-0-5': 'spock-v5/v5-0-9', + 'spock-v5/v5-0-4': 'spock-v5/v5-0-9', +} + def on_pre_build(config): """Generate redirect index.md files for each versioned docset.""" @@ -147,6 +189,24 @@ def _exclude_old_versions_from_search(site_dir, versioned_docsets): ) +def _count_dynamic_rules(text): + """Count dynamic (splat/placeholder) rules in a _redirects body. + + Cloudflare budgets these separately from static rules, and a + _redirects shipped in docs/ is appended to what this hook generates, + so the deployment's real total is whatever ends up in the file. + """ + count = 0 + for line in text.splitlines(): + line = line.strip() + if not line or line.startswith('#'): + continue + source = line.split()[0] + if '*' in source or ':' in source: + count += 1 + return count + + def on_post_build(config): """Post-build: generate _redirects and exclude old versions from search. @@ -189,6 +249,39 @@ def on_post_build(config): ) ) + # RETIRED_VERSIONS is hand-maintained alongside manual nav edits, so + # verify each pair against what was actually built before emitting it. + # + # A restored source is the dangerous case: Pages evaluates _redirects + # before static assets (see the note in this function's docstring), so + # a rule whose source directory exists again would build every page of + # that version and then hide all of them behind a 301. Drop the rule + # rather than ship that, and log an error so the stale entry gets + # cleaned up. A missing target is skipped too: redirecting to a path + # that is not in the deployment just adds a hop before the same 404, + # while consuming a rule from the dynamic-rule budget. + retired_rules = [] + for old_path, new_path in RETIRED_VERSIONS.items(): + if os.path.isdir(os.path.join(site_dir, old_path)): + log.error( + f"RETIRED_VERSIONS lists {old_path}, but it was built into " + f"the site — the redirect would make every one of its pages " + f"unreachable. Skipping the rule; remove the entry from " + f"hooks/versioned_redirects.py now that the version is back." + ) + continue + if not os.path.isdir(os.path.join(site_dir, new_path)): + log.warning( + f"RETIRED_VERSIONS points {old_path} at {new_path}, which is " + f"not in the built site — the redirect would only add a hop " + f"before the same 404. Skipping the rule; retarget it at a " + f"surviving version." + ) + continue + retired_rules.append( + '/{old}/* /{new}/:splat 301'.format(old=old_path, new=new_path) + ) + if legacy_rules: rules.append('# Legacy prefix redirects') rules.extend(legacy_rules) @@ -197,6 +290,15 @@ def on_post_build(config): f"Generated {len(legacy_rules)} legacy prefix redirect rules" ) + if retired_rules: + rules.append('# Retired version redirects') + rules.extend(retired_rules) + rules.append('') + log.info( + f"Generated {len(retired_rules)} retired version redirect rules" + ) + + if legacy_rules or retired_rules: # Write the _redirects file to the site root redirects_path = os.path.join(site_dir, '_redirects') @@ -206,13 +308,33 @@ def on_post_build(config): with open(redirects_path, 'r') as f: existing = f.read() - with open(redirects_path, 'w') as f: - f.write('\n'.join(rules)) - if existing: - f.write('\n') - f.write(existing) + final = '\n'.join(rules) + if existing: + final += '\n' + existing - log.info(f"Wrote {len(legacy_rules)} redirect rules to {redirects_path}") + with open(redirects_path, 'w') as f: + f.write(final) + + # Cloudflare Pages allows 100 dynamic (splat/placeholder) rules per + # deployment; past that the platform's answer is Bulk Redirects. + # Every retention pass appends entries and none expire, so warn + # while there is still room to change approach. Count what the + # deployment actually ships — a _redirects from docs/ is appended + # here and its dynamic rules draw on the same budget. + generated = len(legacy_rules) + len(retired_rules) + dynamic = _count_dynamic_rules(final) + log.info( + f"Wrote {generated} redirect rules to {redirects_path} " + f"({dynamic}/{DYNAMIC_RULE_BUDGET} of the Cloudflare Pages " + f"dynamic-rule budget in the final file)" + ) + if dynamic > DYNAMIC_RULE_BUDGET * 0.8: + log.warning( + f"{dynamic} dynamic redirect rules is within 20% of " + f"Cloudflare Pages' {DYNAMIC_RULE_BUDGET}-rule limit. Retire " + f"the oldest entries from RETIRED_VERSIONS or move them to " + f"the client-side handling in 404.html." + ) # --- Pagefind: exclude old versions from search index --- diff --git a/mkdocs.yml b/mkdocs.yml index 26584c0b..76e12c50 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -600,18 +600,6 @@ nav: - v1.9.0: '!import https://github.com/pgEdge/ace.git?branch=v1.9.0' - v1.8.1: '!import https://github.com/pgEdge/ace.git?branch=v1.8.1' - v1.8.0: '!import https://github.com/pgEdge/ace.git?branch=v1.8.0' - - v1.7.2: '!import https://github.com/pgEdge/ace.git?branch=v1.7.2' - - v1.7.1: '!import https://github.com/pgEdge/ace.git?branch=v1.7.1' - - v1.7.0: '!import https://github.com/pgEdge/ace.git?branch=v1.7.0' - - v1.6.0: '!import https://github.com/pgEdge/ace.git?branch=v1.6.0' - - v1.5.5: '!import https://github.com/pgEdge/ace.git?branch=v1.5.5' - - v1.5.4: '!import https://github.com/pgEdge/ace.git?branch=v1.5.4' - - v1.5.3: '!import https://github.com/pgEdge/ace.git?branch=v1.5.3' - - v1.5.2: '!import https://github.com/pgEdge/ace.git?branch=v1.5.2' - - v1.5.1: '!import https://github.com/pgEdge/ace.git?branch=fix-for-1.5.1-docs' - - v1.4.2: '!import https://github.com/pgEdge/ace.git?branch=v1.4.2' - - v1.4.1: '!import https://github.com/pgEdge/ace.git?branch=v1.4.1' - - v1.4.0: '!import https://github.com/pgEdge/ace.git?branch=v1.4.0' - Development: '!import https://github.com/pgEdge/ace.git?branch=main' - lolor: @@ -626,7 +614,6 @@ nav: - ColdFront: - v1.0.0-beta2: '!import https://github.com/pgEdge/coldfront.git?branch=v1.0.0-beta2' - - v1.0.0-beta1: '!import https://github.com/pgEdge/coldfront.git?branch=v1.0.0-beta1' - Development: '!import https://github.com/pgEdge/coldfront.git?branch=main' - Spock v6: @@ -637,29 +624,19 @@ nav: - v5.0.11: '!import https://github.com/pgEdge/spock.git?branch=v5.0.11' - v5.0.10: '!import https://github.com/pgEdge/spock.git?branch=v5.0.10' - v5.0.9: '!import https://github.com/pgEdge/spock.git?branch=v5.0.9' - - v5.0.8: '!import https://github.com/pgEdge/spock.git?branch=v5.0.8' # - v5.0.7: '!import https://github.com/pgEdge/spock.git?branch=v5.0.7' - - v5.0.6: '!import https://github.com/pgEdge/spock.git?branch=v5.0.6-doc-2' - - v5.0.5: '!import https://github.com/pgEdge/spock.git?branch=v5.0.5' - - v5.0.4: '!import https://github.com/pgEdge/spock.git?branch=v5.0.4' - Development: '!import https://github.com/pgEdge/spock.git?branch=v5_STABLE' - Control Plane: - v0.10: '!import https://github.com/pgEdge/control-plane.git?branch=release/v0.10.1' - v0.9: '!import https://github.com/pgEdge/control-plane.git?branch=release/v0.9.0' - v0.8: '!import https://github.com/pgEdge/control-plane.git?branch=release/v0.8.1' - - v0.7: '!import https://github.com/pgEdge/control-plane.git?branch=release/v0.7.0' - - v0.6: '!import https://github.com/pgEdge/control-plane.git?branch=v0.6.2-docs' - Development: '!import https://github.com/pgEdge/control-plane.git?branch=main' - Radar: - v0.5.1: '!import https://github.com/pgEdge/radar?branch=v0.5.1' - v0.4.1: '!import https://github.com/pgEdge/radar?branch=v0.4.1' - v0.4.0: '!import https://github.com/pgEdge/radar?branch=v0.4.0' - - v0.3.0: '!import https://github.com/pgEdge/radar?branch=v0.3.0' - - v0.2.3: '!import https://github.com/pgEdge/radar?branch=v0.2.3' - - v0.2.2: '!import https://github.com/pgEdge/radar?branch=v0.2.2' - - v0.1.0: '!import https://github.com/pgEdge/radar?branch=v0.1.0' - Development: '!import https://github.com/pgEdge/radar?branch=main' - pgEdge Loadgen: @@ -703,6 +680,7 @@ nav: - Development: '!import https://github.com/pgEdge/pg-healthcheck?branch=main' - pgedge-safesession: + - v1.0: '!import https://github.com/pgEdge/pgedge-safesession.git?branch=v1.0' - Development: '!import https://github.com/pgEdge/pgedge-safesession?branch=main' - pgedge-mcp-bridge: @@ -721,16 +699,13 @@ nav: - v0.8.5: '!import https://github.com/pgEdge/3rd-party-docs.git?branch=pgvector085' - v0.8.2: '!import https://github.com/pgEdge/3rd-party-docs.git?branch=pgvector082' - v0.8.1: '!import https://github.com/pgEdge/3rd-party-docs.git?branch=pgvector081' - - v0.8.0: '!import https://github.com/pgEdge/3rd-party-docs.git?branch=pgvector080' - Development: '!import https://github.com/pgEdge/3rd-party-docs.git?branch=pgvectormaster' - postgis: - v3.6.4: '!import https://github.com/pgEdge/3rd-party-docs.git?branch=postgis364' - v3.6.3: '!import https://github.com/pgEdge/3rd-party-docs.git?branch=postgis363' - - v3.6.2: '!import https://github.com/pgEdge/3rd-party-docs.git?branch=postgis362' - v3.5.7: '!import https://github.com/pgEdge/3rd-party-docs.git?branch=postgis357' - v3.5.6: '!import https://github.com/pgEdge/3rd-party-docs.git?branch=postgis356' - - v3.5.5: '!import https://github.com/pgEdge/3rd-party-docs.git?branch=postgis355' - Development: '!import https://github.com/pgEdge/3rd-party-docs.git?branch=postgismaster' - pgAudit: @@ -760,6 +735,7 @@ nav: - Development: '!import https://github.com/pgEdge/3rd-party-docs.git?branch=pgcronmaster' - pgmq: + - v1.12.0: '!import https://github.com/pgEdge/3rd-party-docs.git?branch=pgmq1120' - v1.11.1: '!import https://github.com/pgEdge/3rd-party-docs.git?branch=pgmq1111' - v1.11.0: '!import https://github.com/pgEdge/3rd-party-docs.git?branch=pgmq1110' - Development: '!import https://github.com/pgEdge/3rd-party-docs.git?branch=pgmqmaster' @@ -786,7 +762,6 @@ nav: - v9.14: '!import https://github.com/pgEdge/3rd-party-docs.git?branch=pgadmin914' - v9.13: '!import https://github.com/pgEdge/3rd-party-docs.git?branch=pgadmin913' - v9.12: '!import https://github.com/pgEdge/3rd-party-docs.git?branch=pgadmin912' - - v9.11: '!import https://github.com/pgEdge/3rd-party-docs.git?branch=pgadmin911' - Development: '!import https://github.com/pgEdge/3rd-party-docs.git?branch=pgadminmaster' - PgBouncer: @@ -799,9 +774,6 @@ nav: - v14.10: '!import https://github.com/pgEdge/3rd-party-docs.git?branch=postgrest1410' - v14.9: '!import https://github.com/pgEdge/3rd-party-docs.git?branch=postgrest149' - v14.8: '!import https://github.com/pgEdge/3rd-party-docs.git?branch=postgrest148' - - v14.7: '!import https://github.com/pgEdge/3rd-party-docs.git?branch=postgrest147' - - v14.6: '!import https://github.com/pgEdge/3rd-party-docs.git?branch=postgrest146' - - v14.5: '!import https://github.com/pgEdge/3rd-party-docs.git?branch=postgrest145' - Development: '!import https://github.com/pgEdge/3rd-party-docs.git?branch=postgrestmaster' - psycopg2: