Skip to main content

forktex-grid

Tables, columns, relations and rows declared at runtime, stored in Postgres. When your tables are known at deploy time you write DDL; when your tenants define them, you need this.

pip install forktex-grid

Python 3.14, PostgreSQL 14+. Built on the forktex substrate, which pip resolves for you.

What it does

You declare the shape you want as data. Grid converges the schema toward it, and gives you a typed, filterable, paginated table on the other side.

from forktex_grid import ColumnSpec, Namespace, Schema, TableSpec

ns = Namespace(session, str(org_id))

await ns.apply(
    Schema(
        tables=[
            TableSpec(
                slug="invoice",
                label="Invoice",
                columns=[
                    ColumnSpec(key="number", label="Number", type_id="text"),
                    ColumnSpec(key="total", label="Total", type_id="decimal"),
                    ColumnSpec(key="issued_at", label="Issued at", type_id="date"),
                ],
            )
        ]
    )
)

invoices = await ns.table("invoice")
await invoices.create({"number": "INV-001", "total": 250, "issued_at": "2026-03-01"})

page = await invoices.query(
    filter={"column": "total", "op": "gte", "value": 100},
    sort=[{"column": "issued_at", "direction": "desc"}],
    limit=50,
)
for row in page.rows:
    print(row.values["number"])

Nothing above was in a migration. apply is idempotent — call it again with a fourth column and grid adds exactly that column.

Use it when the shape is not known up front: per-tenant custom fields, user-defined record types, agent-authored state. When your tables are known, use forktex.database and real DDL. Grid is not a replacement for a schema you can write down.

Installing

pip install forktex-grid

Install with Python 3.14. forktex and forktex-grid both declare requires-python = ">=3.14", and pip hides a release whose floor your interpreter does not meet rather than saying why — so an older interpreter reports an unrelated older lineage and the dependency looks unsatisfiable. It is not; the interpreter is.

Developing against a local checkout of the substrate:

pip install -e ../forktex-py
pip install -e .

Wiring it into your app

There is no global state. Migrate the substrate once at startup, then construct one Namespace per request over the session you already have.

from contextlib import asynccontextmanager

from fastapi import Depends, FastAPI
from sqlalchemy.ext.asyncio import AsyncSession

from forktex.database import close_engine, connection, init_engine, session_scope
from forktex_grid import Namespace, apply_migrations


@asynccontextmanager
async def lifespan(app: FastAPI):
    init_engine(settings.db_url)
    await apply_migrations(connection.engine)   # idempotent, concurrency-safe
    yield
    await close_engine()


app = FastAPI(lifespan=lifespan)


@app.get("/invoices")
async def list_invoices(
    org_id: str,
    session: AsyncSession = Depends(session_scope),
):
    grid = await Namespace(session, org_id).table("invoice")
    return (await grid.query(limit=50)).rows

apply_migrations(engine, *, schema=...) brings up the forktex_grid schema. It is safe to run on every replica at once.

namespace is a plain string and defaults to "". Tenant isolation is by convention — enforced by passing the right value. There is no ambient context that will do it for you.

How consumers use it

One example and one sentence each; docs/grid.md has the exact semantics.

Evolving a schema

apply takes a typed Schema or a plain JSON dict, so a schema can be built at runtime or loaded from a file.

report = await ns.apply(schema, prune=True, allow_destructive=True, dry_run=True)

prune=True makes the declaration authoritative — anything absent is removed. allow_destructive=True is required before any drop or type-tightening. dry_run=True plans without writing. The return value is a reconcile report as a JSON dict; read it before you run the same call for real.

Schema and data in one transaction

from forktex_grid import RowOp

await ns.batch(schema=schema, rows=[RowOp(op="insert", table="invoice", values={...})])

Either the whole batch lands or none of it does.

Filtering and sorting

filter and sort accept the plain-dict wire forms — so they can arrive straight from a request body — or the typed FilterNode and SortKey from forktex.database.filters, which is where the operator vocabulary lives. Grid does not define its own: FilterOp on grid's surface is re-exported from the substrate, so a filter written against one consumer reads the same against another.

