How to use @owlmeans/planning-postgres — the durable Postgres PlanningStore for @owlmeans/server-planning — the four resources (planning-card, planning-transition, planning-link, planning-schema) a target registers from resources/planning/*.ts, the service a target registers from services/planning.ts, the inline fold under a per-card advisory lock and its prelude, healing and recover(), the LISTEN/NOTIFY commit bus, the project purge, data-defined types and flows, the limits and the errors. A...
Scanned 10/6/2026
npx -y skills add owlmeans/common --skill planning-postgres --agent claude-codeInstalls into .claude/skills of the current project.
Are you the author of Planning Postgres?
Add the live security badge to your README — it updates automatically with every re-scan.
[](https://www.skillsdirectory.com/skills/owlmeans-planning-postgres)More formats (shields.io, HTML) on the badges page. Keep it an A: scan every change in CI with Pro.
---
name: planning-postgres
description: How to use @owlmeans/planning-postgres — the durable Postgres PlanningStore for @owlmeans/server-planning — the four resources (planning-card, planning-transition, planning-link, planning-schema) a target registers from resources/planning/*.ts, the service a target registers from services/planning.ts, the inline fold under a per-card advisory lock and its prelude, healing and recover(), the LISTEN/NOTIFY commit bus, the project purge, data-defined types and flows, the limits and the errors. Auto-invoked when wiring planning into a Postgres backend, touching a planning table, or diagnosing a planning commit that stays pending on Postgres.
user-invocable: false
---
# @owlmeans/planning-postgres
**Layer:** Infra extension
**Install:** `"@owlmeans/planning-postgres": "^0.1.18-rc.10"` in `dependencies` (peers `pg`, `ajv`, `ajv-formats`)
A `PlanningStore` of `@owlmeans/server-planning` on Postgres. It owns no planning semantics: every
write still goes through the executor, every fold through `foldHelper.foldPending`, every query
through `queryHelper.criteriaOf` — this package supplies four tables, the transaction a fold runs
in, and the bus that carries commits between processes. It implements every port, the
data-defined schema port included, so a facade over it has `definitions`.
## Key Exports
| Export | Description |
|--------|-------------|
| `makePlanningCardPostgres` / `makePlanningTransitionPostgres` / `makePlanningLinkPostgres` / `makePlanningSchemaPostgres` | `ResourceMaker`s of the four tables, under the default aliases |
| `makePlanningCardResource(alias?, dbAlias?, serviceAlias?)` (and the three twins), `makePlanningPostgresResources(aliases)` | The same resources under custom aliases |
| `makePostgresPlanningService(opts?, alias?)` | The planning host service over a Postgres store — a target's `makeService()` |
| `appendPostgresPlanning(ctx, opts?, alias?)` | The four resources (each unless present), the service and `ctx.planning()` in one call |
| `makePostgresPlanningStore({ context: () => ctx, ...opts })` | The store alone (its resources resolved on that context at the first call); `fold(card)`, `recover(opts?)`, `close()` beside the ports |
| `PlanningPostgresOptions` | `{ aliases?, bus?, limits?, ids?, now? }`; `PostgresPlanningServiceOptions` adds the service's own (`plugins`, `schemas`, `hooks`) |
| `DEFAULT_PLANNING_POSTGRES_LIMITS` | `{ foldBatch: 500, gapGraceMs: 30_000, healAfterMs: 1_000, recoverAfterMs: 60_000, lockTimeoutMs: 10_000 }` |
| `RES_PLANNING_CARD` · `RES_PLANNING_TRANSITION` · `RES_PLANNING_LINK` · `RES_PLANNING_SCHEMA`, `PLANNING_POSTGRES_STORE` | `planning-card` … `planning-schema`, `planning-postgres` |
| `Planning*TableSchema` | The table schemas, derived from the planning record schemas |
| `PlanningPostgresError` | `planning-postgres:<what>` — a fault (500) |
| `planningChannel(qualified)`, `LOST_ALLOCATION` | The bus channel of a transition table; the cause of a gap's placeholder |
## The four tables
| Resource | Holds | Indexes |
|---|---|---|
| `planning-card` | projects, cards AND specifications (routed by `kind` at the query layer) | `(entityId, kind, type)`, `(parent, order)`, GIN `(parents)`, `(parent, status)`, `(entityId, intrinsic, updatedAt)`, GIN `(labels)`, `(entityId, parent, code) WHERE code IS NOT NULL`, `(parent, category) WHERE kind = 'specification'` |
| `planning-transition` | the append-only log | UNIQUE `(card, seq)`, UNIQUE `(entityId, key) WHERE key IS NOT NULL`, `(project, at)`, `(at) WHERE (commit->>'state') = 'pending'` |
| `planning-link` | typed edges | UNIQUE `(from, to, type)`, `(to, type)`, `(entityId, type)`, `(project)` |
| `planning-schema` | data-defined types and flows, and one private `kind: 'head'` row per organization (its schema revision) | UNIQUE `("entityId", COALESCE(project, ''), kind, key)`, `(entityId, rev)` |
- The DDL comes from `AnyWorkcardSchema`, `TransitionSchema`, `RelationshipSchema` and
`ScopedSchemaRecordSchema`. **Every timestamp stays `text`**: the `date-time` format would compile
to `timestamptz` and marshal through `Date`, which rewrites the ISO string a record carries and
breaks the lexicographic order `updatedSince`, `at` sorts and gap ages rely on. `seq`, `head`,
`revision` and `bodyChars` (and a schema record's `version` and `rev`) are `integer`; `body` is
`text`.
- The card table carries one private column, `headAt` — when `head` last moved — that no read
ever returns.
- A card list includes specifications only when it asks for `kind: 'specification'` or names a
`category` (`wantsSpecifications`, the rule every store routes by); a summary never counts them.
- Every maker declares each index once, however often it runs.
## Target wiring
**Backs:** projects, cards and their documents as planning workcards — the transition log they are folded from, their links, and the card types and flows an application defines as data
| Sub-project | Packages |
|---|---|
| backend | `@owlmeans/planning-postgres`, `@owlmeans/server-planning`, `@owlmeans/planning` |
| common | `@owlmeans/planning` |
| api | `@owlmeans/server-planning`, `@owlmeans/planning` |
The service resolves its four resources by alias, so the registration order never matters. All
four are required: the store keeps data-defined types and flows too, and a write reads their
layer.
```ts file=sources/backend/src/services/planning.ts
import { makePostgresPlanningService } from '@owlmeans/planning-postgres'
import type { Service } from '@owlmeans/context'
import { PLANNING } from '__APP_SLUG__-common/planning'
/**
* The planning service over Postgres — resolves its four resources
* (`resources/planning/{card,transition,link,schema}.ts`) by alias, so registration order never
* matters. The maker name is fixed — the generated service registry imports exactly this symbol.
*/
export const makeService = (): Service => makePostgresPlanningService({ plugins: [PLANNING] })
```
```ts file=sources/backend/src/resources/planning/card.ts
import { makePlanningCardPostgres } from '@owlmeans/planning-postgres'
import type { PlanningCardRecord, PlanningCardResource } from '@owlmeans/planning-postgres'
import type { ResourceMaker } from '@owlmeans/resource'
/**
* Projects, cards and their documents — one table (`planning-card`), routed by `kind` at the
* query layer. The maker is a thin wrapper: the schema and indexes are the package's own.
*/
export const makeResource: ResourceMaker<PlanningCardRecord, PlanningCardResource> =
(dbAlias, serviceAlias) => makePlanningCardPostgres(dbAlias, serviceAlias)
```
```ts file=sources/backend/src/resources/planning/transition.ts
import { makePlanningTransitionPostgres } from '@owlmeans/planning-postgres'
import type { PlanningTransitionResource } from '@owlmeans/planning-postgres'
import type { Transition } from '@owlmeans/planning'
import type { ResourceMaker } from '@owlmeans/resource'
/** The append-only transition log (`planning-transition`) every card is folded from. */
export const makeResource: ResourceMaker<Transition, PlanningTransitionResource> =
(dbAlias, serviceAlias) => makePlanningTransitionPostgres(dbAlias, serviceAlias)
```
```ts file=sources/backend/src/resources/planning/link.ts
import { makePlanningLinkPostgres } from '@owlmeans/planning-postgres'
import type { PlanningLinkResource } from '@owlmeans/planning-postgres'
import type { Relationship } from '@owlmeans/planning'
import type { ResourceMaker } from '@owlmeans/resource'
/** Typed links between cards (`planning-link`) — one row per from, to and type. */
export const makeResource: ResourceMaker<Relationship, PlanningLinkResource> =
(dbAlias, serviceAlias) => makePlanningLinkPostgres(dbAlias, serviceAlias)
```
```ts file=sources/backend/src/resources/planning/schema.ts
import { makePlanningSchemaPostgres } from '@owlmeans/planning-postgres'
import type { PlanningSchemaResource, PlanningSchemaRow } from '@owlmeans/planning-postgres'
import type { ResourceMaker } from '@owlmeans/resource'
/** Card types and flows defined as data (`planning-schema`), per organization and per project. */
export const makeResource: ResourceMaker<PlanningSchemaRow, PlanningSchemaResource> =
(dbAlias, serviceAlias) => makePlanningSchemaPostgres(dbAlias, serviceAlias)
```
```ts file=sources/common/src/planning.ts
import type { PlanningPlugin } from '@owlmeans/planning'
/**
* This application's planning types and flows — see the `planning` skill for their shape. The
* backend's planning service registers it; a client that reads a model loads the same bundle from
* the server, so nothing else imports it.
*/
export const PLANNING: PlanningPlugin = {
name: 'app-planning',
schemas: {
flows: [
// owlmeans: add planning flows above this line
],
types: [
// owlmeans: add planning types above this line
],
},
}
```
## Mounting in a target
This service is the store under a target's stock planning API (`planning` → Mounting in a target).
Its `planning-schema` table is what lets the app's people override and extend the kit's
`overridable` card types and flows through `schema.define` — never drop it from a target that
mounts the tree.
## A worked example
The api binds the tree the shared package declares (an api's own `entrypoints.ts`, outside the
wiring above); a hand-written backend may register everything in one call instead —
`appendPostgres(context); appendPostgresPlanning(context, { plugins: [PLANNING] })`.
```ts
// common: a lending library's branches (projects) and books (cards)
export const PLANNING: PlanningPlugin = {
name: 'app-planning',
schemas: {
flows: [{
id: 'library:circulation', version: 1,
statuses: [
{ key: 'shelved', intrinsic: IntrinsicStatus.Planned, initial: true },
{ key: 'lent', intrinsic: IntrinsicStatus.InProgress },
{ key: 'retired', intrinsic: IntrinsicStatus.Closed },
],
transitions: [
{ name: 'lend', from: ['shelved'], to: 'lent', explicit: true },
{ name: 'return', from: ['lent'], to: 'shelved', explicit: true },
],
}],
types: [
{ type: 'library:branch', kind: WorkcardKind.Project, version: 1, fields: { type: 'object' },
flows: ['library:circulation'], specifications: [], cardTypes: ['library:book'], scopedCardTypes: true },
{ type: 'library:book', kind: WorkcardKind.Card, version: 1, fields: { type: 'object' },
flows: ['library:circulation'], specifications: [] },
],
},
}
export const planningProtocols = makePlanningProtocols({
base: { alias: 'library:planning', path: '/planning' }, guards: DEFAULT_GUARD, definitions: true,
})
// api: the protocol bindings
...servePlanningEntrypoints(planningProtocols)
```
Writing and reading from backend code is `server-planning` → Creating and reading a card (the
call, the read back, `define`, and the wrong forms). A target registers the service as a plain
service (`services/planning.ts` above), so there is NO `ctx.planning()`: reach it as
`ctx.service<PlanningHostService>(PLANNING_SERVICE).for({ entityId, profileId })`, with `entityId`
taken in the HANDLER (`server-planning` → Scope and security) and passed down. This store keeps
schemas, so `definitions` is always present.
The first store call into a missing resource throws
`PlanningPostgresError('resource-missing:planning-schema: add src/resources/planning/schema.ts')`,
naming the file to add.
## The fold
`project()` folds inline — there is no queued mode. One fold is ONE transaction:
1. `SET LOCAL lock_timeout` (`limits.lockTimeoutMs`), then `pg_advisory_xact_lock` on
`pgNameHelper.advisoryKey('planning:<qualified card table>:<card id>')`.
2. **The prelude** walks the rows past `card.seq` (at most `foldBatch`): a FAILED row at the cursor
moves the cursor past it; the PENDING rows from the cursor on are the run the fold takes; a GAP
— a row past the expected seq, or `head > seq` with no row at all — is an append still in
flight while younger than `gapGraceMs` (the fold stops; the appender's own `project()` folds
it), and a lost allocation after that: a failed `lost-allocation` placeholder per missing seq.
`nextSeq` and `append` are two round trips, so without it a transient gap or an already-failed
row would turn the next write into a spurious `fold:out-of-order`.
3. `foldHelper.foldPending` of `@owlmeans/server-planning` over a view of the ports bound to the transaction,
limited to that run, each transition's writes in a SAVEPOINT (`FoldOptions.unit`) — so a write
the database refuses fails that transition alone and the fold goes past it. Prelude and fold
repeat within the transaction until the log is folded or a young gap stops it.
4. A `pg_notify` per settled event (no record).
5. COMMIT — and only then the events reach the commit hub and the `after` hooks, in order, outside
the transaction and the lock. A hook may therefore write to the card it saw commit.
A statement that fails outside a savepoint **poisons** the transaction: it is never committed
(the "transaction is aborted" answers after it are not the cause — the first failure is). The
fold is retried once; failing again, `foldHelper.failPending` fails the card's pending transitions
in a fresh transaction, so no waiter hangs to its timeout. A lock that cannot be taken within
`lockTimeoutMs` means another process is folding the card: nothing is failed, a heal follows.
Allocation: `nextSeq` is a compare-and-set of `head` on the card row, stamping `headAt`; a card
whose create has not folded counts `max(seq) + 1` from the log and the unique `(card, seq)` index
refuses a repeat. A card write never lowers `head` (`GREATEST`) and never touches `headAt`; a write
after the create is an UPDATE of a row that must still exist, so a card purged with its project is
not resurrected by a fold that was already under way.
## Healing and recovery
- **Every waiter heals**: a status read of a pending row older than `healAfterMs` starts a
background fold of its card (try-lock, deduplicated per card).
- `recover({ olderThanMs = recoverAfterMs, limit = 100 })` folds each card whose oldest pending
row is older than the threshold (the partial pending index), and runs once in the background on
the store's first use.
- A lost allocation with no later row is released by the next fold of the card.
## The commit bus
`makeCommitHub` is the commit source; LISTEN/NOTIFY feeds it across processes. The channel is
`planning_<16 hex>` of the qualified transition table, so two schemas in one database never hear
each other. The frame is `{ p: <process id>, t: 'c', e: <CommitEvent without record> }`; a process
ignores its own (`p`) — it delivered it itself, after its commit. LISTEN holds one dedicated
`pg.Client` built from the pool's own configuration (no pool slot), opened on the first subscribe,
wait or schema watch, reconnecting 1 s → 30 s; meanwhile the hub's poll ladder answers every
waiter. A schema write sends `{ t: 's', e: <entityId> }` on the same channel. `bus: false` sends
and hears nothing — waits poll, layers re-read the revision.
## Data-defined types and flows
`planning-schema` holds one record per `(organization, project layer, kind, key)`; a write is one
transaction that bumps the organization's `head` row, writes under the compare-and-set on `version`
(an INSERT for version 1, an UPDATE guarded on `version - 1` after) and NOTIFYs. `revision()` is a
primary read of the head row, so the service's cached layers are never served stale. The rules —
what may be defined, sealing, retiring — are `planning`'s and `server-planning`'s.
## Purge
A project's delete purges in the fold's own transaction (or, called directly, in one under the
project's lock): a recursive walk over `parents @> ARRAY[id]` finds every doomed card, nested
projects included; then links, transitions, the doomed projects' schema layers and the cards go,
in that order. The project's own `delete` rows stay as its tombstone — a waiter still reads the
delete committed.
## Querying
Lists, counts and summaries go through `@owlmeans/postgres-resource` (`pgCriteriaHelper.criteriaToSql`),
so a `WorkcardQuery` selects here what it selects in memory: `within` is `parents @> $1::varchar[]`,
`labels` `&&`, `flows.<id>` / `fields.<key>` typed jsonb paths (a list is membership), `q` `ILIKE` +
code prefix. A summary is one `countBy(parent, intrinsic)`. Paging is Postgres's — 100 rows unless
`size` says otherwise.
## Gotchas
- `close()` the store in a process that should end on its own — the LISTEN connection keeps it
alive.
- Two fold transactions of one card never run at once in a process (chained) or across processes
(the advisory lock); a heal that finds the lock taken yields.
- A row appended by hand (`store.transitions.append`) is folded only by `project()`, a heal or
`recover()` — nothing watches the table.
## Testing
`bun test ./tests` — `schema.spec.ts`, `sql.spec.ts` and `created-by.spec.ts` (the statements
around the `createdBy` column ownership checks read) need no database; `conformance.spec.ts`
(the `@owlmeans/server-planning/conformance` cases, read from that package's BUILT output — a new
case runs here only after `server-planning` is rebuilt), `fold.spec.ts`, `bus.spec.ts` and
`sync.spec.ts` are gated on `POSTGRES_URL` (`gateHelper.postgresGate()`) and skip cleanly without
it. Each spec file owns a throwaway schema (`makeSuite`); a fault is injected with a real trigger,
never a mock.
## Related
- `server-planning` — the executor, `foldHelper.foldPending`/`failPending`, the commit hub, the conformance suite
- `planning` — records, flows, scoped schemas, the query language
- `postgres-resource` — the table compiler, `pgCriteriaHelper.criteriaToSql`, `countBy`, `pgNameHelper.advisoryKey`
- `postgres` — the connection service these resources resolve through
- `marketing-consent-postgres` — the same resource-per-file wiring for another feature
Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.
No comments yet. Be the first to comment!