Resource handles — the provider implementer's page¶
The user-facing model is user-docs/handles.md; this page is for whoever writes a :pq: PROVIDER — a q namespace
.X whose hooks the host calls by NAME. A user names your provider in a coordinate, `:pq:X:alias:…, and never
calls a hook directly. The contract is v3; lib/qpc.q (q IPC) and lib/duckdb.q are the two shipped implementations
to read beside it.
The model in one paragraph¶
A resolver decides whether a :... specification is an existing q resource, an inferred format, a transport/container
composition, or an explicit :pq: provider. For a provider, hopen and hclose are the only lifecycle doors: the host
calls your open hook, keeps the ONE registry (alias → the private token your open hook returned) and answers the alias
symbol `:pq:X:alias; every other hook is token-keyed. A bound table is a POINTER (the flip of
cols!`:pq:X:alias:t/) whose columns are advisory: every query and write goes back through you. Decoder-backed
resources (CSV, JSON, parquet) are not providers — they produce a table for the host's qSQL; transport and container
resolution (HTTP → CSV, ZIP → CSV) belong to PeachQ, not the decoder.
resource specification
-> resolve transport/container/provider
-> obtain or expose table
-> optional provider qsql
-> host qSQL fallback
The hooks¶
token is whatever .X.i.open returned — an int, a symbol, anything; the host stores it and hands it back, never
reads it. Names obey [a-zA-Z][a-zA-Z0-9_]*. A hook's error propagates to the caller unmodified, except where marked
best-effort.
| hook | called when | required? |
|---|---|---|
.X.i.open[alias;rest;timeout;config] |
hopen, the one-shot apply (alias `), a temporary connection for an aliasless coordinate. rest is the coordinate text after the alias, verbatim; timeout 0N when absent; config :: or the dict of the 3-list form. Answers the token. |
required |
.X.i.close[token] |
hclose, the end of a one-shot, a re-pointed alias |
optional (no-op) |
.X.call[token;text;sync] |
the handle applied to a string (h "…") |
required for that door |
.X.async[token;msg] |
h (async;msg)| required for that door ('.X.async` otherwise) |
|
.X.bind[token;name] |
a table coordinate is bound (get, hname`): answers the column names (a symbol list) |
required |
.X.get[token;name] |
a bound table is materialised (select, count/meta fallback) |
required |
.X.set[token;name;data] / .X.upsert[token;name;data] |
`:pq:X:alias:t/ set x / upsert x; insert/upsert on a global bound to your table (through upsert) |
required for that door |
.X.count[token;name] / .X.meta[token;name] |
count/meta of a bound table |
optional (host materialises through get) |
.X.qsql[token;cols;tree] |
a qSQL statement over a bound table: cols the live column list, tree the functional form with slot 0 the BARE table name; answer the result, or :: to decline to the host's materialise-and-evaluate fallback |
optional |
.X.i.link[token;qname;table] |
a carrier of yours is bound to a q global — at the one global-set seam, so myt:get …, myt:ht,.ns.t:…and a\d-scoped assignment all reach it;qnameis the global's FULL name,table` the bare table name |
optional, paired |
.X.i.unlink[token;qname] |
that global is replaced or unbound (myt:1, delete myt from.);tokenis::` once the alias is closed |
optional, paired |
.X.i.load[token;tables] |
\l `:pq:X:alias (tables = ::), \l `:pq:X:alias:t/ (enlist `t), .pq.i.load[h;tables] (as given: (), :: or a symbol list): answer the NAMES to bind — a symbol list, () for none. The host makes each pointer through bind and binds it at the root, later-wins, and answers the names |
optional ('nyi) |
.X.i.hdel[token;name] |
hdel `:pq:X:alias:t/: drop the OBJECT; the q name, if any, stays bound |
optional ('nyi) |
Any other name reaches you through the list door, h (name; args…)→.X.name[token; args…], excepti,openandclose`, which the host keeps to itself.
The link hooks are best-effort: the global is already bound when they run, so an error is dropped (the same shape
as .z.vs), a hook that itself binds a carrier does not re-enter, and an aliasless (self-contained) carrier never
links — it is served by a connection that dies with the call, so there is nothing durable to point at. .X.i.link is
called only while the alias is live; .X.i.unlink always. They are a pair: defining one without the other is
'nyi at the first use of either, the error text the missing hook's name ('.X.i.unlink) — a leaked view is
otherwise silent. The DuckDB provider uses them to keep a same-named VIEW of the table in its main instance
(docs/duckdb-api.md § The instance); q IPC defines neither.
The verb → hook table, and the naming rule¶
A hook is NAMED FOR THE Q VERB IT SERVES; .i. marks a hook only the host calls (lifecycle, the link seam, the
loader, hdel) — a user never spells one, and the list door never reaches one. Required: i.open, call, bind,
get; everything else is optional with a host fallback or 'nyi.
| q spelling | the host does | hook | required |
|---|---|---|---|
hopen:pq:X| registers alias → token, answers the alias symbol |.X.i.open[alias;rest;timeout;config]` |
yes | ||
hclose h |
drops the registration | .X.i.close[token] |
no (no-op) |
h "text" |
text call | .X.call[token;text;sync] |
yes |
h (async;msg)| the async send |.X.async[token;msg]| no ('.X.async`) |
|||
ht,get :pq:X:al:t/ |
builds the pointer from the column names | .X.bind[token;name] |
yes |
select … from t, value t |
materialises | .X.get[token;name] |
yes |
?[t;…] and every qSQL over a pointer |
pushes the resolved functional tree | .X.qsql[token;cols;tree] |
no (materialise) |
`:pq:X:al:t/ set x |
the write | .X.set[token;name;data] |
for that door |
`:pq:X:al:t/ upsert x, `t insert x / `t upsert x on a pointer global |
the write-through | .X.upsert[token;name;data] |
for that door |
count t |
.X.count[token;name] |
no (materialise) | |
meta t |
.X.meta[token;name] |
no (materialise) | |
t:pointer (any global-set) |
announces the binding | .X.i.link[token;qname;table] |
no, paired |
t:1, delete t from.| announces the displacement |.X.i.unlink[token;qname]` |
no, paired | ||
\l `:pq:X:al, \l `:pq:X:al:t/, .pq.i.load[h;tables] |
binds the names answered, at the root | .X.i.load[token;tables] |
no ('nyi) |
hdel `:pq:X:al:t/ |
.X.i.hdel[token;name] |
no ('nyi) |
|
s)CREATE … / s)ALTER … |
(DuckDB only) binds main's new names through .pq.i.load |
.duckdb.i.tables[h] + .duckdb.i.load |
— |
The sync law (ADR § Sync law, owner 2026-09-16) the table serves: whatever is done THROUGH q — assigning,
re-pointing or unbinding a pointer, insert/upsert on one, hdel of a coordinate — is in sync by construction
(one global-set seam, one hook per verb). Whatever is done THROUGH the provider's own language is best effort,
weighted to additions: DuckDB's s) binds the new names after a CREATE/ALTER; a drop on that side is the user's
— the stale pointer stays bound and errors on use; nothing prints, nothing is unbound behind the user's back.
qsql is the table-query seam: a provider may execute the query itself, materialise and evaluate locally, or return
::. .pq.i.resolveTree[cols;tree] (lib/pq.q) is the shared resolver every implementation rides — free variables
inlined, columns left symbolic, an unresolvable name 'unpush. DuckDB uses the seam for provider-side query handling;
q IPC forwards the resolved functional query to the peer.
What the host guarantees¶
hopenvalidates the open tuple before you see it (timeoutan int atom or absent,configa dict or::); the alias is required for a registered open and reserved names are yours to refuse (DuckDB refusesmain).- Only
:pq:X:aliasis ever stored: the config (credentials, paths) dies athopen. - A live alias re-opened is re-pointed in place: your close hook, then your open hook, the same symbol answers.
.pq.conns[]renders every registered row — the alias symbol ashandle, the legacy fd ash— and a provider's own row (registered from C throughq_provider_register_internal) renders like any other but refuseshopenandhclose.- A hook is resolved from the live q env at call time: redefining one takes effect on the next call.