Custom fields on tables you already own

An extension is a grid table whose rows link 1:1 to a host row by external_ref. Your table is never touched.

from forktex_grid import Extension

clients = await ns.declare(
    TableSpec(
        slug="client_ext",
        label="Client extension",
        binding=Extension(physical_relation="public.client_record", primary_key="id"),
        columns=[ColumnSpec(key="account_manager", label="Account manager", type_id="text")],
    )
)

await clients.create({"account_manager": "IP"}, external_ref=host_id)
row = await clients.get_by_external_ref(host_id)

external_ref accepts any host primary key — int, str or UUID — stored as canonical text, so a table keyed by bigserial does not have to mint a surrogate.

Querying a table you already own

An overlay projects an existing physical table through the grid query API. It is read-only, offset-paginated, and accepts plain column projections only.

from forktex_grid import Overlay

await ns.declare(
    TableSpec(
        slug="client",
        label="Client",
        binding=Overlay(
            physical_relation="public.client_record",
            primary_key="id",
            namespace_column="org_id",
            column_map={"name": "display_name"},
        ),
        columns=[ColumnSpec(key="name", label="Name", type_id="text")],
    )
)

Reach for it when you need existing rows inside a grid query, not merely annotated. docs/grid-binding-design.md covers both bindings and why the seam is data rather than a mixin.

Relations

await grid.relate("owner", source_id=invoice_id, target_id=client_id)
clients = await grid.related("owner", source_id=invoice_id)

Everything past the first argument is keyword-only. relate is the reason: three same-typed positionals mean swapping two is a silent semantic bug no type checker can catch.

Registering your own field type

type_id is a validated VARCHAR(64), not a closed enum — the built-ins are a seed. Adding one is a code-only change, with no enum edit and no migration.

import ipaddress

import sqlalchemy as sa

from forktex_grid import Capabilities, FieldTypeHandler, FilterOp, register_field_type


class IPv4Type(FieldTypeHandler):
    type_id = "ipv4"                     # a string, not an enum member — that is the point
    capabilities = Capabilities(
        filterable=True,
        sortable=True,
        filter_ops=frozenset({FilterOp.eq, FilterOp.ne, FilterOp.in_, FilterOp.is_null}),
        index_kinds=frozenset({"btree"}),
        default_index_kind="btree",
    )

    def normalize(self, value, *, config):      # wire value → stored value; validates here
        return None if value is None else str(ipaddress.IPv4Address(str(value)))

    def to_cell(self, value, *, config):        # stored value → sidecar column
        return None if value is None else str(value)

    def from_cell(self, cell, *, config):       # sidecar column → stored value
        return None if cell is None else self.normalize(cell, config=config)

    def promoted_type(self, *, config):         # the SQL type when promoted to a sidecar
        return sa.String(15)


register_field_type(IPv4Type())

Those four methods are the whole contract, and docs/grid.md works the example through in full. A handler signals a bad value with ValueError and the write path turns it into BadRequestError, so a handler never imports grid's error vocabulary.

Driving it from an LLM

forktex_grid.ops exposes the operations as declarative, JSON-serialisable commands, so a model emits a command object rather than Python.

from forktex_grid.ops import TOOLS, run, tool_schemas

schemas = tool_schemas()
result = await run(ns, "query", {"table": "invoice", "limit": 10})

forktex_grid.declare is the other opt-in front door: a decorator DSL for declaring a schema as classes. Both are importable but deliberately outside __all__, so the root surface stays lean.

Errors

Class Raised when
NotFoundError Table, row or relation does not exist
BadRequestError Invalid spec, filter, or value for a column's type
AlreadyExistsError A slug or key collides within the namespace
ReadOnlyStorage A write was attempted against a read-only overlay

All four are exported from the package root and all derive from forktex.error.AppError, so one AppError handler in the consuming service renders them with the right code alongside its own rather than a masked 500.

Stability

