@ai-matrx/data
The opinionated AI Matrx data layer: the /db tier — the hard-won Supabase primitives
every Matrx client uses against the platform database — the /net tier — the
resilience trio every outbound call and event stream rides — and the /files tier —
the Matrx file-communication client. Each primitive exists because a real production
incident forged it; the file headers carry the stories.
npm install @ai-matrx/data
| Subpath | What it carries |
|---|---|
@ai-matrx/data | Core: structural PostgREST types, pgErrorToError, the credentials port, plus the /net trio re-exported (it rides the core). |
@ai-matrx/data/db | The Supabase lane: optimistic concurrency, complete-list reads, JSONB merge, session-retry, auth cookie, realtime hygiene. |
@ai-matrx/api/net | The net-resilience trio: resilient fetch, retry/backoff, stream stall watchdog, structured NetError vocabulary. |
@ai-matrx/data/next | The Next.js binding: ONE factory for every Supabase client a Next app builds — browser, Server Component, Route Handler, proxy/middleware, OAuth callback — plus the whole middleware auth-cookie pass. |
@ai-matrx/data/react | The React bindings: useAsyncData, useRealtimeChannel / useRealtimeQuery, useAsyncAction. Stamped "use client". |
@ai-matrx/data/access | The access gate's pure section: the "you can't open this" vocabulary, the access_denied_context resolver client, the request / decide / withdraw flow, the per-person hide, honest-copy helpers. Not stamped "use client"; app inputs injected with configureAccess. |
@ai-matrx/data/db
| Export | What it does |
|---|---|
guardedUpdate | Optimistic concurrency as compare-and-swap on the canonical version column. Classifies "0 rows updated" honestly: conflict (with the current row so the UI can offer merge/refresh) vs not_found. Callback-shaped — it never needs to know your schema or client. |
readAllRows / tryReadAllRows | The ONE way to read a list you intend to treat as complete. PostgREST silently caps responses (HTTP 206 looks like success); a truncated list used for an existence/diff decision is a confidently wrong answer. Pages to the server-declared total and throws (IncompleteReadError) rather than return a provably partial list. readAllRowsRest / tryReadAllRowsRest are raw-fetch twins for scripts with no client. |
mergeJsonColumn | Merge into a row's JSONB column without losing a concurrent writer's keys: composes guardedUpdate with a bounded re-read-and-remerge retry. Returns a typed status, never throws for a normal outcome. |
createSessionRetry | Recovers the one failure worth retrying: a request that reached PostgREST with no user JWT while a session actually exists (refresh in flight, cookie swap). Retries exactly that cause, exactly once, after re-resolving the session. Everything else passes through untouched. |
createOAuthRefreshFetch | Keeps auth-js's normal storage, locking, refresh lifecycle and events, but routes a proven allowlisted OAuth-client session's refresh to /auth/v1/oauth/token with its JWT client_id. Normal sessions continue through Supabase's ordinary refresh endpoint. |
createAuthCookie | One login across every subdomain of your apex: a renamed (sb-matrx-auth), domain-wide auth cookie issued only on real apex hosts — localhost and previews get host-only, because a browser silently rejects a mismatched Domain. |
createActiveOrgCookie | ONE browser memory of the organization a person is acting in, shared by every Matrx surface on the apex (matrx-active-org, Domain=.aimatrx.com): identity-keyed (<userId>:<orgId>), domain-wide only on a listed apex, host-only elsewhere. Stores the stored-selection rung of the canonical order; never decides. |
uniqueChannelTopic | Realtime channel hygiene: unique per-mount topics so React 19 double-invoked effects and Fast Refresh can never collide with a still-joined channel. |
The doors — one way to talk to the database
Every app talks to the database through doors over descriptors it generates locally from the
ONE database description. The description ships inside this package (description/, gzipped, the one
this version was built with), so no repository or database access is needed.
# in your app (package.json: "db:generate": "matrx-data generate --out src/db/generated --schemas workbench,chat")
npx matrx-data generate --out src/db/generated --schemas workbench # one file per schema
npx matrx-data generate --out src/db/generated --schemas workbench --check # CI: fail when stale
npx matrx-data generate --out src/db/generated --flags-index # + per-schema flags for tableByName
# fresher than this package version? With the direct-DB variables (SUPABASE_MATRIX_*):
npx matrx-data emit --out db-contract && npx matrx-data generate --from db-contract --out src/db/generated
import { createDb, listAll, read, insert, update, remove, call } from "@ai-matrx/data/db";
import { noteFolders, notes, noteFolderGetOrCreate } from "./db/generated/workbench";
const db = createDb({ client: supabase, organization: () => activeOrgId }); // wraps YOUR client
const pinned = await listAll(db, notes, (q) => q.eq("is_pinned", true)); // complete; typed filter; live rows
const row = await read(db, notes, id, { columns: ["id", "label"] }); // typed to those columns
await insert(db, notes, { folder_id, label }); // child: the folder's organization, never the active one
await update(db, notes, id, { label }, { expectedVersion: row.version }); // or { overwrite: true }
await remove(db, notes, id, { expectedVersion: v }); // soft delete; archive-only tables refuse
await call(db, noteFolderGetOrCreate, { p_name: "Clients" });
// @ai-matrx/api: pass db.credentials as its `credentials` option — one sign-in feeds both lanes
Rules come from the description (the live table registry), never the call site: organization by the
access ladder; reads never filter by the active organization and exclude soft-deleted rows unless
includeDeleted; listAll is complete and ordered by the full primary key; every write on a versioned
table is guarded; the door set follows the registry type; createServiceDb is server-only.
@ai-matrx/api/net
| Export | What it does |
|---|---|
resilientFetch | fetch with a two-stage timeout — connect (default 15s, request start → headers) and total (default 120s, null disables) — plus a composed AbortController returned to the caller for canceling body reads. Never pre-blocks on navigator.onLine (the flag is a hint; a real outage taught us it lies) — offline is only reported when a genuine network failure corroborates it. |
withRetry | Exponential backoff with jitter for idempotent operations only. Default predicate retries exactly what the error taxonomy marks retryable (connect timeouts, network failures, HTTP 408/429/5xx). Honors an AbortSignal mid-backoff; never wrap user-authored submissions. |
monitorStream | Wraps an async event iterable with two guarantees: a heartbeat deadline (default 30s between events) and an absolute lifetime ceiling (default 10min). A stuck stream throws HeartbeatTimeoutError/TotalTimeoutError — and aborts your AbortController — instead of awaiting forever. |
NetError family | ConnectTimeoutError, TotalTimeoutError, HeartbeatTimeoutError, NetworkError, HttpError, AbortedError, OfflineError — every failure resolves to a class with a code and an honest retryable flag, so callers branch on type, never on string matching. Helpers: isNetError, toNetError, isTransportFailure (is this the transport dying, or the server answering? — decides how LOUD to log), extractErrorMessage. |
Wiring: there is nothing to inject. The tier has zero dependencies and zero seams —
fetch and timers come from the environment (Node 20+, browsers, React Native/Hermes),
and it never touches Supabase, org context, or the API lane. Pass your own AbortSignal
to resilientFetch/withRetry and your own AbortController to monitorStream so a
watchdog timeout also cancels the underlying request. Everything here is also
re-exported from the root @ai-matrx/data entry point.
@ai-matrx/data/next
A Next.js app has FIVE places that must construct a Supabase client, and every one of them must pass the SAME auth-cookie options or it cannot see the session the others wrote. One factory owns all five. The host injects IDENTITY only.
// One module in your app — everything else imports from here.
import { createNextSupabase } from "@ai-matrx/data/next";
import type { Database } from "@/types/database.types";
export const supabaseNext = createNextSupabase<Database>({
// STATIC member accesses: Next only inlines NEXT_PUBLIC_* for those.
supabaseUrl: process.env.NEXT_PUBLIC_SUPABASE_URL,
publishableKey: process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY,
apexDomain: "example.com",
cookieName: "sb-example-auth-v2",
legacyCookieName: "sb-example-auth", // optional: migrate + clear an old key
});
| Door | Call |
|---|---|
| Client Component | supabaseNext.browserClient() — a singleton on a globalThis slot |
| Server Component / Action | supabaseNext.serverClient({ cookieStore: await cookies(), host }) |
| Route Handler | supabaseNext.routeClient({ requestCookies: request.cookies, setCookie, host }) |
| Proxy / middleware | await supabaseNext.middlewareSession({ host, requestCookies, createResponse, createRedirect }) |
middlewareSession is the whole auth pass in one call and returns
{ user, client, response, redirect(url), splitCookieJar, ambiguousAuthCookies, authUnavailable }. It owns: legacy-key migration (absent-only, so a live session is never
overwritten), the "nothing may run between createServerClient and the identity resolve"
rule made structural, persisting a migrated session under the current key, clearing the
superseded key at the scope this host issued it on, no-store response headers on any
cookie-bearing response, and cookie carry-over onto redirects. Dropping any one of those
logs users out at random — which is how each was found.
The identity resolve is getClaims(), never getUser(), and it is BOUNDED.
getUser() sends a request to the Auth server for every JWT; getClaims() verifies the
access token against the project's JWKS with WebCrypto — locally, usually with no network
call, and just as trusted, because the signature is checked. It needs an asymmetric
signing key on the project (ECC/RSA); with a symmetric secret it degrades to a
getUser()-shaped request per call, still correct but with none of the saving. A 2.5s
identity budget — ONE deadline per request across /auth/v1/user, a token refresh and the
JWKS fetch, after which auth-js is answered with a non-retryable 408 so its 30s refresh
backoff cannot run (0.18.0) — means a stalled auth server costs
one request its identity instead of costing the page its response — on Vercel, where Next
16 forces this pass into a Node Lambda with a hard task cap, the unbounded version returned
504 MIDDLEWARE_INVOCATION_TIMEOUT to real people.
🚨 authUnavailable is the consumer's obligation. It means the authority could not be
REACHED, which is NOT "signed out": user is null, but no auth cookie was written,
expired or healed and the superseded key was not cleared. Read it before treating
user === null as signed out — a host that bounces to /login on a null user must skip
that bounce when the flag is set, or a 2.5s network blink signs people out mid-session.
A call site needing created_at, identities, last_sign_in_at, factors or
*_confirmed_at must keep getUser() elsewhere: the JWT carries none of them.
Full contract: common-docs/systems/platform/auth/proxy-identity/FEATURE.md.
No next/* import lives in the package. cookies() and NextResponse arrive through
structural seams (createResponse: () => NextResponse.next({ request })), so /next
builds and tests outside Next and never drags the framework into another consumer's graph.
Missing identity throws MissingSupabaseIdentityError NAMING the variable, instead of the
cryptic undefined.trim() TypeError that process.env.X! produces from inside Supabase.
@ai-matrx/data/react
Three hooks, each built at a MEASURED repeated consumer — not a data-fetching framework. There is no cache, no store and no query client here: these compose under your Redux or React Query, they do not compete with it. What they own is the part every hand-rolled copy gets wrong.
| Hook | What it owns |
|---|---|
useAsyncData(load, deps, opts) | Cancellation (a real AbortSignal reaches your loader), stale-response ordering, honest error conversion (a PostgREST error is a plain object — String(err) renders "[object Object]"), retry of TRANSPORT failures only, keepPreviousData so a key change never blanks the screen, and an abort that is silent because it is not a failure. |
useRealtimeChannel(client, topic, bindings, opts) | Unique per-mount topics, reconnect with exponential backoff and a stability reset, an onReconnect catch-up door (events missed while the socket was down are GONE), honest connecting/connected/reconnecting/disconnected status, latest-callback refs, guaranteed teardown. |
useRealtimeQuery({ client, topic, watch, load, deps }, opts) | The two composed, with realtime events COALESCED so a bulk write is one read, and a re-read wired to reconnect. |
useAsyncAction(action, opts) | Double-submit REFUSAL (a disabled button is not a guard), unmount-safe state, honest errors, and a discriminated { ok: true, value } | { ok: false, error } result so nothing is swallowed. Writes are never retried — a replayed submission is how you get two invoices. |
usePaginatedData({ queryKey, initialCursor, loadPage, getRowId }) | Cursor accumulation with one request at a time, identity masking, abort/stale-result handling, explicit retry, in-place row-id updates, and loud repeated-cursor refusal. It has no cache and no table dependency. |
const { data, loading, error, refresh, realtimeStatus } = useRealtimeQuery({
client: supabaseNext.browserClient(),
topic: `jobs:${fileId}`,
watch: [{ schema: "docproc", table: "jobs", filter: `file_id=eq.${fileId}` }],
load: ({ signal }) => listJobsForFile(fileId, signal),
deps: [fileId],
}, { enabled: fileId !== null });
The Supabase client type is STRUCTURAL — the package never imports
@supabase/supabase-js. A real client satisfies it by shape, and there is a compile-time
proof of that in the package's own tests (it caught a too-narrow signature during this
package's build, which is exactly the failure a host would otherwise have hit).
@ai-matrx/data/knobs, /app-config, /config-attestation, /config-types
Config with no code defaults, where every read is counted:
import { createKnobStore, defineKnobFeature, knob, parse } from "@ai-matrx/data/knobs";
import { createAppConfigReader } from "@ai-matrx/data/app-config";
import { createConfigReadRecorder } from "@ai-matrx/data/config-attestation";
const CHAT = defineKnobFeature("desktop.confidential_chat", {
recentlyDeletedDays: knob("recently_deleted_days", parse.int(0, 3650)),
});
const chat = createKnobStore({ feature: CHAT, client, address, bundled, cache, log, name: "confidential_chat", recorder });
chat.load();
await chat.refresh();
chat.get("recentlyDeletedDays"); // live DB → last-seen cache → build snapshot; throws KnobUnavailableError if none
createAppConfigReader reads an app's public.app_config row the same way (PostgREST with
Accept-Profile: public, then aidream with the bearer). forDisplay() on either is a frozen copy that is
never counted. createConfigReadRecorder batches reads into interval buckets for
platform.config_reads_report; buildConfigManifest builds the release manifest from the typed registry.
@ai-matrx/data/access
Moved from the website's access-gate feature. Pure (no React): fetchAccessDeniedContext, deriveStatus,
classifyDataError, readServerRefusal / readTypedRefusal, createAccessRequest / createDeleteRequest /
createSettingAccessRequest / decideAccessRequest / withdrawAccessRequest / listAccessRequests,
fetchRemovalRights / hideForMe / unhideForMe / listHiddenItems, the requestAccess routing helpers,
actionAuthority, archivedWords, decideServerReadRecheck, isGovernedActionDenial, and the types
(AccessDeniedContext, AccessGateStatus, ...).
The configureAccess contract. The package imports nothing from the app. Call
configureAccess({ getClient, ... }) once at boot; any service used before that throws an error naming
configureAccess. Fields (all but getClient are optional and needed only by the feature that uses them):
| Field | Used for |
|---|---|
getClient() | The host's Supabase client (required). |
fetchWithOrganization | The org-carrying fetch, for CMS-resolved tokens (/api/cms/access-context). |
ensureOrgId | Resolve an org id or raise the host's org gate (passed through to React consumers). |
sendDirectActionMessage | The host's DM primitive; without it requests still file, delivered reports 0. |
entityTokens | CMS access-gate tokens: isCmsAccessGateToken, cmsAccessGateLabel, site action/key helpers. |
platformOrgIds | { systemOrganizationId }; defaults to the platform's system org. |
actionRegistries, extraRequestHandlers | Opaque host registries/handlers read by the React section. |
resetAccessEnv() clears the injection (tests). The React screens live in @ai-matrx/sharing/access/react (data owns no UI).
@ai-matrx/data (core)
Structural types (PostgrestLikeError, MaybeSingleResponse, Json) and
pgErrorToError — PostgREST errors are plain objects, not Errors; this folds them into
descriptive real errors. The /net trio is re-exported here as well.
Design
- Zero runtime dependencies. All Supabase typing is structural — a real supabase-js client satisfies the shapes; nothing is imported from it. Works in Node 20+, browsers, and React Native/Hermes.
- Injected hosts, opinionated behavior. Values and clients come in through arguments
(
apexDomain,auth); the behavior — the CAS contract, the refusal to return partial lists, the single-cause retry — is deliberately non-configurable. The opinions are the product. - Dual loader. ESM and CommonJS conditions with matching declarations, proven from the packed tarball on every release.
MIT © AI Matrx