The public surface is forktex_grid.__all__ — 31 names, asserted as an exact set by tests/test_architecture/test_public_surface.py, so neither an addition nor a removal can happen by accident. Anything reachable but not in __all__ is internal and may change in any release. ops and declare are deliberately in that category: importable front doors, not part of the frozen surface.

This is 0.x, but it is not a free-for-all. SemVer permits 0.x to break anything at any time; this project does not use that licence. A public name is removed only after one minor release in which it still works and warns. warnings.deprecated marks it, _compat.DEPRECATED schedules the removal, and tests/test_deprecations.py asserts a marked name still imports, still warns, and appears in the ledger. Behaviour a correct consumer would notice is called out under Changed in CHANGELOG.md.

FieldType members are append-only. Each is a string persisted in grid_column.type_id, and two are named in SQL CHECK constraints, so members are added and never renamed or removed. type_id itself is an open VARCHAR(64): a type this package does not implement is registered by whoever does, and the enum never has to grow for it. A type_id string is the stable identifier; a handler class is not — the class may be renamed, moved or restructured.

Migrations are forward-only, and an applied one is never edited. A schema change is a new v000N file. tests/test_migration_immutability.py pins the sha256 of every migration that has shipped, so editing one fails CI rather than quietly leaving already-migrated databases describing a schema this code no longer expects. Fixing a bad migration means writing the next one.

forktex_grid is a Postgres schema name in deployed databases as well as this distribution's import name. It is never renamed. Tests map over it with schema_translate_map rather than changing it.

Docs are tested. tests/test_docs_accuracy.py imports every name these pages claim is importable, constructs every spec literal they show, and resolves every relative link — so a broken example fails CI instead of a reader's terminal.

Documentation

docs/grid.md — the consumer reference: every public name, the full field-type contract, the agent and decorator surfaces, and the gotchas. docs/grid-binding-design.md — host binding, in depth. docs/development.md — contributing, the layering rules, and the release checklist.

Licence

Dual-licensed: AGPL-3.0-or-later, or a commercial licence from FORKTEX S.R.L. See LICENSE and NOTICE; commercial enquiries to info@forktex.com.

Metadata

Release files for forktex-grid 0.1.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for forktex-grid 0.1.0
File Size Uploaded Provenance Actions
forktex_grid-0.1.0.tar.gz 90.4 kB Without provenance Details for forktex_grid-0.1.0.tar.gz

Built distribution (wheel)

Table of built distributions (wheels) for forktex-grid 0.1.0
File Interpreter ABI Platform Actions
forktex_grid-0.1.0-py3-none-any.whl Python 3 none any Details for forktex_grid-0.1.0-py3-none-any.whl

Total release size: 236.8 kB

Release files / forktex_grid-0.1.0.tar.gz

Download URL forktex_grid-0.1.0.tar.gz
Size 90.4 kB
Tags Source
SHA-256 checksum
How to use checksums
795d7b093684dcc3c75f147b2ee983a9afceb7a16d446979fff5aee18fe93146
BLAKE2b-256 checksum
How to use checksums
5904164ca60b0a6b9858816a91a9a6c28f887be2d6fd922be78290691c087c2e
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/7.0.0 CPython/3.14.2

Release files / forktex_grid-0.1.0-py3-none-any.whl

Download URL forktex_grid-0.1.0-py3-none-any.whl
Size 146.5 kB
Tags Python 3
SHA-256 checksum
How to use checksums
682b4a75beef4beb82d6208bfa0b79f07e6df70d4378b0cbb55f1f1441677c6c
BLAKE2b-256 checksum
How to use checksums
09c217636a90a00e08f0f6b5cf45f0c5fbf6603653e0a2e13a1302c9341a7bce
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/7.0.0 CPython/3.14.2

Security for forktex-grid 0.1.0

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

No provenance found

None of the files in this release were uploaded with provenance metadata.

A note on package security

PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.

To learn more about protecting yourself from malicious actors, visit our package security documentation.

Release history Release notifications | RSS feed

This release

0.1.0 This release

2 release files No provenance

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page