Files
sim/packages/db/schema.ts
T
Vikhyath MondretiandClaude 7798e83489 feat(function): custom sandboxes (#6071)
* feat(sandboxes): workspace dependency sets for Function blocks

Named package sets a Function block can import from. The server
canonicalizes and hashes the list; E2B prebuilds a content-addressed
template per set, Daytona installs per execution. Create/edit is gated to
Max or Enterprise via the shared workspace entitlement check; execution is
deliberately ungated, so a downgraded workspace keeps running what it
already built.

Also on this branch:

- Extract the duplicated dropdown/combobox option-fetch lifecycle into
  use-fetched-options. Only combobox had the dependency-change reset, so
  every dropdown with dependsOn + fetchOptions cleared its list and never
  repopulated until reopened.
- Collapse the repeated Max-tier entitlement check onto one
  hasMaxTierWorkspaceAccess, shared by inbox, live sync, and sandboxes.
- Resolve a personal payer's block state through getEffectiveBillingStatus
  in getBillingEntityBlockStatus, so the client-side Max gates agree with
  the server-side ones when blockOrgMembers' fan-out is stale.
- Carve the Daytona dependency install out of the caller's execution
  budget instead of stacking on top of it.

Co-Authored-By: Claude <noreply@anthropic.com>

* chore(db): regenerate the sandboxes migration as 0273

Staging claimed 0271 and 0272 while this branch was out, so the hand-authored
0271_workspace_sandboxes was dropped before the merge and regenerated on top
of the merged schema. Same DDL; drizzle emits plain CREATE TABLE/INDEX rather
than the hand-added IF NOT EXISTS, which matches the repo default — that
idempotent form is only needed for files with CONCURRENTLY ops below an
embedded COMMIT. Regenerating also restores the meta snapshot the
hand-authored migration never had.

Co-Authored-By: Claude <noreply@anthropic.com>

* chore(db): drop the sandboxes migration ahead of the staging merge

Staging independently claims idx 0273, so remove ours before merging to
avoid an add/add conflict on the drizzle migration index. Regenerated at
the next free index once the merge lands.

Co-Authored-By: Claude <noreply@anthropic.com>

* chore(db): regenerate the sandboxes migration as 0275

Staging took 0273 and 0274, so the sandboxes DDL lands at the next free
index. The emitted SQL is byte-identical to the dropped 0273.

Co-Authored-By: Claude <noreply@anthropic.com>

* fix(billing): consolidate the Max-tier entitlement onto one predicate

The Max tier was spelled five ways. The odd one out — `isMax`, defined as
`isPro(plan) && credits >= 25000` — excluded both `team_25000` and
`enterprise`, and it was the sole input to the personal-workspace cap. A
delinquent Max-for-Teams org admin got 1 personal workspace while a
delinquent Max individual got 10. Only free/pro_6000/pro_25000 were tested,
so the two broken tiers were unpinned.

Separately, the server gate and the client `hasUsableMaxAccess` were
independent copies of the same rule. The settings sidebar renders Sandboxes
and Sim Mailer from the client one while the API answers 403 from the
server one, so any drift renders a feature unlocked that the API refuses.

- `MAX_TIER_CREDITS` is derived from the `CREDIT_TIERS` table; `isMaxTier`
  in plan-helpers is now the single definition, shared by the server gates,
  the client derivation, `getPlanTypeForLimits`, `plan-view`, and the cap
- `hasWorkspaceTierAccess(id, predicate, { intent, onMissingWorkspace })`
  becomes the one org-vs-personal payer fork. `intent: 'active-use'` means
  active and not billing-blocked; `'retention'` means active/past_due with
  block state ignored, so the inbox teardown guard keeps its fail-open
  semantics instead of implying them through a duplicated fork
- `isWorkspaceOnEnterprisePlan`'s personal branch now applies the status and
  block checks its own org branch always had, and its TSDoc names its real
  consumer (copilot BYOK, not Access Control)
- the client live-sync gate gained the server's `isHosted` branch, so a
  self-hosted deploy with billing on no longer locks an interval the API
  accepts. It reads both flags directly rather than taking one as a
  parameter the callers sourced from the same module
- `sqlIsPro`/`sqlIsTeam` escape the `_` LIKE wildcard, matching the already
  correct hand-rolled filter in seat-drift
- deletes the `TERMINAL_SUBSCRIPTION_STATUSES` and `ENTITLED_STATUSES`
  shadow constants, and corrects three test mocks that asserted `trialing`
  was entitled or usable

`max-tier-parity.test.ts` asserts the client and server answers match for
every plan name. Both new guards were checked against the old code: the
parity test fails 3 assertions with the previous predicate, and the
self-hosted test fails without the `isHosted` branch.

Co-Authored-By: Claude <noreply@anthropic.com>

* chore(db): drop the sandboxes migration ahead of the staging merge

Staging has claimed 0275 (table_views) and 0276 (drop_legacy_folder_tables)
since the last merge, so our 0275_workspace_sandboxes collides on the index.

Dropping ours first — the .sql, meta/0275_snapshot.json, and the journal
entry — leaves packages/db/migrations byte-identical to the merge-base, so
the merge sees no add/add conflict at all. Regenerated on the far side.

Ours is the droppable side: plain additive DDL with no hand edits, which
drizzle reproduces exactly. Staging's migrations are hand-written and must
survive.

Co-Authored-By: Claude <noreply@anthropic.com>

* chore(db): regenerate the sandboxes migration as 0277

Staging claimed 0275 (table_views) and 0276 (drop_legacy_folder_tables), so
the sandboxes migration dropped before the merge comes back on top as 0277.

The emitted SQL is byte-identical to what was dropped — the original had no
hand edits, so there is nothing to reapply. It is purely additive: two enums,
sandbox_image and workspace_sandbox, their two FKs and six indexes. That it
regenerated unchanged also confirms the schema.ts auto-merge was correct —
had it lost staging's legacy-folder-table drops, drizzle would have emitted
CREATE TABLE for them here.

Snapshot chain is continuous (0273 -> 0277, each prevId matching the previous
id) and the table counts track the DDL: 100 -> 101 (table_views) -> 99
(legacy folder tables dropped) -> 101 (the two sandbox tables).

Co-Authored-By: Claude <noreply@anthropic.com>

* feat(sandboxes): gate on the enterprise feature flags, drop the rollout switch

Sandboxes shipped behind `custom-sandboxes`, an AppConfig rollout flag falling
back to a `CUSTOM_SANDBOXES` secret. That made it the only Max-gated surface
with no self-hosted path: `INBOX_ENABLED` can force Sim Mailer on for an
operator running their own billing, and `ENTERPRISE_ENABLED` turns on the
other nine features at once, but neither reached sandboxes. A self-hoster had
to find a separately-named variable that was not part of that family, and one
running with billing enabled could not enable it at all.

Sandboxes now joins the enterprise feature set and the rollout flag is gone:

- `sandboxes` is an `EnterpriseFeature` with `SANDBOXES_ENABLED` and its
  `NEXT_PUBLIC_` twin, so the master switch and the per-feature override both
  reach it like every sibling
- `hasWorkspaceSandboxAccess` takes the inbox's shape exactly — the override
  wins, then a deployment without billing is unrestricted, then the workspace
  payer needs usable Max or Enterprise
- the settings nav gains `selfHostedOverride`, so the section resolves through
  the same path as Sim Mailer instead of a second entitlement AND-ed in
- `custom-sandboxes`, the `CUSTOM_SANDBOXES` secret, the now-unreachable
  `SANDBOXES_UNAVAILABLE` 403 copy, and the route's kill-switch branch are
  deleted

Its legacy default is `true`, matching `inbox`: the gate already returns true
whenever billing is off, so `false` would leave the nav override disagreeing
with the gate that answers the request. Self-hosted builds run on the
operator's own E2B/Daytona credentials, so there is no Sim-side cost to
withhold — the docs now say so, since enabling the feature without a provider
configured is the obvious trap.

The new gate tests run with billing enabled on purpose; the `!isBillingEnabled`
bail would otherwise answer every case and hide whether the override is wired.
Verified by deleting the override line — exactly the one assertion fails.

Co-Authored-By: Claude <noreply@anthropic.com>

* fix(sandboxes): let the language menu match its trigger width

`matchTriggerWidth={false}` exists for the opposite case — a narrow trigger
whose option labels would truncate, letting the menu grow past it. The language
field is a full-width form control with two short labels, so the override
shrank the menu to "JavaScript" and pinned it to the right edge instead.

The default (`true`) is correct here. Every other consumer passing `false` is a
genuinely narrow trigger — a role picker in a member row, a table filter chip.

Co-Authored-By: Claude <noreply@anthropic.com>

* fix(sandboxes): re-queue a build when resolution finds the image unusable

`ensureSandboxImage` only ran when a sandbox was saved, so resolution treated
an unusable image as terminal and told the user to go fix a definition that was
never wrong. Three states stuck permanently until someone re-saved in Settings:

- a build that failed
- a build whose worker died mid-flight, stranding the row in `building`
- every sandbox created while the deployment ran a `runtime` provider, after a
  switch to a `prebuilt` one — `runtime` writes no image rows at all, so the
  whole fleet resolved to "no completed build" with nothing to repair it

Resolution now re-queues through the registry's existing idempotent entry point
before failing, and says a build is on its way instead of pointing at Settings.
The conflict guard already claims only a `failed` row or a stale `pending`/
`building` one, so executions arriving during a healthy build enqueue nothing —
no thundering herd from a hot workflow.

The registry is imported dynamically for the same reason `sandboxDb` is: it
pulls `@sim/db` into the static graph, which this module keeps out of the
executor bundle. That also avoids a cycle, since the registry imports
`invalidateSandboxResolution` from here. A repair that itself fails is logged
and swallowed — it must never replace the build error naming the sandbox.

Verified by deleting the repair call: exactly the three new assertions fail.

Co-Authored-By: Claude <noreply@anthropic.com>

* improvement(sandboxes): let the picker show just the sandbox name

The label read "Test · Python · 1 package". The block's own list is already
scoped to the language its sibling `language` subblock selects, so the language
repeated on every row said nothing, and the package count is decoration next to
the name that identifies the sandbox.

The language stays for the one caller that cannot filter — agent tool-input
renders this field under a synthetic id where the sibling `language` value is
unreachable, so its list spans both languages and the name alone is ambiguous.
That is the same missing value which disables filtering, so `showLanguage` is
derived from it directly rather than passed independently and left to drift.

A failed build is still marked: that suffix is the difference between a
selection that runs and one that does not.

Passing the flag also means dropping `.map(toSandboxOption)` for an explicit
arrow — `Array.map` hands the index to the second parameter.

Co-Authored-By: Claude <noreply@anthropic.com>

* fix(sandboxes): show the sandbox name on the block card, not its uuid

The card printed "443f4934-26ab-44ab-8...". `resolveDropdownLabel` only reads a
subblock's static `options` array, and the sandbox picker is a `combobox` whose
options load asynchronously, so its array is empty and the raw stored id fell
through to the label.

Resolved the same way skills and tools already are: a `resolveSandboxLabel` in
the display layer, fed from the shared sandbox list query — the same cache entry
the picker reads, so this adds no request.

Two deliberate scopings:

- the query is subscribed only for the sandbox row. `SubBlockRow` is memoized
  per subblock, and the list query polls while a build is in flight, so an
  unconditional hook would re-render every row on the canvas on each poll tick
- the resolver matches the field id, not just the type. There is no dedicated
  subblock type for it, and matching `combobox` alone would relabel unrelated
  pickers

An id with no matching sandbox resolves to null rather than a guess, so a
deleted sandbox falls through to the caller's placeholder. The template preview
surface is left alone: it is explicitly hook-free and passes empty lists for
tools and skills too.

Co-Authored-By: Claude <noreply@anthropic.com>

* fix(sandboxes): hide the Sandboxes section with no provider configured

Entitlement decides whether a workspace may author sandboxes; nothing decided
whether anything could run one. A self-hosted deployment with SANDBOXES_ENABLED
but no E2B or Daytona credentials got a fully functional tab whose output no
Function block could select — the picker is gated on the provider vars, the tab
was not.

Both navigation planes now drop the section when neither
NEXT_PUBLIC_SANDBOX_ENABLED nor the pre-Daytona NEXT_PUBLIC_E2B_ENABLED is set —
the same pair the picker's `showWhenEnvSet` reads, so the two cannot disagree.
Dropped rather than locked: an upgrade does not conjure a provider.

The unified plane drops it in `buildUnifiedSettingsNavigation` rather than in the
sidebar's filter, because the sidebar's `selfHostedOverride` short-circuit runs
before its `requiresMax` check and would have revealed the tab anyway. It reads
the browser twins, not the server's `isRemoteSandboxEnabled`, since this module
renders on both sides.

The predicate is a function, not a module constant, because the constant form was
untestable and ambient: the env mock falls through to `process.env`, and
`apps/sim/.env` (gitignored, so absent on CI) sets NEXT_PUBLIC_E2B_ENABLED=true.
The nav tests passed locally and failed 6 assertions with the flag cleared. They
now pin both flags, so the suite is identical with and without a local env file —
verified by running it both ways.

Co-Authored-By: Claude <noreply@anthropic.com>

* docs(sandboxes): correct three claims the code no longer makes

The Sandboxes section described behavior two commits on this branch changed, and
led with an internal detail no reader needs.

- entitlement is no longer Max/Enterprise only: self-hosted deployments unlock
  sandboxes with SANDBOXES_ENABLED, and the section is hidden outright when a
  deployment has no sandbox provider, which is the state a self-hoster is most
  likely to hit and least likely to diagnose
- a build that is not Ready is no longer terminal. It is queued again on the next
  run, so the advice is to wait and re-run, not to go edit a package list that
  was never wrong
- deleting a sandbox frees its build once nothing else references it. Builds are
  shared by content, so this is the one place a reader could reasonably assume
  deletion is immediate

Dropped the `ModuleNotFoundError` aside: what the old code did instead is not
something a reader needs to know to use the feature.

The page is hand-written — `function` has category 'blocks' and is absent from
`NATIVE_RESOURCE_BLOCK_TYPES`, so generate-docs skips it and these edits will not
be overwritten.

Co-Authored-By: Claude <noreply@anthropic.com>

* feat(sandboxes): release the provider image when nothing references it

Deleting a sandbox only removed its row, leaving the built template in E2B until
the 30-day retention sweep — up to a month of paying to store an image nothing
could select. Editing a package list had the same effect on the old content
address, which is the more common case since every edit re-points the sandbox.

`releaseSandboxImage(specHash)` now deletes the provider image and its row from
both paths. It reuses the sweep's provider call and its ordering: image first,
row second, so a refused delete leaves the row for the sweep to retry rather than
orphaning a remote template nothing points at.

Two guards make eager deletion safe:

- builds are keyed by content, not by workspace, so two workspaces declaring the
  same package list share one image. The release no-ops while any sandbox still
  references the hash — otherwise one workspace's delete would break the other's
- an in-flight build is left alone rather than raced; the sweep collects it once
  it settles

Called detached from both routes. The row is already committed by then, so the
user's action has succeeded whatever the provider says, and awaiting would hold a
UI delete open on a remote call the sweep would retry anyway. Every failure inside
is logged and swallowed for the same reason.

E2B's delete verified against their API reference: DELETE /templates/{templateID}
with X-API-Key, 204 on success. The existing implementation already matched, so
this commit only adds the call sites and the guards.

Co-Authored-By: Claude <noreply@anthropic.com>

* fix(sandboxes): rate-limit the automatic rebuild, drop the one-off status dot

Two follow-ups to the resolution repair.

The repair had no rate limit. `ensureSandboxImage` re-claims a `failed` row on
sight, and a bad package name fails in seconds, so the in-flight guard never
closed the window: a workflow on a one-minute schedule would enqueue a build a
minute against a package list that will never resolve, each one real provider
build compute. Before the repair existed resolution simply threw, so this was
introduced with it.

The two callers want different things, so the cooldown is opt-in. A save is a
person explicitly asking for another attempt and still retries immediately;
resolution passes `FAILED_BUILD_RETRY_COOLDOWN_MS` and gets at most one attempt
per window no matter how often the workflow runs. Ten minutes: long enough that
per-minute runs cannot drive per-minute builds, short enough that a transient
registry outage clears within the hour.

The status line loses its colour dot. `size-[6px] rounded-full` appeared in
exactly one file in the repo, so it was a new primitive rather than a pattern,
and it duplicated state the text colour already carries — the label now turns
`--text-error` on a failed build, which is what every other status row in
settings does. `ChipTag` was the wrong home for this: its variants are
`mono`/`invite`, with no semantic tone, so a status version would have meant
overriding its chrome from the consumer.

Also corrects the docs line this changes: a failed build is retried periodically,
and saving is the way to retry now, so "wait a moment and run again" no longer
describes it.

Co-Authored-By: Claude <noreply@anthropic.com>

* fix(sandboxes): claim the image row and its reference check in one statement

Greptile P1. Reading references in one statement and deleting in another left a
window — a wide one, since a provider delete is a network call — where a second
workspace could declare the same package list, inherit the `ready` row, and have
its next run fail against a template already on its way out. Content addressing
is what makes that reachable: the image is shared, so one workspace's delete can
strand another's sandbox.

The reference check now lives in the conditional DELETE itself, so winning the
delete is the proof that nothing referenced the hash. A workspace that adopts the
hash first makes the delete match nothing and the release becomes a no-op.

Claiming the row before the provider call would otherwise strand a template
nothing points at if the provider then refused, so that path puts the row back
and the retention sweep inherits the retry — the same property the previous
ordering had.

The sweep is deliberately left as it is: its equivalent window needs a hash
unreferenced AND unused for 30 days, and its provider-first ordering encodes the
documented retry-on-refusal behaviour this path now reproduces explicitly.

No transaction is opened. The provider call sits between discrete statements
rather than inside one, so no pooled connection is held across it — which is why
this uses a conditional delete instead of the repo's `pg_advisory_xact_lock`
pattern, whose lock only releases at commit.

Co-Authored-By: Claude <noreply@anthropic.com>

* fix(sandboxes): route the retention sweep through the same image claim

Cursor and Greptile both flagged the sweep as still carrying the interleaving
just fixed in releaseSandboxImage, and they are right — the reason given for
leaving it alone last round does not survive scrutiny.

That reason was that provider-first ordering encodes retry-on-refusal, so making
the claim atomic would trade a race for an orphaned template. The release path
already answers that: claim the row, and put it back if the provider refuses. The
sweep can have both properties too.

The rarity argument was also weaker than stated. The sweep nominates up to 200
candidates and then works through them eight network deletes at a time, so its
check-to-delete gap is seconds to minutes — wider than the window that was just
closed, not narrower.

Both callers now share `claimAndDeleteImage`, which owns the whole contract: the
unreferenced check lives inside the DELETE, the provider call runs only after the
claim succeeds, and a refusal restores the row. Having written that ordering twice
is what let the two paths drift, so it exists once now.

The sweep's query becomes a nomination step only. Its retention cutoff is passed
into the claim rather than trusted from the earlier read, so a candidate that
stops qualifying mid-sweep fails its claim and is skipped instead of losing its
image.

Co-Authored-By: Claude <noreply@anthropic.com>

* fix(sandboxes): rebuild a hash adopted while its image was being deleted

Greptile's third pass on this path, and a case the previous two did not cover:
the adopter starting a *fresh build* rather than inheriting a ready row.

Claiming removes the registry row, so between that and the provider delete
finishing, a workspace can declare the same package list, get a new row, and start
a build under the same content-derived imageRef — which the in-flight delete then
removes.

The window itself is inherent. The registry row and the provider template are two
systems with no shared transaction, so it can be narrowed but not closed. A Redis
lock would not close it either: acquireLock returns true when Redis is absent, so
it cannot be a correctness guarantee for self-hosted. Holding a Postgres advisory
lock would, but only by pinning a pooled connection for the length of a provider
call, which is a worse trade.

What was avoidable is the adopter finding out the slow way. Its row is new and
healthy-looking, so nothing noticed: resolution only repairs a row that is missing
or failed, and a failed one waits out the retry cooldown first. The release path
now re-checks after the delete and re-enqueues, so the rebuild starts immediately
instead of one failed run plus a cooldown later. A build already in flight is left
to the conflict guard, since it may still outlive the delete.

Co-Authored-By: Claude <noreply@anthropic.com>

* fix(sandboxes): reclaim a ready row whose image was deleted underneath it

Greptile found the hole the previous commit left, and it is the case that made the
claim in that commit's message wrong: this one is permanent, not transient.

If a re-adopted hash reaches `ready` before the in-flight provider delete lands —
plausible, since E2B layer caching can rebuild an identical spec in seconds — the
row looks healthy while its imageRef points at nothing. Resolution repairs a row
that is missing or failed, never one claiming to be ready, so nothing recovers it.
The sandbox stays broken until someone re-saves it by hand.

`rebuildIfReadopted` called `ensureSandboxImage` with no options, whose conflict
guard reclaims only a failed or stale in-flight row, so it silently did nothing in
exactly that case.

The release path now passes `imageKnownGone`, which widens the re-claim to any
settled row rather than only a failed one. It is the one caller that knows the
image is gone regardless of what the row says. An in-flight build is still left
alone: it either recreates the template it was building or fails into the normal
repair path, and resetting it would only add a duplicate build.

The three ways a settled row may be re-claimed now sit in one `settledRebuildBranch`
helper — any settled row when the image is known gone, a failed one after the
cooldown for an automatic caller, a failed one immediately for a person — because
inlining the third case is what hid the gap.

Co-Authored-By: Claude <noreply@anthropic.com>

* fix(sandboxes): let a same-spec save retry a failed build

Cursor Bugbot. `scheduleSandboxBuild` sat inside the changed-hash branch, so a save
that did not alter the package list never reached the registry. The comment above
it described the opposite — that an unchanged spec finds a ready row and enqueues
nothing — which is what `ensureSandboxImage` does, but only if it is called.

That made the docs wrong too. They tell a reader to save the sandbox again to retry
a failed build immediately, and this branch is exactly why that did nothing: the
only way to retry was to edit the package list into a different hash, which is not
what someone recovering from a transient registry failure wants to do.

The call is now unconditional and the registry decides what a save costs, which is
what its conflict guard is for: a ready or in-flight row is left alone, a failed one
is re-claimed at once. Releasing the previous image stays behind the hash check,
since only a changed hash orphans one. Cache invalidation is unchanged —
`scheduleSandboxBuild` already does it, which is why the else branch existed.

Co-Authored-By: Claude <noreply@anthropic.com>

* docs(sandboxes): correct the image cache's staleness invariant

Cursor Bugbot found that a released image can still be served from another
replica's cache. The finding is real, and the reason it went unnoticed is that the
cache documented an invariant which eager release quietly broke.

It claimed a `ready` row is terminal for its spec hash, so a cached hit could not
go stale in a way that matters. That held while the only ways a row changed were an
edit (new hash) or a delete (caught by the `workspace_sandbox` read). Releasing an
image eagerly made a `ready` row disappear with the hash unchanged, so the premise
no longer holds and the comment was actively misleading to the next reader.

No behaviour change here — the exposure is bounded at IMAGE_TTL_MS on replicas
other than the one that ran the release, and it self-heals once the entry expires
and the row read finds nothing. Closing it properly needs cross-replica
invalidation or a provider-error path that invalidates on "template not found",
both of which are larger than a review fix; the comment now says so instead of
implying the problem cannot exist.

Co-Authored-By: Claude <noreply@anthropic.com>

* docs(sandboxes): note that a JavaScript sandbox needs an import to apply

Cursor Bugbot pointed out that `useRemoteSandbox` keys on detected static
import/require and never on the selected sandbox, so JavaScript without one runs
locally and the selection has no effect.

Keeping the behaviour: honouring the selection would force those blocks remote,
and the large-value-ref guard immediately below would then reject code that runs
fine today. Documenting it instead, next to the picker, since a selection that
silently does nothing is only surprising if nothing says so.

Python is unaffected — it always runs remotely, so its sandbox always applies.

Co-Authored-By: Claude <noreply@anthropic.com>

* fix(sandboxes): stop create mode surviving a return to an open sandbox

Cursor Bugbot. Create mode and having a sandbox open are mutually exclusive, but
nothing enforced it, so both could be set at once — and the screen then lied about
which sandbox its Delete pointed at.

With `isCreating` true and `selectedId` restored, `baseline` is null, so the editor
renders an empty "New sandbox" form, while the Delete action is built from
`selected` and still targets the restored sandbox. An admin looking at a blank
create form could delete a sandbox it never named.

Two ways in, both closed:

- Browser Forward after starting a new sandbox restores `selectedId` without going
  through `closeEditor`. The render-time sync that already drops a stale draft now
  also leaves create mode, which is the same class of correction and the reason
  that block exists.
- "New sandbox" set `isCreating` without clearing `selectedId`, so the same
  contradiction was reachable without touching history at all. It now clears the
  selection, with `history: 'replace'` because switching mode is not a destination.

Co-Authored-By: Claude <noreply@anthropic.com>

* fix(ci): pin the sandbox flag in the second nav catalog test, bump the chart

Two CI failures, both mine.

`app/workspace/[workspaceId]/settings/navigation.test.ts` asserts the unified
catalog and was left on ambient env. Dropping the Sandboxes section without a
sandbox provider made it 26 items instead of 27 on CI, which has no
`apps/sim/.env` — the same trap already fixed in the sibling
`components/settings/navigation.test.ts`, in the one file that was missed.

Fixing it needs `vi.hoisted` rather than the sibling's `beforeEach`, because this
file reads `allNavigationItems`, built once at module load; a hook would run after
the value it is trying to influence already exists.

The chart gate is separate: this branch adds sandbox settings to
`helm/sim/values.yaml`, and the workflow requires a Chart.yaml bump whenever
`helm/sim/**` changes. Additive config, so 1.3.0 -> 1.4.0 by SemVer.

Verified by running the whole suite with the flags forced off, not just the two
navigation files — no other test depends on a local env file.

Co-Authored-By: Claude <noreply@anthropic.com>

* fix(sandboxes): keep the row restore to a refused delete only

Cursor and Greptile, independently, on the same code. `deleteImage` and
`rebuildIfReadopted` shared one try/catch, so a rebuild failure after a *successful*
provider delete was handled as if the provider had refused: the catch put the
claimed row back, `ready` status and all, pointing at a template that no longer
exists.

That is the one state resolution cannot repair — it fixes a row that is missing or
failed, never one claiming to be ready — so it reintroduced the permanent breakage
an earlier commit had just closed, through the error path rather than the happy one.

Restoring now belongs strictly to a refused delete. Once the template is gone the
row stays gone, and the rebuild runs past that catch. The rebuild also swallows its
own failures: it follows a delete that already succeeded, so it must not be reported
as a failed release, and inside the sweep it must not reject the rest of its chunk.
The adopter's next run still reaches the normal repair path.

The regression test drives a rebuild failure and asserts no row is restored. It
fails against the original shape — rebuild inside the shared try, no inner catch —
which is what the two reviewers were describing.

Co-Authored-By: Claude <noreply@anthropic.com>

* fix(sandboxes): drop the dead row when a re-adopt rebuild cannot be scheduled

Greptile, one layer under the previous fix. Making the post-delete rebuild swallow
its own failures kept it from being reported as a failed release, but left the
adopter's row claiming a `ready` image whose template is already deleted — the one
state resolution cannot repair, since it rebuilds a row that is missing or failed
and never one that says ready.

So the row is now dropped when the rebuild does not take. That turns the adopter
into the missing-row case, which the next execution repairs on its own, instead of
a sandbox that stays broken until someone re-saves it by hand. A failure to drop it
is logged at error, because at that point two writes in a row have failed and there
is nothing further this path can do.

Also gives the release tests a default "nothing re-adopted" select. Without it the
rebuild threw on an unstubbed mock and the cleanup delete overwrote the predicate
the claim assertions read, so two of them were passing on the wrong statement.

Co-Authored-By: Claude <noreply@anthropic.com>

* feat(sandboxes): repair a missing image at create, where the truth is observable

Six review rounds narrowed the window between deleting a shared template and
another workspace adopting its content hash, and each fix exposed the next facet.
They all share a cause: the registry row and the provider template are two systems
with no shared transaction, so any scheme that keeps them in step is guessing.

Create is the one step that does not have to guess. It either gets a sandbox or it
does not, so a `ready` row pointing at a deleted template now corrects itself the
first time it is used, rather than needing someone to re-save the sandbox.

- `SandboxImageBuilder.isMissingImage` asks the provider to classify its own
  failure. Prebuilt-only, because a runtime provider has no image to miss
- E2B answers it off `NotFoundError`, which the SDK maps from a 404. The only
  resource a create names is the template, and the two subclasses that describe
  other calls — a missing file, an exited sandbox — are excluded. The classifier
  stays deliberately narrow: treating auth or rate-limit failures as a missing
  image would turn a provider outage into a build storm
- `repairMissingSandboxImage` invalidates the cache, rebuilds with
  `imageKnownGone` (no cooldown, since this observed the image is gone rather than
  inferring it), and returns copy telling the author to run again
- `ResolvedSandbox` carries `specHash` so the failing execution can name what to
  rebuild

This subsumes the open facets rather than adding another guard beside them: the
stale per-replica cache, an adopter left `ready` against a deleted ref, and a
rebuild that never took all end at the same place — the next run repairs itself.

Co-Authored-By: Claude <noreply@anthropic.com>

* fix(sandboxes): key the build trigger by attempt, not by spec

Cursor Bugbot. The Trigger.dev idempotency key was the content address alone, so a
second attempt at the same spec was deduped against the first: the SDK returns the
finished run instead of starting one, and the row that `ensureSandboxImage` just
flipped to `pending` sits there with no worker. Nothing can re-claim a `pending`
row until it goes stale, so a retry inside the 5-minute TTL did nothing for the
next half hour.

That silently disabled every repair path — save-to-retry, which the docs name
explicitly, and both the resolution and create-time rebuilds.

The key's own comment already said it exists "to collapse concurrent saves of the
same spec into one build, not to suppress a retry after one failed". The conditional
update above it is what actually collapses concurrent saves: only one caller gets a
row back, so only one ever reaches the trigger. Keying by the claim's `updatedAt`
keeps that property and makes each genuine attempt distinct, while a duplicate
delivery of one attempt still collapses.

Co-Authored-By: Claude <noreply@anthropic.com>

* feat(sandboxes): create a sandbox from the picker, and fix three UI papercuts

The Function block's sandbox field now pins a "Create Sandbox" row above its
options, matching the "Create Skill" / "Create Tool" rows it sits beside, so
authoring a package list no longer means leaving the workflow for Settings. The
row is declared by the field (`createAction`) rather than hardcoded by id;
block configs are read by the serializer and executor, so the name maps to a
modal in the picker rather than carrying a component.

Two things the modal has to get right. It seeds the new sandbox's language from
the sibling the list is scoped by, or a sandbox created off a JavaScript block
would land in the Python list and vanish. And the created option is held locally
until a real fetch carries it, or the field would sit on a raw uuid until
hydration answered.

Also:
- The Sandboxes icon was the Logs block's icon (`blocks/blocks/logs.ts`), in
  both the settings nav and the list rows. It is the Function block's now.
- "Default image (no extra packages)" claimed something untrue: E2B and Daytona
  base images both ship with packages installed.
- A new sandbox opened in Python while the Function block defaults to
  JavaScript. The test pins the two together rather than the literal.

Draft shape and helpers moved out of the editor component into `utils.ts` —
three consumers now, and it makes the defaults testable without a DOM.

* feat(settings): one Max-plan wall, and give the create modal the same one

The create-sandbox modal answered a non-Max workspace with a red line under a
form it could never submit, and no way to act on it. It now renders the same
wall the Settings > Sandboxes tab does — heading, one sentence on what the plan
unlocks, and an Upgrade to Max chip — instead of the fields.

That wall existed twice already (sandboxes and Sim Mailer), so this extracts it
rather than adding a third copy. `SettingsUpgradeNotice` owns the copy rhythm
and the route, and `compact` trades the page's full-height centering for a
modal's. Both settings consumers now compose it; neither keeps its own markup.

The action lands on billing, which `resolveSettingsHref` already redirects to
the plan-comparison page for a member who cannot manage billing — so it is a
route to explore plans, never a dead end. The chip stays hidden for non-admins,
exactly as the settings pages had it.

A non-admin on an entitled workspace gets the muted reason rather than the
upgrade wall: buying a plan is not what is in their way.

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-07-30 16:33:35 -07:00

4358 lines
176 KiB
TypeScript

import { type SQL, sql } from 'drizzle-orm'
import {
type AnyPgColumn,
bigint,
boolean,
check,
customType,
decimal,
doublePrecision,
index,
integer,
json,
jsonb,
pgEnum,
pgTable,
primaryKey,
text,
timestamp,
uniqueIndex,
uuid,
vector,
} from 'drizzle-orm/pg-core'
import { DEFAULT_FREE_CREDITS, TAG_SLOTS } from './constants'
// Custom tsvector type for full-text search
export const tsvector = customType<{
data: string
}>({
dataType() {
return `tsvector`
},
})
export const user = pgTable('user', {
id: text('id').primaryKey(),
name: text('name').notNull(),
email: text('email').notNull().unique(),
normalizedEmail: text('normalized_email').unique(),
emailVerified: boolean('email_verified').notNull(),
image: text('image'),
createdAt: timestamp('created_at').notNull(),
updatedAt: timestamp('updated_at').notNull(),
stripeCustomerId: text('stripe_customer_id'),
role: text('role').default('user'),
banned: boolean('banned').default(false),
banReason: text('ban_reason'),
banExpires: timestamp('ban_expires'),
})
export const session = pgTable(
'session',
{
id: text('id').primaryKey(),
expiresAt: timestamp('expires_at').notNull(),
token: text('token').notNull().unique(),
createdAt: timestamp('created_at').notNull(),
updatedAt: timestamp('updated_at').notNull(),
ipAddress: text('ip_address'),
userAgent: text('user_agent'),
userId: text('user_id')
.notNull()
.references(() => user.id, { onDelete: 'cascade' }),
activeOrganizationId: text('active_organization_id').references(() => organization.id, {
onDelete: 'set null',
}),
impersonatedBy: text('impersonated_by'),
},
(table) => ({
userIdIdx: index('session_user_id_idx').on(table.userId),
tokenIdx: index('session_token_idx').on(table.token),
})
)
export const account = pgTable(
'account',
{
id: text('id').primaryKey(),
accountId: text('account_id').notNull(),
providerId: text('provider_id').notNull(),
userId: text('user_id')
.notNull()
.references(() => user.id, { onDelete: 'cascade' }),
accessToken: text('access_token'),
refreshToken: text('refresh_token'),
idToken: text('id_token'),
accessTokenExpiresAt: timestamp('access_token_expires_at'),
refreshTokenExpiresAt: timestamp('refresh_token_expires_at'),
scope: text('scope'),
password: text('password'),
createdAt: timestamp('created_at').notNull(),
updatedAt: timestamp('updated_at').notNull(),
},
(table) => ({
userIdIdx: index('account_user_id_idx').on(table.userId),
accountProviderIdx: index('idx_account_on_account_id_provider_id').on(
table.accountId,
table.providerId
),
})
)
export const verification = pgTable(
'verification',
{
id: text('id').primaryKey(),
identifier: text('identifier').notNull(),
value: text('value').notNull(),
expiresAt: timestamp('expires_at').notNull(),
createdAt: timestamp('created_at'),
updatedAt: timestamp('updated_at'),
},
(table) => ({
identifierIdx: index('verification_identifier_idx').on(table.identifier),
expiresAtIdx: index('verification_expires_at_idx').on(table.expiresAt),
})
)
export const folderResourceTypeEnum = pgEnum('folder_resource_type', [
'workflow',
'file',
'knowledge_base',
'table',
])
/**
* Generic folder hierarchy shared by workflows, files, knowledge bases, and tables.
* Supersedes the resource-specific `workflow_folder` and `workspace_file_folders` tables,
* dropped in migration 0276 once the cutover was verified against production.
*
* `resourceType` is a real `pgEnum` here — unlike `pinnedItem.resourceType` — because the
* set of folder-bearing resources is small and fixed. A folder may only parent a folder
* of the same `resourceType` in the same workspace; that invariant is enforced both in
* the application layer and by the `folder_parent_resource_type_match` trigger, since a
* plain FK cannot express it.
*
* `locked` carries over the existing workflow-folder lock feature verbatim. It is NOT
* extended to the other resource types — file/knowledge_base/table folders leave it at
* `false` and no lock cascade reads it for them. Dropping the column would regress
* shipped workflow-folder locking.
*
* `color` and `isExpanded` from the old `workflow_folder` table are intentionally not
* carried over:
* `color` has no UI consumer, and `isExpanded`'s real state lives client-side in the
* folders Zustand store and is never read back from the DB.
*/
export const folder = pgTable(
'folder',
{
id: text('id').primaryKey(),
resourceType: folderResourceTypeEnum('resource_type').notNull(),
name: text('name').notNull(),
userId: text('user_id')
.notNull()
.references(() => user.id, { onDelete: 'cascade' }),
workspaceId: text('workspace_id')
.notNull()
.references(() => workspace.id, { onDelete: 'cascade' }),
parentId: text('parent_id').references((): AnyPgColumn => folder.id, {
onDelete: 'set null',
}),
locked: boolean('locked').notNull().default(false),
sortOrder: integer('sort_order').notNull().default(0),
createdAt: timestamp('created_at').notNull().defaultNow(),
updatedAt: timestamp('updated_at').notNull().defaultNow(),
deletedAt: timestamp('deleted_at'),
},
(table) => ({
userIdx: index('folder_user_idx').on(table.userId),
workspaceResourceParentIdx: index('folder_workspace_resource_parent_idx').on(
table.workspaceId,
table.resourceType,
table.parentId
),
parentSortIdx: index('folder_parent_sort_idx').on(table.parentId, table.sortOrder),
deletedAtIdx: index('folder_deleted_at_idx').on(table.deletedAt),
workspaceDeletedAtPartialIdx: index('folder_workspace_deleted_partial_idx')
.on(table.workspaceId, table.deletedAt)
.where(sql`${table.deletedAt} IS NOT NULL`),
/**
* Carries over the active-unique key the old `workspace_file_folders` table enforced,
* and extends it to workflow folders, which never had one — 0272's backfill deduplicated
* the 47 pre-existing violations it surfaced.
*/
workspaceResourceParentNameActiveUnique: uniqueIndex(
'folder_workspace_resource_parent_name_active_unique'
)
.on(table.workspaceId, table.resourceType, sql`coalesce(${table.parentId}, '')`, table.name)
.where(sql`${table.deletedAt} IS NULL`),
})
)
/**
* Per-user pinning of workspace resources. Polymorphic on `resourceType`, following
* the same shape as `publicShare.resourceType` below — deliberately plain `text`
* rather than a `pgEnum`, because the set of pinnable kinds is expected to grow
* (folders become pinnable alongside the generic-folder work) and widening a text
* column costs nothing where widening an enum needs a migration.
*
* Pins are per-user, not per-workspace: two members of the same workspace pin
* independently, which is why `userId` leads the unique index and every read path
* filters on the session user.
*/
export const pinnedItem = pgTable(
'pinned_item',
{
id: text('id').primaryKey(),
userId: text('user_id')
.notNull()
.references(() => user.id, { onDelete: 'cascade' }),
workspaceId: text('workspace_id')
.notNull()
.references(() => workspace.id, { onDelete: 'cascade' }),
resourceType: text('resource_type').notNull(), // 'workflow' | 'file' | 'knowledge_base' | 'table' | 'folder'
resourceId: text('resource_id').notNull(),
pinnedAt: timestamp('pinned_at').notNull().defaultNow(),
},
(table) => ({
userWorkspaceIdx: index('pinned_item_user_workspace_idx').on(table.userId, table.workspaceId),
resourceIdx: index('pinned_item_resource_idx').on(table.resourceType, table.resourceId),
userResourceUnique: uniqueIndex('pinned_item_user_resource_unique').on(
table.userId,
table.resourceType,
table.resourceId
),
})
)
export const workflow = pgTable(
'workflow',
{
id: text('id').primaryKey(),
userId: text('user_id')
.notNull()
.references(() => user.id, { onDelete: 'cascade' }),
workspaceId: text('workspace_id').references(() => workspace.id, { onDelete: 'cascade' }),
folderId: text('folder_id').references(() => folder.id, { onDelete: 'set null' }),
sortOrder: integer('sort_order').notNull().default(0),
name: text('name').notNull(),
description: text('description'),
lastSynced: timestamp('last_synced').notNull(),
createdAt: timestamp('created_at').notNull(),
updatedAt: timestamp('updated_at').notNull(),
isDeployed: boolean('is_deployed').notNull().default(false),
deployedAt: timestamp('deployed_at'),
isPublicApi: boolean('is_public_api').notNull().default(false),
locked: boolean('locked').notNull().default(false),
forkSyncExcluded: boolean('fork_sync_excluded').notNull().default(false),
runCount: integer('run_count').notNull().default(0),
lastRunAt: timestamp('last_run_at'),
variables: json('variables').default('{}'),
archivedAt: timestamp('archived_at'),
},
(table) => ({
userIdIdx: index('workflow_user_id_idx').on(table.userId),
workspaceIdIdx: index('workflow_workspace_id_idx').on(table.workspaceId),
userWorkspaceIdx: index('workflow_user_workspace_idx').on(table.userId, table.workspaceId),
workspaceFolderNameUnique: uniqueIndex('workflow_workspace_folder_name_active_unique')
.on(table.workspaceId, sql`coalesce(${table.folderId}, '')`, table.name)
.where(sql`${table.archivedAt} IS NULL`),
folderSortIdx: index('workflow_folder_sort_idx').on(table.folderId, table.sortOrder),
archivedAtIdx: index('workflow_archived_at_idx').on(table.archivedAt),
workspaceArchivedAtPartialIdx: index('workflow_workspace_archived_partial_idx')
.on(table.workspaceId, table.archivedAt)
.where(sql`${table.archivedAt} IS NOT NULL`),
})
)
export const workflowBlocks = pgTable(
'workflow_blocks',
{
id: text('id').primaryKey(),
workflowId: text('workflow_id')
.notNull()
.references(() => workflow.id, { onDelete: 'cascade' }),
type: text('type').notNull(), // 'starter', 'agent', 'api', 'function'
name: text('name').notNull(),
positionX: decimal('position_x').notNull(),
positionY: decimal('position_y').notNull(),
enabled: boolean('enabled').notNull().default(true),
horizontalHandles: boolean('horizontal_handles').notNull().default(true),
isWide: boolean('is_wide').notNull().default(false),
advancedMode: boolean('advanced_mode').notNull().default(false),
triggerMode: boolean('trigger_mode').notNull().default(false),
locked: boolean('locked').notNull().default(false),
height: decimal('height').notNull().default('0'),
subBlocks: jsonb('sub_blocks').notNull().default('{}'),
outputs: jsonb('outputs').notNull().default('{}'),
data: jsonb('data').default('{}'),
createdAt: timestamp('created_at').notNull().defaultNow(),
updatedAt: timestamp('updated_at').notNull().defaultNow(),
},
(table) => ({
workflowIdIdx: index('workflow_blocks_workflow_id_idx').on(table.workflowId),
typeIdx: index('workflow_blocks_type_idx').on(table.type),
})
)
export const workflowEdges = pgTable(
'workflow_edges',
{
id: text('id').primaryKey(),
workflowId: text('workflow_id')
.notNull()
.references(() => workflow.id, { onDelete: 'cascade' }),
sourceBlockId: text('source_block_id')
.notNull()
.references(() => workflowBlocks.id, { onDelete: 'cascade' }),
targetBlockId: text('target_block_id')
.notNull()
.references(() => workflowBlocks.id, { onDelete: 'cascade' }),
sourceHandle: text('source_handle'),
targetHandle: text('target_handle'),
createdAt: timestamp('created_at').notNull().defaultNow(),
},
(table) => ({
workflowIdIdx: index('workflow_edges_workflow_id_idx').on(table.workflowId),
workflowSourceIdx: index('workflow_edges_workflow_source_idx').on(
table.workflowId,
table.sourceBlockId
),
workflowTargetIdx: index('workflow_edges_workflow_target_idx').on(
table.workflowId,
table.targetBlockId
),
})
)
export const workflowSubflows = pgTable(
'workflow_subflows',
{
id: text('id').primaryKey(),
workflowId: text('workflow_id')
.notNull()
.references(() => workflow.id, { onDelete: 'cascade' }),
type: text('type').notNull(), // 'loop' or 'parallel'
config: jsonb('config').notNull().default('{}'),
createdAt: timestamp('created_at').notNull().defaultNow(),
updatedAt: timestamp('updated_at').notNull().defaultNow(),
},
(table) => ({
workflowIdIdx: index('workflow_subflows_workflow_id_idx').on(table.workflowId),
workflowTypeIdx: index('workflow_subflows_workflow_type_idx').on(table.workflowId, table.type),
})
)
export const waitlist = pgTable('waitlist', {
id: text('id').primaryKey(),
email: text('email').notNull().unique(),
status: text('status').notNull().default('pending'), // pending, approved, rejected
createdAt: timestamp('created_at').notNull().defaultNow(),
updatedAt: timestamp('updated_at').notNull().defaultNow(),
})
export const workflowExecutionSnapshots = pgTable(
'workflow_execution_snapshots',
{
id: text('id').primaryKey(),
workflowId: text('workflow_id').references(() => workflow.id, { onDelete: 'set null' }),
stateHash: text('state_hash').notNull(),
stateData: jsonb('state_data').notNull(),
createdAt: timestamp('created_at').notNull().defaultNow(),
},
(table) => ({
workflowIdIdx: index('workflow_snapshots_workflow_id_idx').on(table.workflowId),
stateHashIdx: index('workflow_snapshots_hash_idx').on(table.stateHash),
workflowHashUnique: uniqueIndex('workflow_snapshots_workflow_hash_idx').on(
table.workflowId,
table.stateHash
),
createdAtIdx: index('workflow_snapshots_created_at_idx').on(table.createdAt),
})
)
export const workflowExecutionLogs = pgTable(
'workflow_execution_logs',
{
id: text('id').primaryKey(),
workflowId: text('workflow_id').references(() => workflow.id, { onDelete: 'set null' }),
workspaceId: text('workspace_id')
.notNull()
.references(() => workspace.id, { onDelete: 'cascade' }),
executionId: text('execution_id').notNull(),
stateSnapshotId: text('state_snapshot_id')
.notNull()
.references(() => workflowExecutionSnapshots.id),
deploymentVersionId: text('deployment_version_id').references(
() => workflowDeploymentVersion.id,
{ onDelete: 'set null' }
),
level: text('level').notNull(), // 'info' | 'error'
status: text('status').notNull().default('running'), // 'running' | 'pending' | 'completed' | 'failed' | 'cancelled'
trigger: text('trigger').notNull(), // 'api' | 'webhook' | 'schedule' | 'manual' | 'chat'
startedAt: timestamp('started_at').notNull(),
endedAt: timestamp('ended_at'),
totalDurationMs: integer('total_duration_ms'),
/**
* Heavy trace data (traceSpans, finalOutput, workflowInput, executionState)
* is externalized to object storage; this column then holds a slim payload:
* a `traceStoreRef` (__simLargeValueRef) pointer to the stored object plus
* inline markers (hasTraceSpans, traceSpanCount, environment, trigger,
* truncation flags). It also still holds the FULL payload inline for legacy
* / not-yet-backfilled rows, for the storage-write-failure fallback, and for
* job_execution_logs. Required — not droppable. Read it via
* `materializeExecutionData`, which resolves the pointer.
*/
executionData: jsonb('execution_data').notNull().default('{}'),
/** @deprecated Not written/read; cost lives in usage_log + the `cost_total` projection. Drop in a follow-up PR after the `cost_total` backfill. */
cost: jsonb('cost'),
// Faithful, write-once projection of the run's usage_log ledger sum (dollars).
// Backs list cost display/filter/sort without live aggregation; never an
// independently-computed value (cost_total == SUM(usage_log) for the run).
costTotal: decimal('cost_total'),
// Model names used by the run (incl. zero-cost/BYOK), for the v1 model filter.
modelsUsed: text('models_used').array(),
files: jsonb('files'), // File metadata for execution files
createdAt: timestamp('created_at').notNull().defaultNow(),
},
(table) => ({
workflowIdIdx: index('workflow_execution_logs_workflow_id_idx').on(table.workflowId),
stateSnapshotIdIdx: index('workflow_execution_logs_state_snapshot_id_idx').on(
table.stateSnapshotId
),
deploymentVersionIdIdx: index('workflow_execution_logs_deployment_version_id_idx').on(
table.deploymentVersionId
),
triggerIdx: index('workflow_execution_logs_trigger_idx').on(table.trigger),
levelIdx: index('workflow_execution_logs_level_idx').on(table.level),
startedAtIdx: index('workflow_execution_logs_started_at_idx').on(table.startedAt),
executionIdUnique: uniqueIndex('workflow_execution_logs_execution_id_unique').on(
table.executionId
),
workflowStartedAtIdx: index('workflow_execution_logs_workflow_started_at_idx').on(
table.workflowId,
table.startedAt
),
workspaceStartedAtIdx: index('workflow_execution_logs_workspace_started_at_idx').on(
table.workspaceId,
table.startedAt
),
workspaceStartedAtIdDescIdx: index(
'workflow_execution_logs_workspace_started_at_id_desc_idx'
).on(table.workspaceId, sql`${table.startedAt} DESC NULLS LAST`, sql`${table.id} DESC`),
workspaceCostTotalIdx: index('workflow_execution_logs_workspace_cost_total_idx').on(
table.workspaceId,
table.costTotal
),
modelsUsedIdx: index('workflow_execution_logs_models_used_idx').using('gin', table.modelsUsed),
workspaceEndedAtIdIdx: index('workflow_execution_logs_workspace_ended_at_id_idx').on(
table.workspaceId,
sql`date_trunc('milliseconds', ${table.endedAt})`,
table.id
),
runningStartedAtIdx: index('workflow_execution_logs_running_started_at_idx')
.on(table.startedAt)
.where(sql`status = 'running'`),
completedEndedAtIdx: index('workflow_execution_logs_completed_ended_at_idx')
.on(table.endedAt, table.workspaceId, table.executionId)
.where(
sql`${table.status} = 'completed' AND ${table.level} = 'info' AND ${table.endedAt} IS NOT NULL`
),
})
)
export const executionLargeValueReferenceSourceEnum = pgEnum(
'execution_large_value_reference_source',
['execution_log', 'paused_snapshot']
)
export const executionLargeValues = pgTable(
'execution_large_values',
{
key: text('key').primaryKey(),
workspaceId: text('workspace_id')
.notNull()
.references(() => workspace.id, { onDelete: 'cascade' }),
workflowId: text('workflow_id').references(() => workflow.id, { onDelete: 'set null' }),
ownerExecutionId: text('owner_execution_id').notNull(),
size: integer('size').notNull(),
createdAt: timestamp('created_at').notNull().defaultNow(),
deletedAt: timestamp('deleted_at'),
},
(table) => ({
ownerExecutionIdIdx: index('execution_large_values_owner_execution_id_idx').on(
table.ownerExecutionId
),
cleanupIdx: index('execution_large_values_cleanup_idx')
.on(table.workspaceId, table.createdAt, table.key)
.where(sql`${table.deletedAt} IS NULL`),
tombstoneCleanupIdx: index('execution_large_values_tombstone_cleanup_idx')
.on(table.workspaceId, table.deletedAt, table.key)
.where(sql`${table.deletedAt} IS NOT NULL`),
})
)
export const executionLargeValueReferences = pgTable(
'execution_large_value_references',
{
key: text('key').notNull(),
executionId: text('execution_id').notNull(),
source: executionLargeValueReferenceSourceEnum('source').notNull(),
workspaceId: text('workspace_id')
.notNull()
.references(() => workspace.id, { onDelete: 'cascade' }),
workflowId: text('workflow_id').references(() => workflow.id, { onDelete: 'set null' }),
createdAt: timestamp('created_at').notNull().defaultNow(),
},
(table) => ({
pk: primaryKey({ columns: [table.key, table.executionId, table.source] }),
workspaceExecutionSourceIdx: index(
'execution_large_value_references_workspace_execution_source_idx'
).on(table.workspaceId, table.executionId, table.source),
})
)
export const executionLargeValueDependencies = pgTable(
'execution_large_value_dependencies',
{
parentKey: text('parent_key').notNull(),
childKey: text('child_key').notNull(),
workspaceId: text('workspace_id')
.notNull()
.references(() => workspace.id, { onDelete: 'cascade' }),
createdAt: timestamp('created_at').notNull().defaultNow(),
},
(table) => ({
pk: primaryKey({ columns: [table.parentKey, table.childKey] }),
workspaceParentKeyIdx: index('execution_large_value_dependencies_workspace_parent_key_idx').on(
table.workspaceId,
table.parentKey
),
workspaceChildKeyIdx: index('execution_large_value_dependencies_workspace_child_key_idx').on(
table.workspaceId,
table.childKey
),
})
)
export const pausedExecutions = pgTable(
'paused_executions',
{
id: text('id').primaryKey(),
workflowId: text('workflow_id')
.notNull()
.references(() => workflow.id, { onDelete: 'cascade' }),
executionId: text('execution_id').notNull(),
executionSnapshot: jsonb('execution_snapshot').notNull(),
pausePoints: jsonb('pause_points').notNull(),
totalPauseCount: integer('total_pause_count').notNull(),
resumedCount: integer('resumed_count').notNull().default(0),
automaticResumeRetryCount: integer('automatic_resume_retry_count').notNull().default(0),
status: text('status').notNull().default('paused'),
metadata: jsonb('metadata').notNull().default(sql`'{}'::jsonb`),
pausedAt: timestamp('paused_at').notNull().defaultNow(),
updatedAt: timestamp('updated_at').notNull().defaultNow(),
expiresAt: timestamp('expires_at'),
/** Earliest `resumeAt` across this row's time-based pause points. NULL for human-only pauses. */
nextResumeAt: timestamp('next_resume_at'),
},
(table) => ({
workflowIdx: index('paused_executions_workflow_id_idx').on(table.workflowId),
statusIdx: index('paused_executions_status_idx').on(table.status),
executionUnique: uniqueIndex('paused_executions_execution_id_unique').on(table.executionId),
nextResumeAtIdx: index('paused_executions_next_resume_at_idx')
.on(table.nextResumeAt)
.where(sql`status = 'paused' AND next_resume_at IS NOT NULL`),
})
)
export const resumeQueue = pgTable(
'resume_queue',
{
id: text('id').primaryKey(),
pausedExecutionId: text('paused_execution_id')
.notNull()
.references(() => pausedExecutions.id, { onDelete: 'cascade' }),
parentExecutionId: text('parent_execution_id').notNull(),
newExecutionId: text('new_execution_id').notNull(),
contextId: text('context_id').notNull(),
resumeInput: jsonb('resume_input'),
status: text('status').notNull().default('pending'),
queuedAt: timestamp('queued_at').notNull().defaultNow(),
claimedAt: timestamp('claimed_at'),
completedAt: timestamp('completed_at'),
failureReason: text('failure_reason'),
},
(table) => ({
parentStatusIdx: index('resume_queue_parent_status_idx').on(
table.parentExecutionId,
table.status,
table.queuedAt
),
newExecutionIdx: index('resume_queue_new_execution_idx').on(table.newExecutionId),
})
)
export const environment = pgTable('environment', {
id: text('id').primaryKey(), // Use the user id as the key
userId: text('user_id')
.notNull()
.references(() => user.id, { onDelete: 'cascade' })
.unique(), // One environment per user
variables: json('variables').notNull(),
updatedAt: timestamp('updated_at').notNull().defaultNow(),
})
export const workspaceEnvironment = pgTable(
'workspace_environment',
{
id: text('id').primaryKey(),
workspaceId: text('workspace_id')
.notNull()
.references(() => workspace.id, { onDelete: 'cascade' }),
variables: json('variables').notNull().default('{}'),
createdAt: timestamp('created_at').notNull().defaultNow(),
updatedAt: timestamp('updated_at').notNull().defaultNow(),
},
(table) => ({
workspaceUnique: uniqueIndex('workspace_environment_workspace_unique').on(table.workspaceId),
})
)
export const workspaceBYOKKeys = pgTable(
'workspace_byok_keys',
{
id: text('id').primaryKey(),
workspaceId: text('workspace_id')
.notNull()
.references(() => workspace.id, { onDelete: 'cascade' }),
providerId: text('provider_id').notNull(),
encryptedApiKey: text('encrypted_api_key').notNull(),
name: text('name'),
createdBy: text('created_by').references(() => user.id, { onDelete: 'set null' }),
createdAt: timestamp('created_at').notNull().defaultNow(),
updatedAt: timestamp('updated_at').notNull().defaultNow(),
},
(table) => ({
workspaceProviderIdx: index('workspace_byok_workspace_provider_idx').on(
table.workspaceId,
table.providerId
),
})
)
export const settings = pgTable('settings', {
id: text('id').primaryKey(), // Use the user id as the key
userId: text('user_id')
.notNull()
.references(() => user.id, { onDelete: 'cascade' })
.unique(), // One settings record per user
// General settings
theme: text('theme').notNull().default('system'),
autoConnect: boolean('auto_connect').notNull().default(true),
// Privacy settings
telemetryEnabled: boolean('telemetry_enabled').notNull().default(true),
// Email preferences
emailPreferences: json('email_preferences').notNull().default('{}'),
// Billing usage notifications preference
billingUsageNotificationsEnabled: boolean('billing_usage_notifications_enabled')
.notNull()
.default(true),
// UI preferences
showTrainingControls: boolean('show_training_controls').notNull().default(false),
superUserModeEnabled: boolean('super_user_mode_enabled').notNull().default(true),
mothershipEnvironment: text('mothership_environment').notNull().default('default'),
// Notification preferences
errorNotificationsEnabled: boolean('error_notifications_enabled').notNull().default(true),
// Canvas preferences
snapToGridSize: integer('snap_to_grid_size').notNull().default(0), // 0 = off, 10-50 = grid size
showActionBar: boolean('show_action_bar').notNull().default(true),
timezone: text('timezone'),
// Copilot preferences - maps model_id to enabled/disabled boolean
copilotEnabledModels: jsonb('copilot_enabled_models').notNull().default('{}'),
// Copilot auto-allowed integration tools - array of tool IDs that can run without confirmation
copilotAutoAllowedTools: jsonb('copilot_auto_allowed_tools').notNull().default('[]'),
// Workspace navigation
lastActiveWorkspaceId: text('last_active_workspace_id'),
updatedAt: timestamp('updated_at').notNull().defaultNow(),
})
export const workflowSchedule = pgTable(
'workflow_schedule',
{
id: text('id').primaryKey(),
workflowId: text('workflow_id').references(() => workflow.id, { onDelete: 'cascade' }),
deploymentVersionId: text('deployment_version_id').references(
() => workflowDeploymentVersion.id,
{ onDelete: 'cascade' }
),
deploymentOperationId: text('deployment_operation_id').references(
(): AnyPgColumn => workflowDeploymentOperation.id,
{ onDelete: 'set null' }
),
blockId: text('block_id'),
cronExpression: text('cron_expression'),
nextRunAt: timestamp('next_run_at'),
lastRanAt: timestamp('last_ran_at'),
lastQueuedAt: timestamp('last_queued_at'),
triggerType: text('trigger_type').notNull(), // "manual", "webhook", "schedule"
timezone: text('timezone').notNull().default('UTC'),
failedCount: integer('failed_count').notNull().default(0),
infraRetryCount: integer('infra_retry_count').notNull().default(0),
status: text('status').notNull().default('active'), // 'active', 'disabled', or 'completed'
lastFailedAt: timestamp('last_failed_at'),
sourceType: text('source_type').notNull().default('workflow'), // 'workflow' or 'job'
jobTitle: text('job_title'),
prompt: text('prompt'),
lifecycle: text('lifecycle').notNull().default('persistent'), // 'persistent' or 'until_complete'
successCondition: text('success_condition'),
maxRuns: integer('max_runs'),
runCount: integer('run_count').notNull().default(0),
sourceChatId: text('source_chat_id'),
sourceTaskName: text('source_task_name'),
sourceUserId: text('source_user_id').references(() => user.id, { onDelete: 'cascade' }),
sourceWorkspaceId: text('source_workspace_id').references(() => workspace.id, {
onDelete: 'cascade',
}),
jobHistory: jsonb('job_history').$type<Array<{ timestamp: string; summary: string }>>(),
/** `@`-mentioned resources / `/`-invoked skills captured with the prompt, resolved into the agent run at fire time. */
contexts: jsonb('contexts').$type<Array<Record<string, unknown>>>(),
/** ISO timestamps of recurring occurrences the user deleted individually (EXDATE); the executor skips them. */
excludedDates: jsonb('excluded_dates').$type<string[]>(),
/** Recurrence end boundary: the schedule completes once its next run would fall after this instant. */
endsAt: timestamp('ends_at'),
archivedAt: timestamp('archived_at'),
createdAt: timestamp('created_at').notNull().defaultNow(),
updatedAt: timestamp('updated_at').notNull().defaultNow(),
},
(table) => {
return {
workflowBlockUnique: uniqueIndex('workflow_schedule_workflow_block_deployment_unique')
.on(table.workflowId, table.blockId, table.deploymentVersionId)
.where(sql`${table.archivedAt} IS NULL`),
workflowDeploymentIdx: index('workflow_schedule_workflow_deployment_idx').on(
table.workflowId,
table.deploymentVersionId
),
archivedAtPartialIdx: index('workflow_schedule_archived_at_partial_idx')
.on(table.archivedAt)
.where(sql`${table.archivedAt} IS NOT NULL`),
sourceWorkspaceSourceTypeIdx: index(
'idx_workflow_schedule_on_source_workspace_id_source_t_c07f3bba6'
).on(table.sourceWorkspaceId, table.sourceType, table.archivedAt, table.status),
dueWorkflowIdx: index('workflow_schedule_due_workflow_idx')
.on(table.nextRunAt, table.lastQueuedAt, table.deploymentVersionId, table.workflowId)
.where(
sql`${table.archivedAt} IS NULL AND ${table.status} NOT IN ('disabled', 'completed') AND (${table.sourceType} = 'workflow' OR ${table.sourceType} IS NULL)`
),
dueJobIdx: index('workflow_schedule_due_job_idx')
.on(table.nextRunAt, table.lastQueuedAt)
.where(
sql`${table.archivedAt} IS NULL AND ${table.status} NOT IN ('disabled', 'completed') AND ${table.sourceType} = 'job'`
),
}
}
)
export const jobExecutionLogs = pgTable(
'job_execution_logs',
{
id: text('id').primaryKey(),
scheduleId: text('schedule_id').references(() => workflowSchedule.id, { onDelete: 'set null' }),
workspaceId: text('workspace_id')
.notNull()
.references(() => workspace.id, { onDelete: 'cascade' }),
executionId: text('execution_id').notNull(),
level: text('level').notNull(),
status: text('status').notNull().default('running'),
trigger: text('trigger').notNull(),
startedAt: timestamp('started_at').notNull(),
endedAt: timestamp('ended_at'),
totalDurationMs: integer('total_duration_ms'),
executionData: jsonb('execution_data').notNull().default('{}'),
cost: jsonb('cost'),
createdAt: timestamp('created_at').notNull().defaultNow(),
},
(table) => ({
scheduleIdIdx: index('job_execution_logs_schedule_id_idx').on(table.scheduleId),
workspaceStartedAtIdx: index('job_execution_logs_workspace_started_at_idx').on(
table.workspaceId,
table.startedAt
),
workspaceEndedAtIdIdx: index('job_execution_logs_workspace_ended_at_id_idx').on(
table.workspaceId,
sql`date_trunc('milliseconds', ${table.endedAt})`,
table.id
),
executionIdUnique: uniqueIndex('job_execution_logs_execution_id_unique').on(table.executionId),
triggerIdx: index('job_execution_logs_trigger_idx').on(table.trigger),
})
)
/** Extracts the canonical credential ID persisted in webhook provider configuration. */
export function webhookCredentialIdExpression(column: AnyPgColumn): SQL<string> {
return sql<string>`((${column})::jsonb ->> 'credentialId')`
}
export const webhook = pgTable(
'webhook',
{
id: text('id').primaryKey(),
workflowId: text('workflow_id')
.notNull()
.references(() => workflow.id, { onDelete: 'cascade' }),
deploymentVersionId: text('deployment_version_id').references(
() => workflowDeploymentVersion.id,
{ onDelete: 'cascade' }
),
registrationStatus: text('registration_status'),
registrationGeneration: integer('registration_generation'),
configFingerprint: text('config_fingerprint'),
preparedAt: timestamp('prepared_at'),
blockId: text('block_id'),
/**
* URL-addressable webhook path. NULL for shared-app providers (e.g. the
* native Slack OAuth trigger) whose events arrive on a single shared
* endpoint and route by `routingKey` instead of a per-workflow path.
*/
path: text('path'),
/**
* Tenant routing key for shared-app providers. For `provider='slack_app'`
* this is the Slack `team_id`, derived server-side from the connected
* credential at deploy time — never user input. Inbound events match on
* this after HMAC verification.
*/
routingKey: text('routing_key'),
provider: text('provider'), // e.g., "whatsapp", "github", etc.
providerConfig: json('provider_config'), // Store provider-specific configuration
isActive: boolean('is_active').notNull().default(true),
failedCount: integer('failed_count').default(0), // Track consecutive failures
lastFailedAt: timestamp('last_failed_at'), // When the webhook last failed
archivedAt: timestamp('archived_at'),
createdAt: timestamp('created_at').notNull().defaultNow(),
updatedAt: timestamp('updated_at').notNull().defaultNow(),
},
(table) => {
return {
// Ensure webhook paths are unique per deployment version
pathIdx: uniqueIndex('path_deployment_unique')
.on(table.path, table.deploymentVersionId)
.where(sql`${table.archivedAt} IS NULL`),
workflowDeploymentIdx: index('webhook_workflow_deployment_idx').on(
table.workflowId,
table.deploymentVersionId
),
// Shared-app inbound routing (Slack native OAuth trigger). routingKey leads.
routingKeyActiveIdx: index('webhook_routing_key_active_idx')
.on(table.routingKey, table.provider)
.where(sql`${table.archivedAt} IS NULL AND ${table.routingKey} IS NOT NULL`),
archivedAtPartialIdx: index('webhook_archived_at_partial_idx')
.on(table.archivedAt)
.where(sql`${table.archivedAt} IS NOT NULL`),
providerActiveWorkflowDeploymentIdx: index(
'idx_webhook_on_provider_is_active_workflow_id_deploym_bdeed5468'
).on(table.provider, table.isActive, table.workflowId, table.deploymentVersionId),
tiktokCredentialIdIdx: index('webhook_tiktok_credential_id_idx')
.on(webhookCredentialIdExpression(table.providerConfig))
.where(
sql`${table.provider} = 'tiktok' AND ${table.isActive} = true AND ${table.archivedAt} IS NULL`
),
workflowBlockUpdatedDescIdx: index('idx_webhook_on_workflow_id_block_id_updated_at_desc').on(
table.workflowId,
table.blockId,
table.updatedAt.desc()
),
activeRegistrationUnique: uniqueIndex('webhook_active_registration_unique')
.on(table.workflowId, table.blockId)
.where(
sql`${table.registrationStatus} = 'active' AND ${table.blockId} IS NOT NULL AND ${table.archivedAt} IS NULL`
),
candidateRegistrationUnique: uniqueIndex('webhook_candidate_registration_unique')
.on(table.workflowId, table.blockId)
.where(sql`${table.registrationStatus} = 'candidate' AND ${table.blockId} IS NOT NULL`),
registrationGenerationIdx: index('webhook_registration_status_generation_idx').on(
table.workflowId,
table.registrationStatus,
table.registrationGeneration
),
registrationStatusCheck: check(
'webhook_registration_status_check',
sql`${table.registrationStatus} IS NULL OR ${table.registrationStatus} IN ('active', 'candidate', 'retired', 'orphaned')`
),
registrationGenerationCheck: check(
'webhook_registration_generation_check',
sql`${table.registrationGeneration} IS NULL OR ${table.registrationGeneration} >= 0`
),
}
}
)
/**
* Owns a normalized path independently from registration generations.
*/
export const webhookPathClaim = pgTable(
'webhook_path_claim',
{
path: text('path').primaryKey(),
workflowId: text('workflow_id')
.notNull()
.references(() => workflow.id, { onDelete: 'cascade' }),
generation: integer('generation').notNull(),
createdAt: timestamp('created_at').notNull().defaultNow(),
updatedAt: timestamp('updated_at').notNull().defaultNow(),
},
(table) => ({
workflowIdx: index('webhook_path_claim_workflow_idx').on(table.workflowId),
generationCheck: check('webhook_path_claim_generation_check', sql`${table.generation} >= 0`),
})
)
/**
* Cooldown state for Sim workspace-event trigger subscriptions.
*
* Keyed by (workflowId, blockId, scopeKey) rather than the webhook row because
* webhook rows are recreated per deployment version — state stored there would
* reset on every redeploy. `scopeKey` is '' for subscription-level cooldowns
* and the source workflow ID for per-source-workflow rules (no_activity).
*/
export const simTriggerState = pgTable(
'sim_trigger_state',
{
workflowId: text('workflow_id')
.notNull()
.references(() => workflow.id, { onDelete: 'cascade' }),
blockId: text('block_id').notNull(),
scopeKey: text('scope_key').notNull().default(''),
lastFiredAt: timestamp('last_fired_at'),
updatedAt: timestamp('updated_at').notNull().defaultNow(),
},
(table) => ({
pk: primaryKey({ columns: [table.workflowId, table.blockId, table.scopeKey] }),
})
)
export const apiKey = pgTable(
'api_key',
{
id: text('id').primaryKey(),
userId: text('user_id')
.notNull()
.references(() => user.id, { onDelete: 'cascade' }),
workspaceId: text('workspace_id').references(() => workspace.id, { onDelete: 'cascade' }), // Only set for workspace keys
createdBy: text('created_by').references(() => user.id, { onDelete: 'set null' }),
name: text('name').notNull(),
key: text('key').notNull().unique(),
keyHash: text('key_hash'),
type: text('type').notNull().default('personal'),
lastUsed: timestamp('last_used'),
createdAt: timestamp('created_at').notNull().defaultNow(),
updatedAt: timestamp('updated_at').notNull().defaultNow(),
expiresAt: timestamp('expires_at'),
},
(table) => ({
workspaceTypeCheck: check(
'workspace_type_check',
sql`(type = 'workspace' AND workspace_id IS NOT NULL) OR (type = 'personal' AND workspace_id IS NULL)`
),
workspaceTypeIdx: index('api_key_workspace_type_idx').on(table.workspaceId, table.type),
userTypeIdx: index('api_key_user_type_idx').on(table.userId, table.type),
keyHashIdx: uniqueIndex('api_key_key_hash_idx').on(table.keyHash),
})
)
export const billingBlockedReasonEnum = pgEnum('billing_blocked_reason', [
'payment_failed',
'dispute',
])
export const billingEntityTypeEnum = pgEnum('billing_entity_type', ['user', 'organization'])
export const userStats = pgTable('user_stats', {
id: text('id').primaryKey(),
userId: text('user_id')
.notNull()
.references(() => user.id, { onDelete: 'cascade' })
.unique(), // One record per user
// Retired usage hot-path counters: no writers/readers; derive from usage_log.
// Drop via DROP COLUMN in a follow-up migration.
/** @deprecated Retired usage counter; derive from usage_log. */
totalManualExecutions: integer('total_manual_executions').notNull().default(0),
/** @deprecated Retired usage counter; derive from usage_log. */
totalApiCalls: integer('total_api_calls').notNull().default(0),
/** @deprecated Retired usage counter; derive from usage_log. */
totalWebhookTriggers: integer('total_webhook_triggers').notNull().default(0),
/** @deprecated Retired usage counter; derive from usage_log. */
totalScheduledExecutions: integer('total_scheduled_executions').notNull().default(0),
/** @deprecated Retired usage counter; derive from usage_log. */
totalChatExecutions: integer('total_chat_executions').notNull().default(0),
/** @deprecated Retired usage counter; derive from usage_log. */
totalMcpExecutions: integer('total_mcp_executions').notNull().default(0),
/** @deprecated Retired usage counter; derive from usage_log. */
totalTokensUsed: bigint('total_tokens_used', { mode: 'number' }).notNull().default(0),
/** @deprecated Not written (recordUsage appends to usage_log); legacy/admin reads only. Move readers to ledger aggregation. */
totalCost: decimal('total_cost').notNull().default('0'),
currentUsageLimit: decimal('current_usage_limit').default(DEFAULT_FREE_CREDITS.toString()), // Default $5 (1,000 credits) for free plan, null for team/enterprise
usageLimitUpdatedAt: timestamp('usage_limit_updated_at').defaultNow(),
/**
* Active per-period baseline (not a per-usage hot-path counter). Current usage
* = this baseline + attributed usage_log rows for the period; reset at rollover.
*/
currentPeriodCost: decimal('current_period_cost').notNull().default('0'),
lastPeriodCost: decimal('last_period_cost').default('0'), // Usage from previous billing period
/**
* Threshold/final billing tracker.
*
* This is intentionally still written when threshold billing or invoice
* finalization serializes overage collection. It is not incremented by the
* ordinary per-usage ledger write path.
*/
billedOverageThisPeriod: decimal('billed_overage_this_period').notNull().default('0'), // Amount of overage already billed via threshold billing
// Pro usage snapshot when joining a team (to prevent double-billing)
proPeriodCostSnapshot: decimal('pro_period_cost_snapshot').default('0'), // Snapshot of Pro usage when joining team
proPeriodCostSnapshotAt: timestamp('pro_period_cost_snapshot_at'), // When the snapshot was captured (= join moment). Used to cap daily-refresh computation so post-join refresh isn't deducted from pre-join personal Pro usage (and vice-versa for the org's pooled refresh).
/**
* Credit balance tracker.
*
* Still debited/credited by billing lifecycle paths and threshold/final
* overage collection. It is not a per-usage aggregate counter.
*/
creditBalance: decimal('credit_balance').notNull().default('0'),
/** @deprecated Not written; report Copilot cost from usage_log. Legacy/admin reads only. */
totalCopilotCost: decimal('total_copilot_cost').notNull().default('0'),
/** Active per-period Copilot baseline; reset at rollover (not a per-usage counter). */
currentPeriodCopilotCost: decimal('current_period_copilot_cost').notNull().default('0'),
/** Previous-period Copilot cost; set at rollover. */
lastPeriodCopilotCost: decimal('last_period_copilot_cost').default('0'),
/** @deprecated Not written; report Copilot tokens from usage_log. Legacy/admin reads only. */
totalCopilotTokens: bigint('total_copilot_tokens', { mode: 'number' }).notNull().default(0),
/** @deprecated Not written; report Copilot calls from usage_log. Legacy/admin reads only. */
totalCopilotCalls: integer('total_copilot_calls').notNull().default(0),
/** @deprecated Not written; report MCP Copilot calls from usage_log. Legacy/admin reads only. */
totalMcpCopilotCalls: integer('total_mcp_copilot_calls').notNull().default(0),
/** @deprecated Not written; report MCP Copilot cost from usage_log. Legacy/admin reads only. */
totalMcpCopilotCost: decimal('total_mcp_copilot_cost').notNull().default('0'),
/** @deprecated No writer (never incremented or reset). MCP copilot usage lives in usage_log (source 'mcp_copilot'); read it from there, not this column. */
currentPeriodMcpCopilotCost: decimal('current_period_mcp_copilot_cost').notNull().default('0'),
/**
* Storage upload/delete hot-path tracker for personal plans.
*
* This remains a direct aggregate write for personal file storage changes;
* org-scoped storage writes update `organization.storageUsedBytes`.
*/
storageUsedBytes: bigint('storage_used_bytes', { mode: 'number' }).notNull().default(0),
/** @deprecated Not updated by execution (no user_stats write on completion); legacy/admin reads only. */
lastActive: timestamp('last_active').notNull().defaultNow(),
billingBlocked: boolean('billing_blocked').notNull().default(false),
billingBlockedReason: billingBlockedReasonEnum('billing_blocked_reason'),
/**
* Highest usage-limit threshold already emailed per category (e.g.
* `{ storage: 80, tables: 100 }`). Prevents re-spamming the same warning;
* re-arms when usage drops back below the re-arm band. Keyed by limit
* category ('storage' | 'tables'); seats live on `organization`.
*
* Dedup granularity is per billing account per category — intentionally NOT
* per table, so a user hitting the row limit on several tables gets one
* 'tables' warning, not one per table (the email still names the table that
* triggered it).
*/
limitNotifications: jsonb('limit_notifications')
.$type<Record<string, number>>()
.notNull()
.default({}),
})
export const customTools = pgTable(
'custom_tools',
{
id: text('id').primaryKey(),
workspaceId: text('workspace_id').references(() => workspace.id, { onDelete: 'cascade' }),
userId: text('user_id').references(() => user.id, { onDelete: 'set null' }),
title: text('title').notNull(),
schema: json('schema').notNull(),
code: text('code').notNull(),
createdAt: timestamp('created_at').notNull().defaultNow(),
updatedAt: timestamp('updated_at').notNull().defaultNow(),
},
(table) => ({
workspaceIdIdx: index('custom_tools_workspace_id_idx').on(table.workspaceId),
workspaceTitleUnique: uniqueIndex('custom_tools_workspace_title_unique').on(
table.workspaceId,
table.title
),
})
)
export const skill = pgTable(
'skill',
{
id: text('id').primaryKey(),
workspaceId: text('workspace_id').references(() => workspace.id, { onDelete: 'cascade' }),
userId: text('user_id').references(() => user.id, { onDelete: 'set null' }),
name: text('name').notNull(),
description: text('description').notNull(),
content: text('content').notNull(),
createdAt: timestamp('created_at').notNull().defaultNow(),
updatedAt: timestamp('updated_at').notNull().defaultNow(),
},
(table) => ({
workspaceNameUnique: uniqueIndex('skill_workspace_name_unique').on(
table.workspaceId,
table.name
),
})
)
/**
* Editor grants for a skill. A row makes the user an editor (edit, delete,
* share); workspace admins are derived editors and need no rows. Everyone with
* workspace access can see and use every skill regardless of rows.
*/
export const skillMember = pgTable(
'skill_member',
{
id: text('id').primaryKey(),
skillId: text('skill_id')
.notNull()
.references(() => skill.id, { onDelete: 'cascade' }),
userId: text('user_id')
.notNull()
.references(() => user.id, { onDelete: 'cascade' }),
invitedBy: text('invited_by').references(() => user.id, { onDelete: 'set null' }),
createdAt: timestamp('created_at').notNull().defaultNow(),
updatedAt: timestamp('updated_at').notNull().defaultNow(),
},
(table) => ({
userIdIdx: index('skill_member_user_id_idx').on(table.userId),
uniqueMembership: uniqueIndex('skill_member_unique').on(table.skillId, table.userId),
})
)
export const mothershipSettings = pgTable(
'mothership_settings',
{
workspaceId: text('workspace_id')
.primaryKey()
.references(() => workspace.id, { onDelete: 'cascade' }),
mcpToolRefs: jsonb('mcp_tool_refs').notNull().default(sql`'[]'::jsonb`),
customToolRefs: jsonb('custom_tool_refs').notNull().default(sql`'[]'::jsonb`),
skillRefs: jsonb('skill_refs').notNull().default(sql`'[]'::jsonb`),
createdAt: timestamp('created_at').notNull().defaultNow(),
updatedAt: timestamp('updated_at').notNull().defaultNow(),
},
(table) => ({
workspaceIdIdx: index('mothership_settings_workspace_id_idx').on(table.workspaceId),
})
)
export const subscription = pgTable(
'subscription',
{
id: text('id').primaryKey(),
plan: text('plan').notNull(),
referenceId: text('reference_id').notNull(),
stripeCustomerId: text('stripe_customer_id'),
stripeSubscriptionId: text('stripe_subscription_id'),
status: text('status'),
periodStart: timestamp('period_start'),
periodEnd: timestamp('period_end'),
cancelAtPeriodEnd: boolean('cancel_at_period_end'),
cancelAt: timestamp('cancel_at'),
canceledAt: timestamp('canceled_at'),
endedAt: timestamp('ended_at'),
seats: integer('seats'),
trialStart: timestamp('trial_start'),
trialEnd: timestamp('trial_end'),
billingInterval: text('billing_interval'),
stripeScheduleId: text('stripe_schedule_id'),
metadata: json('metadata'),
},
(table) => ({
referenceStatusIdx: index('subscription_reference_status_idx').on(
table.referenceId,
table.status
),
enterpriseMetadataCheck: check(
'check_enterprise_metadata',
sql`plan != 'enterprise' OR metadata IS NOT NULL`
),
})
)
export const rateLimitBucket = pgTable('rate_limit_bucket', {
key: text('key').primaryKey(),
tokens: decimal('tokens').notNull(),
lastRefillAt: timestamp('last_refill_at').notNull(),
updatedAt: timestamp('updated_at').notNull().defaultNow(),
})
export const chat = pgTable(
'chat',
{
id: text('id').primaryKey(),
workflowId: text('workflow_id')
.notNull()
.references(() => workflow.id, { onDelete: 'cascade' }),
userId: text('user_id')
.notNull()
.references(() => user.id, { onDelete: 'cascade' }),
identifier: text('identifier').notNull(),
title: text('title').notNull(),
description: text('description'),
isActive: boolean('is_active').notNull().default(true),
customizations: json('customizations').default('{}'), // For UI customization options
// Authentication options
authType: text('auth_type').notNull().default('public'), // 'public', 'password', 'email', 'sso'
password: text('password'), // Stored hashed, populated when authType is 'password'
allowedEmails: json('allowed_emails').default('[]'), // Array of allowed emails or domains when authType is 'email' or 'sso'
// Output configuration
outputConfigs: json('output_configs').default('[]'), // Array of {blockId, path} objects
/**
* When true, public chat SSE exposes provider thinking events. Independent
* of the `X-Sim-Stream-Protocol` header, which governs answer-text cadence
* rather than frame exposure. Default off — never derived from auth type or
* isSecureMode.
*/
includeThinking: boolean('include_thinking').notNull().default(false),
/**
* When true, public chat SSE exposes tool lifecycle events. Independent of
* includeThinking and of the protocol header.
*
* Nullable only because the column was added after the table; readers treat
* null as false.
*/
// contract-pending(any release): normalize nulls to false, then set DEFAULT false and NOT NULL — cosmetic only, since no reader distinguishes null from false
includeToolCalls: boolean('include_tool_calls'),
archivedAt: timestamp('archived_at'),
createdAt: timestamp('created_at').notNull().defaultNow(),
updatedAt: timestamp('updated_at').notNull().defaultNow(),
},
(table) => {
return {
// Ensure identifiers are unique
identifierIdx: uniqueIndex('identifier_idx')
.on(table.identifier)
.where(sql`${table.archivedAt} IS NULL`),
archivedAtPartialIdx: index('chat_archived_at_partial_idx')
.on(table.archivedAt)
.where(sql`${table.archivedAt} IS NOT NULL`),
workflowArchivedAtIdx: index('idx_chat_on_workflow_id_archived_at').on(
table.workflowId,
table.archivedAt
),
}
}
)
/** A user-supplied custom regex pattern; matches are replaced verbatim with `replacement`. */
export interface CustomPiiPattern {
name: string
regex: string
replacement: string
}
/** Per-stage PII redaction policy stored on a {@link PiiRedactionRule}. */
export interface PiiStagePolicy {
enabled: boolean
/** Presidio entity types to mask. Empty (or disabled) = redact nothing. */
entityTypes: string[]
/** Language whose Presidio recognizers apply (e.g. 'en', 'es'); defaults to English. */
language?: string
/** User-supplied custom regex patterns applied alongside `entityTypes`. */
customPatterns?: CustomPiiPattern[]
}
/**
* A single PII redaction rule. Lives in the org-level
* {@link DataRetentionSettings.piiRedaction} rules list. Each rule targets one
* scope — all workspaces (`workspaceId: null`) or a single workspace — and
* `workspaceId` is unique across rules. Resolution is most-specific-wins: a
* workspace's own rule overrides the all-workspaces rule (never unioned).
*
* New rules carry per-stage {@link stages} (input / blockOutputs / logs); legacy
* rows carry only the flat `entityTypes`/`language`, resolved as a logs-only
* rule. At least one of the two is present.
*/
export interface PiiRedactionRule {
id: string
name?: string
/** `null` = all workspaces; otherwise the single targeted workspace. */
workspaceId: string | null
/** Per-stage policy (input redaction, block-output redaction, log redaction). */
stages?: {
input: PiiStagePolicy
blockOutputs: PiiStagePolicy
logs: PiiStagePolicy
}
/** Legacy flat policy (pre-stages). Presidio entity types masked at log persist. */
entityTypes?: string[]
/** Legacy flat language (pre-stages). */
language?: string
}
/**
* A per-workspace override of the org-level retention hours. Each field is
* tri-state: absent = inherit the org value; a number = that workspace's
* retention in hours; `null` = forever (never delete). `workspaceId` is unique
* across overrides.
*/
export interface RetentionOverride {
workspaceId: string
logRetentionHours?: number | null
softDeleteRetentionHours?: number | null
taskCleanupHours?: number | null
}
/**
* Org-level data retention + governance settings. Retention-hours fall back to
* plan defaults when unset. `piiRedaction.rules` are org-scoped; each rule
* selects which workspaces it applies to. `retentionOverrides` lets individual
* workspaces override the org retention hours (enterprise only).
*/
export interface DataRetentionSettings {
logRetentionHours?: number | null
softDeleteRetentionHours?: number | null
taskCleanupHours?: number | null
/** Enterprise PII redaction rules applied to workflow logs on persist. */
piiRedaction?: {
rules?: PiiRedactionRule[]
} | null
/** Per-workspace overrides of the retention hours above (enterprise only). */
retentionOverrides?: RetentionOverride[] | null
}
/**
* Org-level session policy (enterprise). Absent or empty = Better Auth
* defaults (30-day sliding sessions). `maxSessionHours` caps absolute session
* lifetime from creation; `idleTimeoutHours` caps time between refreshes.
* Enforced by clamping `session.expiresAt` in the Better Auth session
* create/update database hooks; `securityPolicyVersion` invalidates cached
* session cookies org-wide when bumped.
*/
export interface SessionPolicySettings {
/** Absolute session lifetime cap in hours from session creation. */
maxSessionHours?: number | null
/** Idle timeout in hours — session expires this long after its last refresh. */
idleTimeoutHours?: number | null
}
export const organization = pgTable('organization', {
id: text('id').primaryKey(),
name: text('name').notNull(),
slug: text('slug').notNull(),
logo: text('logo'),
metadata: json('metadata'),
sessionPolicySettings: json('session_policy_settings').$type<SessionPolicySettings>(),
/**
* Monotonic counter embedded in the Better Auth cookie-cache version for
* this org's members. Bumped on any security-policy change or org-wide
* session revocation so every cached session cookie in the org is
* invalidated (falls through to a DB session read) within the policy
* cache TTL instead of the 24h cookie-cache lifetime.
*/
securityPolicyVersion: integer('security_policy_version').notNull().default(1),
whitelabelSettings: json('whitelabel_settings').$type<{
brandName?: string
logoUrl?: string
primaryColor?: string
primaryHoverColor?: string
accentColor?: string
accentHoverColor?: string
supportEmail?: string
documentationUrl?: string
termsUrl?: string
privacyUrl?: string
hidePoweredBySim?: boolean
}>(),
dataRetentionSettings: json('data_retention_settings').$type<DataRetentionSettings>(),
orgUsageLimit: decimal('org_usage_limit'),
/**
* Storage upload/delete hot-path tracker for org-scoped plans.
*
* This remains a direct aggregate write for organization file storage
* changes; personal storage writes update `user_stats.storageUsedBytes`.
*/
storageUsedBytes: bigint('storage_used_bytes', { mode: 'number' }).notNull().default(0),
/**
* Highest usage-limit threshold already emailed per category for this org
* (e.g. `{ seats: 80, storage: 100 }`). Mirrors `user_stats.limitNotifications`
* for org-scoped (pooled) limits. Re-arms when usage drops below the re-arm band.
*/
limitNotifications: jsonb('limit_notifications')
.$type<Record<string, number>>()
.notNull()
.default({}),
departedMemberUsage: decimal('departed_member_usage').notNull().default('0'),
/**
* Organization credit balance tracker.
*
* Still debited/credited by billing lifecycle paths and threshold/final
* overage collection. It is not a per-usage aggregate counter.
*/
creditBalance: decimal('credit_balance').notNull().default('0'),
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at').defaultNow().notNull(),
})
export const member = pgTable(
'member',
{
id: text('id').primaryKey(),
userId: text('user_id')
.notNull()
.references(() => user.id, { onDelete: 'cascade' }),
organizationId: text('organization_id')
.notNull()
.references(() => organization.id, { onDelete: 'cascade' }),
role: text('role').notNull(), // 'admin' or 'member' - team-level permissions only
createdAt: timestamp('created_at').defaultNow().notNull(),
},
(table) => ({
userIdUnique: uniqueIndex('member_user_id_unique').on(table.userId), // Users can only belong to one org
organizationIdIdx: index('member_organization_id_idx').on(table.organizationId),
})
)
/**
* Per-member usage limit (in dollars) scoped to a single organization.
*
* Keyed by `(organizationId, userId)` so it covers both organization members
* (rows in `member`) and external members (users with workspace permissions in
* org-owned workspaces but no `member` row). Independent of
* `user_stats.current_usage_limit`, which is the user's personal subscription
* cap and is nulled for org-scoped members. An absent row means "no per-member
* cap" (only the pooled org limit applies). Enforced for usage in org-owned
* workspaces; hosted-only.
*/
export const organizationMemberUsageLimit = pgTable(
'organization_member_usage_limit',
{
id: text('id').primaryKey(),
organizationId: text('organization_id')
.notNull()
.references(() => organization.id, { onDelete: 'cascade' }),
userId: text('user_id')
.notNull()
.references(() => user.id, { onDelete: 'cascade' }),
usageLimit: decimal('usage_limit').notNull(),
/** Admin who set the cap (audit only). Soft FK: nulled if that user is
* deleted so the member's limit row survives — never cascade-deleted. */
setBy: text('set_by').references(() => user.id, { onDelete: 'set null' }),
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at').defaultNow().notNull(),
},
(table) => ({
orgUserUnique: uniqueIndex('org_member_usage_limit_org_user_unique').on(
table.organizationId,
table.userId
),
organizationIdIdx: index('org_member_usage_limit_organization_id_idx').on(table.organizationId),
})
)
export const invitationKindEnum = pgEnum('invitation_kind', ['organization', 'workspace'])
export type InvitationKind = (typeof invitationKindEnum.enumValues)[number]
export const invitationMembershipIntentEnum = pgEnum('invitation_membership_intent', [
'internal',
'external',
])
export type InvitationMembershipIntent = (typeof invitationMembershipIntentEnum.enumValues)[number]
export const invitationStatusEnum = pgEnum('invitation_status', [
'pending',
'accepted',
'rejected',
'cancelled',
'expired',
])
export type InvitationStatus = (typeof invitationStatusEnum.enumValues)[number]
export const invitation = pgTable(
'invitation',
{
id: text('id').primaryKey(),
kind: invitationKindEnum('kind').notNull().default('organization'),
email: text('email').notNull(),
inviterId: text('inviter_id')
.notNull()
.references(() => user.id, { onDelete: 'cascade' }),
organizationId: text('organization_id').references(() => organization.id, {
onDelete: 'cascade',
}),
membershipIntent: invitationMembershipIntentEnum('membership_intent')
.notNull()
.default('internal'),
role: text('role').notNull(),
status: invitationStatusEnum('status').notNull().default('pending'),
token: text('token').notNull().unique(),
expiresAt: timestamp('expires_at').notNull(),
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at').defaultNow().notNull(),
},
(table) => ({
emailIdx: index('invitation_email_idx').on(table.email),
organizationIdIdx: index('invitation_organization_id_idx').on(table.organizationId),
statusIdx: index('invitation_status_idx').on(table.status),
pendingPerOrgEmailUnique: uniqueIndex('invitation_pending_email_org_unique')
.on(table.email, table.organizationId)
.where(sql`${table.status} = 'pending' AND ${table.organizationId} IS NOT NULL`),
})
)
export const workspaceModeEnum = pgEnum('workspace_mode', [
'personal',
'organization',
'grandfathered_shared',
])
export type WorkspaceMode = (typeof workspaceModeEnum.enumValues)[number]
export const workspace = pgTable(
'workspace',
{
id: text('id').primaryKey(),
name: text('name').notNull(),
color: text('color').notNull().default('#33C482'),
logoUrl: text('logo_url'),
/**
* @deprecated Not a permission or identity concept — do not use for admin/access
* checks. The owner→admin derivation is redundant: every workspace owner already
* has an explicit `admin` row in `permissions` (verified across all production
* workspaces) and all creation paths add one. Retained only as the lifecycle
* anchor — `onDelete: 'cascade'` cleans up a user's workspaces on account
* deletion — and the ownership-transfer target when an owner is removed. For
* admin checks use explicit `permissions` rows; for the workspace's principal
* billing identity use `billedAccountUserId`. DO NOT DELETE.
*/
ownerId: text('owner_id')
.notNull()
.references(() => user.id, { onDelete: 'cascade' }),
organizationId: text('organization_id').references(() => organization.id, {
onDelete: 'set null',
}),
workspaceMode: workspaceModeEnum('workspace_mode').notNull().default('grandfathered_shared'),
billedAccountUserId: text('billed_account_user_id')
.notNull()
.references(() => user.id, { onDelete: 'no action' }),
/**
* Durable workspace-first storage ledger.
*
* Invariant: this non-negative total and the currently routed payer aggregate
* change atomically while the workspace row is locked. A payer identity change
* moves this entire total old payer -> new payer in the same transaction.
*/
storageUsedBytes: bigint('storage_used_bytes', { mode: 'number' }).notNull().default(0),
allowPersonalApiKeys: boolean('allow_personal_api_keys').notNull().default(true),
inboxEnabled: boolean('inbox_enabled').notNull().default(false),
inboxAddress: text('inbox_address'),
inboxProviderId: text('inbox_provider_id'),
archivedAt: timestamp('archived_at'),
organizationAssignedAt: timestamp('organization_assigned_at'),
forkedFromWorkspaceId: text('forked_from_workspace_id').references(
(): AnyPgColumn => workspace.id,
{ onDelete: 'set null' }
),
createdAt: timestamp('created_at').notNull().defaultNow(),
updatedAt: timestamp('updated_at').notNull().defaultNow(),
},
(table) => ({
ownerIdIdx: index('workspace_owner_id_idx').on(table.ownerId),
organizationIdIdx: index('workspace_organization_id_idx').on(table.organizationId),
nonNegativeStorage: check(
'workspace_storage_used_bytes_non_negative',
sql`${table.storageUsedBytes} >= 0`
),
workspaceModeIdx: index('workspace_mode_idx').on(table.workspaceMode),
forkedFromWorkspaceIdx: index('workspace_forked_from_workspace_id_idx').on(
table.forkedFromWorkspaceId
),
})
)
export const workspaceForkResourceTypeEnum = pgEnum('workspace_fork_resource_type', [
'workflow',
'oauth_credential',
'service_account_credential',
'env_var',
'table',
'knowledge_base',
'knowledge_document',
'file',
'mcp_server',
/** Workflow-publishing MCP server identity (fork shell copy), for attachment sync. */
'workflow_mcp_server',
'custom_tool',
'skill',
])
export const workspaceForkResourceMap = pgTable(
'workspace_fork_resource_map',
{
id: text('id').primaryKey(),
childWorkspaceId: text('child_workspace_id')
.notNull()
.references(() => workspace.id, { onDelete: 'cascade' }),
resourceType: workspaceForkResourceTypeEnum('resource_type').notNull(),
parentResourceId: text('parent_resource_id').notNull(),
childResourceId: text('child_resource_id'),
// SET NULL (not CASCADE): deleting the creating user must not delete the fork's
// identity mappings, which the edge depends on for every future promote.
createdBy: text('created_by').references(() => user.id, { onDelete: 'set null' }),
createdAt: timestamp('created_at').notNull().defaultNow(),
updatedAt: timestamp('updated_at').notNull().defaultNow(),
},
(table) => ({
childWorkspaceIdx: index('workspace_fork_resource_map_child_ws_idx').on(table.childWorkspaceId),
childWorkspaceTypeIdx: index('workspace_fork_resource_map_child_ws_type_idx').on(
table.childWorkspaceId,
table.resourceType
),
childTypeParentUnique: uniqueIndex('workspace_fork_resource_map_child_type_parent_unique').on(
table.childWorkspaceId,
table.resourceType,
table.parentResourceId
),
})
)
/**
* Stable 1:1 block-identity map between a fork (child) and its parent, per edge. Seeded at
* fork creation (parent block -> derived child block) and reconciled on every promote.
* Promote looks a source block up here to reuse its counterpart's EXISTING id instead of
* re-deriving: without it, pushing a fork's workflow over the parent would re-key the
* parent's blocks and change their webhook URLs (the path falls back to the block id).
*
* Each pair records BOTH workflow ids so a lookup can be scoped to the workflow it belongs
* to: a target workflow that was archived and re-created gets a fresh id (the pair no longer
* matches), which avoids reusing an archived workflow's block id and colliding on the global
* `workflow_blocks` primary key. Block ids are plain text (no FK to `workflow_blocks`, which
* is rewritten on every deploy); only the edge (`child_workspace_id`) cascades. A parent
* block can map to different children across sibling forks, so uniqueness is per (edge,
* parent) and per (edge, child).
*/
export const workspaceForkBlockMap = pgTable(
'workspace_fork_block_map',
{
id: text('id').primaryKey(),
childWorkspaceId: text('child_workspace_id')
.notNull()
.references(() => workspace.id, { onDelete: 'cascade' }),
parentWorkflowId: text('parent_workflow_id').notNull(),
parentBlockId: text('parent_block_id').notNull(),
childWorkflowId: text('child_workflow_id').notNull(),
childBlockId: text('child_block_id').notNull(),
createdAt: timestamp('created_at').notNull().defaultNow(),
updatedAt: timestamp('updated_at').notNull().defaultNow(),
},
(table) => ({
// Pull resolves parent source block -> child target; one child per parent block per edge.
childWsParentBlockUnique: uniqueIndex('workspace_fork_block_map_child_ws_parent_unique').on(
table.childWorkspaceId,
table.parentBlockId
),
// Push resolves child source block -> parent target; one parent per child block per edge.
childWsChildBlockUnique: uniqueIndex('workspace_fork_block_map_child_ws_child_unique').on(
table.childWorkspaceId,
table.childBlockId
),
// Reconcile deletes a source workflow's pairs by its (stable) workflow id before
// re-inserting the live ones, so index both workflow sides for that sweep.
childWsParentWorkflowIdx: index('workspace_fork_block_map_child_ws_parent_wf_idx').on(
table.childWorkspaceId,
table.parentWorkflowId
),
childWsChildWorkflowIdx: index('workspace_fork_block_map_child_ws_child_wf_idx').on(
table.childWorkspaceId,
table.childWorkflowId
),
})
)
/**
* The user's stored dependent-field re-picks for an edge: a (target workflow, target block,
* subblock) -> selected value mapping (a Gmail label, a KB document, a sheet tab). The sync
* modal reads and writes this, and every promote applies it verbatim - it is the single
* source of truth for dependent values, replacing the old implicit "preserve the target's
* value if the credential is unchanged" path. Block ids are plain text (no FK to
* `workflow_blocks`, which is rewritten on every deploy); only the edge (`child_workspace_id`)
* cascades. The target workflow id encodes direction (push -> parent workflow, pull -> child
* workflow), so no separate direction column is needed.
*/
export const workspaceForkDependentValue = pgTable(
'workspace_fork_dependent_value',
{
id: text('id').primaryKey(),
childWorkspaceId: text('child_workspace_id')
.notNull()
.references(() => workspace.id, { onDelete: 'cascade' }),
targetWorkflowId: text('target_workflow_id').notNull(),
targetBlockId: text('target_block_id').notNull(),
subBlockKey: text('sub_block_key').notNull(),
value: text('value').notNull(),
createdAt: timestamp('created_at').notNull().defaultNow(),
updatedAt: timestamp('updated_at').notNull().defaultNow(),
},
(table) => ({
// Reconcile replaces a workflow's stored values by its id, so index that sweep.
childWsWorkflowIdx: index('workspace_fork_dependent_value_child_ws_wf_idx').on(
table.childWorkspaceId,
table.targetWorkflowId
),
// One stored value per (edge, target workflow, target block, subblock).
childWsFieldUnique: uniqueIndex('workspace_fork_dependent_value_field_unique').on(
table.childWorkspaceId,
table.targetWorkflowId,
table.targetBlockId,
table.subBlockKey
),
})
)
export const workspaceForkPromoteDirectionEnum = pgEnum('workspace_fork_promote_direction', [
'push',
'pull',
])
export const workspaceForkPromoteRun = pgTable(
'workspace_fork_promote_run',
{
id: text('id').primaryKey(),
childWorkspaceId: text('child_workspace_id')
.notNull()
.references(() => workspace.id, { onDelete: 'cascade' }),
sourceWorkspaceId: text('source_workspace_id').notNull(),
targetWorkspaceId: text('target_workspace_id').notNull(),
direction: workspaceForkPromoteDirectionEnum('direction').notNull(),
snapshot: jsonb('snapshot').notNull(),
// SET NULL (not CASCADE): deleting the creating user must not delete a pending
// undo point for a target workspace.
createdBy: text('created_by').references(() => user.id, { onDelete: 'set null' }),
createdAt: timestamp('created_at').notNull().defaultNow(),
},
(table) => ({
// One undo point per (edge, target) so a push (target=parent) and a pull
// (target=child) on the same edge keep independent undo points.
childWorkspaceTargetUnique: uniqueIndex('workspace_fork_promote_run_child_ws_target_unique').on(
table.childWorkspaceId,
table.targetWorkspaceId
),
targetWorkspaceIdx: index('workspace_fork_promote_run_target_ws_idx').on(
table.targetWorkspaceId
),
})
)
export const backgroundWorkKindEnum = pgEnum('background_work_kind', [
'deployment_side_effects',
'fork_content_copy',
'fork_sync',
'fork_rollback',
])
export const backgroundWorkStatusValueEnum = pgEnum('background_work_status_value', [
'pending',
'processing',
'completed',
'completed_with_warnings',
'failed',
])
/**
* Durable status for asynchronous background work (post-sync/rollback deployment
* side-effects and fork content copy), so the canvas can show a "work in progress"
* banner that survives a reload. A row scoped to a single workflow sets `workflowId`;
* workspace-spanning work (fork content copy) leaves it null.
*/
export const backgroundWorkStatus = pgTable(
'background_work_status',
{
id: text('id').primaryKey(),
workspaceId: text('workspace_id')
.notNull()
.references(() => workspace.id, { onDelete: 'cascade' }),
workflowId: text('workflow_id').references(() => workflow.id, { onDelete: 'cascade' }),
kind: backgroundWorkKindEnum('kind').notNull(),
status: backgroundWorkStatusValueEnum('status').notNull(),
message: text('message'),
error: text('error'),
metadata: jsonb('metadata'),
startedAt: timestamp('started_at').notNull().defaultNow(),
completedAt: timestamp('completed_at'),
updatedAt: timestamp('updated_at').notNull().defaultNow(),
},
(table) => ({
workspaceStatusIdx: index('background_work_status_workspace_status_idx').on(
table.workspaceId,
table.status
),
workflowStatusIdx: index('background_work_status_workflow_status_idx').on(
table.workflowId,
table.status
),
// Expression indexes for listSurfacedBackgroundWork's metadata legs: `->>` equality can't
// use a GIN index, and one unindexable leg in its `or()` forces a full-table scan.
metaChildWorkspaceIdx: index('background_work_status_meta_child_ws_idx').on(
sql`(${table.metadata} ->> 'childWorkspaceId')`
),
metaOtherWorkspaceIdx: index('background_work_status_meta_other_ws_idx').on(
sql`(${table.metadata} ->> 'otherWorkspaceId')`
),
})
)
export const workspaceFile = pgTable(
'workspace_file',
{
id: text('id').primaryKey(),
workspaceId: text('workspace_id')
.notNull()
.references(() => workspace.id, { onDelete: 'cascade' }),
name: text('name').notNull(),
key: text('key').notNull().unique(),
size: integer('size').notNull(),
type: text('type').notNull(),
uploadedBy: text('uploaded_by')
.notNull()
.references(() => user.id, { onDelete: 'cascade' }),
deletedAt: timestamp('deleted_at'),
uploadedAt: timestamp('uploaded_at').notNull().defaultNow(),
},
(table) => ({
workspaceIdIdx: index('workspace_file_workspace_id_idx').on(table.workspaceId),
keyIdx: index('workspace_file_key_idx').on(table.key),
deletedAtIdx: index('workspace_file_deleted_at_idx').on(table.deletedAt),
workspaceDeletedAtPartialIdx: index('workspace_file_workspace_deleted_partial_idx')
.on(table.workspaceId, table.deletedAt)
.where(sql`${table.deletedAt} IS NOT NULL`),
})
)
export const workspaceFiles = pgTable(
'workspace_files',
{
id: text('id').primaryKey(),
key: text('key').notNull(),
userId: text('user_id')
.notNull()
.references(() => user.id, { onDelete: 'cascade' }),
workspaceId: text('workspace_id').references(() => workspace.id, { onDelete: 'cascade' }),
folderId: text('folder_id').references(() => folder.id, { onDelete: 'set null' }),
context: text('context').notNull(), // 'workspace', 'mothership', 'copilot', 'chat', 'knowledge-base', 'profile-pictures', 'general', 'execution'
chatId: uuid('chat_id').references(() => copilotChats.id, { onDelete: 'cascade' }),
/**
* Logical id of the copilot message this file was born in (the user message the
* upload was attached to). Plain text with no FK: message ids are only unique per
* chat — the same id legitimately exists in the source chat and every fork of it,
* which is what lets a fork's "copy files at-or-before this message" cut match rows
* in both. NULL means "birth unknown / not tracked": rows predating this column and
* contexts that don't stamp it. Nulled together with chatId when a file is
* materialized to the workspace.
*/
messageId: text('message_id'),
originalName: text('original_name').notNull(),
/**
* Collision-disambiguated name exposed to the copilot VFS as `uploads/<displayName>`.
* For mothership chat uploads, identical originalNames within a chat get suffixed
* `(2)`, `(3)`, ... in upload order so the VFS path is unique per chat.
* NULL on legacy rows that predate this column — readers must coalesce to originalName.
* Stable for the row's lifetime; the partial unique index below enforces uniqueness
* for new (non-NULL) rows. NULLs are treated as distinct in PG unique indexes, so
* legacy collisions remain (acceptable: those uploads have already happened).
*/
displayName: text('display_name'),
contentType: text('content_type').notNull(),
size: integer('size').notNull(),
deletedAt: timestamp('deleted_at'),
uploadedAt: timestamp('uploaded_at').notNull().defaultNow(),
updatedAt: timestamp('updated_at').notNull().defaultNow(),
},
(table) => ({
keyActiveUniqueIdx: uniqueIndex('workspace_files_key_active_unique')
.on(table.key)
.where(sql`${table.deletedAt} IS NULL`),
workspaceFolderOriginalNameActiveUnique: uniqueIndex(
'workspace_files_workspace_folder_name_active_unique'
)
.on(table.workspaceId, sql`coalesce(${table.folderId}, '')`, table.originalName)
.where(
sql`${table.deletedAt} IS NULL AND ${table.context} = 'workspace' AND ${table.workspaceId} IS NOT NULL`
),
/**
* One display name per chat for mothership chat uploads, enforced across the row's
* entire lifetime (including soft-deleted rows). VFS paths must remain stable for the
* LLM's session — soft-deleting a sibling cannot free a name slot that the model has
* already been told about, since that would cause `read("uploads/<name>")` to silently
* resolve to a different file. NULLs are distinct in PG, so legacy rows (display_name
* IS NULL) don't block index creation or new inserts.
*/
chatDisplayNameUnique: uniqueIndex('workspace_files_chat_display_name_unique')
.on(table.chatId, table.displayName)
.where(sql`${table.context} = 'mothership' AND ${table.chatId} IS NOT NULL`),
keyIdx: index('workspace_files_key_idx').on(table.key),
userIdIdx: index('workspace_files_user_id_idx').on(table.userId),
workspaceIdIdx: index('workspace_files_workspace_id_idx').on(table.workspaceId),
folderIdIdx: index('workspace_files_folder_id_idx').on(table.folderId),
contextIdx: index('workspace_files_context_idx').on(table.context),
chatIdIdx: index('workspace_files_chat_id_idx').on(table.chatId),
deletedAtIdx: index('workspace_files_deleted_at_idx').on(table.deletedAt),
workspaceDeletedAtPartialIdx: index('workspace_files_workspace_deleted_partial_idx')
.on(table.workspaceId, table.deletedAt)
.where(sql`${table.deletedAt} IS NOT NULL`),
})
)
/**
* Public share links for workspace resources. Polymorphic on `resourceType` so a
* single mechanism serves files now and folders later. One row per resource
* (disable/re-enable flips `isActive` and keeps the same token).
*/
export const publicShare = pgTable(
'public_share',
{
id: text('id').primaryKey(),
resourceType: text('resource_type').notNull(), // 'file' | 'folder' (folder reserved for future)
resourceId: text('resource_id').notNull(),
workspaceId: text('workspace_id')
.notNull()
.references(() => workspace.id, { onDelete: 'cascade' }),
// SET NULL (not CASCADE) so a share — and its public link — outlives the user
// who created it; the file still belongs to the workspace.
createdBy: text('created_by').references(() => user.id, { onDelete: 'set null' }),
token: text('token').notNull(),
isActive: boolean('is_active').notNull().default(true),
// 'public' (anyone with the link) | 'password' | 'email' (OTP) | 'sso'.
authType: text('auth_type').notNull().default('public'),
// AES-256-GCM encrypted share password; null unless authType is 'password'.
password: text('password'),
// Allowed emails/domains (e.g. '@acme.com') when authType is 'email' or 'sso'.
allowedEmails: json('allowed_emails').default('[]'),
createdAt: timestamp('created_at').notNull().defaultNow(),
updatedAt: timestamp('updated_at').notNull().defaultNow(),
},
(table) => ({
tokenIdx: uniqueIndex('public_share_token_unique').on(table.token),
resourceUniqueIdx: uniqueIndex('public_share_resource_unique').on(
table.resourceType,
table.resourceId
),
resourceIdIdx: index('public_share_resource_id_idx').on(table.resourceId),
workspaceIdIdx: index('public_share_workspace_id_idx').on(table.workspaceId),
})
)
export const permissionTypeEnum = pgEnum('permission_type', ['admin', 'write', 'read'])
export const invitationWorkspaceGrant = pgTable(
'invitation_workspace_grant',
{
id: text('id').primaryKey(),
invitationId: text('invitation_id')
.notNull()
.references(() => invitation.id, { onDelete: 'cascade' }),
workspaceId: text('workspace_id')
.notNull()
.references(() => workspace.id, { onDelete: 'cascade' }),
permission: permissionTypeEnum('permission').notNull(),
createdAt: timestamp('created_at').notNull().defaultNow(),
updatedAt: timestamp('updated_at').notNull().defaultNow(),
},
(table) => ({
invitationWorkspaceUnique: uniqueIndex('invitation_workspace_grant_unique').on(
table.invitationId,
table.workspaceId
),
workspaceIdIdx: index('invitation_workspace_grant_workspace_id_idx').on(table.workspaceId),
})
)
/**
* Polymorphic access grants: `entityType` + `entityId` reference a workspace,
* workflow, organization, etc. by id, but `entityId` is **not a foreign key** —
* so deleting the referenced entity does NOT cascade-delete these rows. Soft
* deletes (e.g. workspace archive) intentionally keep them: the entity is blocked
* everywhere by its `archivedAt`, so the rows are harmless, and a future restore
* would need them. Only a **hard** delete/purge of an entity must remove its
* grants explicitly — e.g.
* `DELETE FROM permissions WHERE entity_type = 'workspace' AND entity_id = $id` —
* or they orphan.
*/
export const permissions = pgTable(
'permissions',
{
id: text('id').primaryKey(),
userId: text('user_id')
.notNull()
.references(() => user.id, { onDelete: 'cascade' }),
entityType: text('entity_type').notNull(), // 'workspace', 'workflow', 'organization', etc.
entityId: text('entity_id').notNull(), // ID of the workspace, workflow, etc.
permissionType: permissionTypeEnum('permission_type').notNull(), // Use enum instead of text
createdAt: timestamp('created_at').notNull().defaultNow(),
updatedAt: timestamp('updated_at').notNull().defaultNow(),
},
(table) => ({
// Primary access pattern - get all permissions for a user
userIdIdx: index('permissions_user_id_idx').on(table.userId),
// Entity-based queries - get all users with permissions on an entity
entityIdx: index('permissions_entity_idx').on(table.entityType, table.entityId),
// User + entity type queries - get user's permissions for all workspaces
userEntityTypeIdx: index('permissions_user_entity_type_idx').on(table.userId, table.entityType),
// Specific permission checks - does user have specific permission on entity
userEntityPermissionIdx: index('permissions_user_entity_permission_idx').on(
table.userId,
table.entityType,
table.permissionType
),
// User + specific entity queries - get user's permissions for specific entity
userEntityIdx: index('permissions_user_entity_idx').on(
table.userId,
table.entityType,
table.entityId
),
// Uniqueness constraint - prevent duplicate permission rows (one permission per user/entity)
uniquePermissionConstraint: uniqueIndex('permissions_unique_constraint').on(
table.userId,
table.entityType,
table.entityId
),
})
)
export const memory = pgTable(
'memory',
{
id: text('id').primaryKey(),
workspaceId: text('workspace_id')
.notNull()
.references(() => workspace.id, { onDelete: 'cascade' }),
key: text('key').notNull(),
data: jsonb('data').notNull(),
createdAt: timestamp('created_at').notNull().defaultNow(),
updatedAt: timestamp('updated_at').notNull().defaultNow(),
deletedAt: timestamp('deleted_at'),
},
(table) => {
return {
keyIdx: index('memory_key_idx').on(table.key),
workspaceIdx: index('memory_workspace_idx').on(table.workspaceId),
uniqueKeyPerWorkspaceIdx: uniqueIndex('memory_workspace_key_idx').on(
table.workspaceId,
table.key
),
workspaceDeletedAtPartialIdx: index('memory_workspace_deleted_partial_idx')
.on(table.workspaceId, table.deletedAt)
.where(sql`${table.deletedAt} IS NOT NULL`),
}
}
)
export const knowledgeBase = pgTable(
'knowledge_base',
{
id: text('id').primaryKey(),
userId: text('user_id')
.notNull()
.references(() => user.id, { onDelete: 'cascade' }),
workspaceId: text('workspace_id').references(() => workspace.id, { onDelete: 'cascade' }),
folderId: text('folder_id').references(() => folder.id, { onDelete: 'set null' }),
name: text('name').notNull(),
description: text('description'),
// Token tracking for usage
tokenCount: integer('token_count').notNull().default(0),
// Embedding configuration
embeddingModel: text('embedding_model').notNull().default('text-embedding-3-small'),
embeddingDimension: integer('embedding_dimension').notNull().default(1536),
// Chunking configuration stored as JSON for flexibility
chunkingConfig: json('chunking_config')
.notNull()
.default('{"maxSize": 1024, "minSize": 1, "overlap": 200}'),
// Soft delete support
deletedAt: timestamp('deleted_at'),
// Metadata and timestamps
createdAt: timestamp('created_at').notNull().defaultNow(),
updatedAt: timestamp('updated_at').notNull().defaultNow(),
},
(table) => ({
// Primary access patterns
userIdIdx: index('kb_user_id_idx').on(table.userId),
workspaceIdIdx: index('kb_workspace_id_idx').on(table.workspaceId),
// Composite index for user's workspaces
userWorkspaceIdx: index('kb_user_workspace_idx').on(table.userId, table.workspaceId),
folderIdIdx: index('kb_folder_id_idx').on(table.folderId),
// Index for soft delete filtering
deletedAtIdx: index('kb_deleted_at_idx').on(table.deletedAt),
workspaceDeletedAtPartialIdx: index('kb_workspace_deleted_partial_idx')
.on(table.workspaceId, table.deletedAt)
.where(sql`${table.deletedAt} IS NOT NULL`),
/** One active (non-deleted) name per workspace; matches user_table_definitions pattern */
workspaceNameActiveUnique: uniqueIndex('kb_workspace_name_active_unique')
.on(table.workspaceId, table.name)
.where(sql`${table.deletedAt} IS NULL`),
})
)
export const document = pgTable(
'document',
{
id: text('id').primaryKey(),
knowledgeBaseId: text('knowledge_base_id')
.notNull()
.references(() => knowledgeBase.id, { onDelete: 'cascade' }),
// File information
filename: text('filename').notNull(),
fileUrl: text('file_url').notNull(),
// Canonical storage key derived from fileUrl at write time (e.g. 'kb/<...>'),
// or null for external/data: ingestion URLs. KB file authorization matches on
// this exact key rather than re-parsing the URL at read time.
storageKey: text('storage_key'),
fileSize: integer('file_size').notNull(), // Size in bytes
mimeType: text('mime_type').notNull(), // e.g., 'application/pdf', 'text/plain'
// Content statistics
chunkCount: integer('chunk_count').notNull().default(0),
tokenCount: integer('token_count').notNull().default(0),
characterCount: integer('character_count').notNull().default(0),
// Processing status
processingStatus: text('processing_status').notNull().default('pending'), // 'pending', 'processing', 'completed', 'failed'
processingStartedAt: timestamp('processing_started_at'),
processingCompletedAt: timestamp('processing_completed_at'),
processingError: text('processing_error'),
// Document state
enabled: boolean('enabled').notNull().default(true), // Enable/disable from knowledge base
archivedAt: timestamp('archived_at'), // Parent KB/workspace archive marker
deletedAt: timestamp('deleted_at'), // Soft delete
userExcluded: boolean('user_excluded').notNull().default(false), // User explicitly excluded — skip on sync
// Document tags for filtering (inherited by all chunks)
// Text tags (7 slots)
tag1: text('tag1'),
tag2: text('tag2'),
tag3: text('tag3'),
tag4: text('tag4'),
tag5: text('tag5'),
tag6: text('tag6'),
tag7: text('tag7'),
// Number tags (5 slots)
number1: doublePrecision('number1'),
number2: doublePrecision('number2'),
number3: doublePrecision('number3'),
number4: doublePrecision('number4'),
number5: doublePrecision('number5'),
// Date tags (2 slots)
date1: timestamp('date1'),
date2: timestamp('date2'),
// Boolean tags (3 slots)
boolean1: boolean('boolean1'),
boolean2: boolean('boolean2'),
boolean3: boolean('boolean3'),
// Connector-sourced document fields
connectorId: text('connector_id').references(() => knowledgeConnector.id, {
onDelete: 'set null',
}),
externalId: text('external_id'),
contentHash: text('content_hash'),
sourceUrl: text('source_url'),
/** User who uploaded the document, for usage attribution. Null for
* connector/cron-synced docs (and pre-migration rows) → indexing billing
* falls back to the workspace billed account. */
uploadedBy: text('uploaded_by').references(() => user.id, { onDelete: 'set null' }),
// Timestamps
uploadedAt: timestamp('uploaded_at').notNull().defaultNow(),
},
(table) => ({
// Primary access pattern - filter by knowledge base
knowledgeBaseIdIdx: index('doc_kb_id_idx').on(table.knowledgeBaseId),
// Search by filename
filenameIdx: index('doc_filename_idx').on(table.filename),
// Processing status filtering
processingStatusIdx: index('doc_processing_status_idx').on(
table.knowledgeBaseId,
table.processingStatus
),
// Connector document uniqueness (partial — only non-deleted rows)
connectorExternalIdIdx: uniqueIndex('doc_connector_external_id_idx')
.on(table.connectorId, table.externalId)
.where(sql`${table.deletedAt} IS NULL`),
// Sync engine: load all active docs for a connector
connectorIdIdx: index('doc_connector_id_idx').on(table.connectorId),
// KB file-access liveness: exact lookup by canonical storage key
storageKeyIdx: index('doc_storage_key_idx')
.on(table.storageKey)
.where(sql`${table.storageKey} IS NOT NULL`),
archivedAtPartialIdx: index('doc_archived_at_partial_idx')
.on(table.archivedAt)
.where(sql`${table.archivedAt} IS NOT NULL`),
deletedAtPartialIdx: index('doc_deleted_at_partial_idx')
.on(table.deletedAt)
.where(sql`${table.deletedAt} IS NOT NULL`),
// Text tag indexes
tag1Idx: index('doc_tag1_idx').on(table.tag1),
tag2Idx: index('doc_tag2_idx').on(table.tag2),
tag3Idx: index('doc_tag3_idx').on(table.tag3),
tag4Idx: index('doc_tag4_idx').on(table.tag4),
tag5Idx: index('doc_tag5_idx').on(table.tag5),
tag6Idx: index('doc_tag6_idx').on(table.tag6),
tag7Idx: index('doc_tag7_idx').on(table.tag7),
// Number tag indexes (5 slots)
number1Idx: index('doc_number1_idx').on(table.number1),
number2Idx: index('doc_number2_idx').on(table.number2),
number3Idx: index('doc_number3_idx').on(table.number3),
number4Idx: index('doc_number4_idx').on(table.number4),
number5Idx: index('doc_number5_idx').on(table.number5),
// Date tag indexes (2 slots)
date1Idx: index('doc_date1_idx').on(table.date1),
date2Idx: index('doc_date2_idx').on(table.date2),
// Boolean tag indexes (3 slots)
boolean1Idx: index('doc_boolean1_idx').on(table.boolean1),
boolean2Idx: index('doc_boolean2_idx').on(table.boolean2),
boolean3Idx: index('doc_boolean3_idx').on(table.boolean3),
})
)
export const knowledgeBaseTagDefinitions = pgTable(
'knowledge_base_tag_definitions',
{
id: text('id').primaryKey(),
knowledgeBaseId: text('knowledge_base_id')
.notNull()
.references(() => knowledgeBase.id, { onDelete: 'cascade' }),
tagSlot: text('tag_slot', {
enum: TAG_SLOTS,
}).notNull(),
displayName: text('display_name').notNull(),
fieldType: text('field_type').notNull().default('text'), // 'text', future: 'date', 'number', 'range'
createdAt: timestamp('created_at').notNull().defaultNow(),
updatedAt: timestamp('updated_at').notNull().defaultNow(),
},
(table) => ({
// Ensure unique tag slot per knowledge base
kbTagSlotIdx: uniqueIndex('kb_tag_definitions_kb_slot_idx').on(
table.knowledgeBaseId,
table.tagSlot
),
// Ensure unique display name per knowledge base
kbDisplayNameIdx: uniqueIndex('kb_tag_definitions_kb_display_name_idx').on(
table.knowledgeBaseId,
table.displayName
),
// Index for querying by knowledge base
kbIdIdx: index('kb_tag_definitions_kb_id_idx').on(table.knowledgeBaseId),
})
)
export const embedding = pgTable(
'embedding',
{
id: text('id').primaryKey(),
knowledgeBaseId: text('knowledge_base_id')
.notNull()
.references(() => knowledgeBase.id, { onDelete: 'cascade' }),
documentId: text('document_id')
.notNull()
.references(() => document.id, { onDelete: 'cascade' }),
// Chunk information
chunkIndex: integer('chunk_index').notNull(),
chunkHash: text('chunk_hash').notNull(),
content: text('content').notNull(),
contentLength: integer('content_length').notNull(),
tokenCount: integer('token_count').notNull(),
// Vector embeddings - optimized for text-embedding-3-small with HNSW support
embedding: vector('embedding', { dimensions: 1536 }), // For text-embedding-3-small
embeddingModel: text('embedding_model').notNull().default('text-embedding-3-small'),
// Chunk boundaries and overlap
startOffset: integer('start_offset').notNull(),
endOffset: integer('end_offset').notNull(),
// Tag columns inherited from document for efficient filtering
// Text tags (7 slots)
tag1: text('tag1'),
tag2: text('tag2'),
tag3: text('tag3'),
tag4: text('tag4'),
tag5: text('tag5'),
tag6: text('tag6'),
tag7: text('tag7'),
// Number tags (5 slots)
number1: doublePrecision('number1'),
number2: doublePrecision('number2'),
number3: doublePrecision('number3'),
number4: doublePrecision('number4'),
number5: doublePrecision('number5'),
// Date tags (2 slots)
date1: timestamp('date1'),
date2: timestamp('date2'),
// Boolean tags (3 slots)
boolean1: boolean('boolean1'),
boolean2: boolean('boolean2'),
boolean3: boolean('boolean3'),
// Chunk state - enable/disable from knowledge base
enabled: boolean('enabled').notNull().default(true),
// Full-text search support - generated tsvector column
contentTsv: tsvector('content_tsv').generatedAlwaysAs(
(): SQL => sql`to_tsvector('english', ${embedding.content})`
),
// Timestamps
createdAt: timestamp('created_at').notNull().defaultNow(),
updatedAt: timestamp('updated_at').notNull().defaultNow(),
},
(table) => ({
// Primary vector search pattern
kbIdIdx: index('emb_kb_id_idx').on(table.knowledgeBaseId),
// Document-level access
docIdIdx: index('emb_doc_id_idx').on(table.documentId),
// Chunk ordering within documents
docChunkIdx: uniqueIndex('emb_doc_chunk_idx').on(table.documentId, table.chunkIndex),
// Model-specific queries for A/B testing or migrations
kbModelIdx: index('emb_kb_model_idx').on(table.knowledgeBaseId, table.embeddingModel),
// Enabled state filtering indexes (for chunk enable/disable functionality)
kbEnabledIdx: index('emb_kb_enabled_idx').on(table.knowledgeBaseId, table.enabled),
docEnabledIdx: index('emb_doc_enabled_idx').on(table.documentId, table.enabled),
// Vector similarity search indexes (HNSW) - optimized for small embeddings
embeddingVectorHnswIdx: index('embedding_vector_hnsw_idx')
.using('hnsw', table.embedding.op('vector_cosine_ops'))
.with({
m: 16,
ef_construction: 64,
}),
// Text tag indexes
tag1Idx: index('emb_tag1_idx').on(table.tag1),
tag2Idx: index('emb_tag2_idx').on(table.tag2),
tag3Idx: index('emb_tag3_idx').on(table.tag3),
tag4Idx: index('emb_tag4_idx').on(table.tag4),
tag5Idx: index('emb_tag5_idx').on(table.tag5),
tag6Idx: index('emb_tag6_idx').on(table.tag6),
tag7Idx: index('emb_tag7_idx').on(table.tag7),
// Number tag indexes (5 slots)
number1Idx: index('emb_number1_idx').on(table.number1),
number2Idx: index('emb_number2_idx').on(table.number2),
number3Idx: index('emb_number3_idx').on(table.number3),
number4Idx: index('emb_number4_idx').on(table.number4),
number5Idx: index('emb_number5_idx').on(table.number5),
// Date tag indexes (2 slots)
date1Idx: index('emb_date1_idx').on(table.date1),
date2Idx: index('emb_date2_idx').on(table.date2),
// Boolean tag indexes (3 slots)
boolean1Idx: index('emb_boolean1_idx').on(table.boolean1),
boolean2Idx: index('emb_boolean2_idx').on(table.boolean2),
boolean3Idx: index('emb_boolean3_idx').on(table.boolean3),
// Full-text search index
contentFtsIdx: index('emb_content_fts_idx').using('gin', table.contentTsv),
// Ensure embedding exists (simplified since we only support one model)
embeddingNotNullCheck: check('embedding_not_null_check', sql`"embedding" IS NOT NULL`),
})
)
export const docsEmbeddings = pgTable(
'docs_embeddings',
{
chunkId: uuid('chunk_id').primaryKey().defaultRandom(),
chunkText: text('chunk_text').notNull(),
sourceDocument: text('source_document').notNull(),
sourceLink: text('source_link').notNull(),
headerText: text('header_text').notNull(),
headerLevel: integer('header_level').notNull(),
tokenCount: integer('token_count').notNull(),
// Vector embedding - optimized for text-embedding-3-small with HNSW support
embedding: vector('embedding', { dimensions: 1536 }).notNull(),
embeddingModel: text('embedding_model').notNull().default('text-embedding-3-small'),
// Metadata for flexible filtering
metadata: jsonb('metadata').notNull().default('{}'),
// Full-text search support - generated tsvector column
chunkTextTsv: tsvector('chunk_text_tsv').generatedAlwaysAs(
(): SQL => sql`to_tsvector('english', ${docsEmbeddings.chunkText})`
),
// Timestamps
createdAt: timestamp('created_at').notNull().defaultNow(),
updatedAt: timestamp('updated_at').notNull().defaultNow(),
},
(table) => ({
// Source document queries
sourceDocumentIdx: index('docs_emb_source_document_idx').on(table.sourceDocument),
// Header level filtering
headerLevelIdx: index('docs_emb_header_level_idx').on(table.headerLevel),
// Combined source and header queries
sourceHeaderIdx: index('docs_emb_source_header_idx').on(
table.sourceDocument,
table.headerLevel
),
// Model-specific queries
modelIdx: index('docs_emb_model_idx').on(table.embeddingModel),
// Timestamp queries
createdAtIdx: index('docs_emb_created_at_idx').on(table.createdAt),
// Vector similarity search indexes (HNSW) - optimized for documentation embeddings
embeddingVectorHnswIdx: index('docs_embedding_vector_hnsw_idx')
.using('hnsw', table.embedding.op('vector_cosine_ops'))
.with({
m: 16,
ef_construction: 64,
}),
// GIN index for JSONB metadata queries
metadataGinIdx: index('docs_emb_metadata_gin_idx').using('gin', table.metadata),
// Full-text search index
chunkTextFtsIdx: index('docs_emb_chunk_text_fts_idx').using('gin', table.chunkTextTsv),
// Constraints
embeddingNotNullCheck: check('docs_embedding_not_null_check', sql`"embedding" IS NOT NULL`),
headerLevelCheck: check(
'docs_header_level_check',
sql`"header_level" >= 1 AND "header_level" <= 6`
),
})
)
export const chatTypeEnum = pgEnum('chat_type', ['mothership', 'copilot'])
export const copilotChats = pgTable(
'copilot_chats',
{
id: uuid('id').primaryKey().defaultRandom(),
userId: text('user_id')
.notNull()
.references(() => user.id, { onDelete: 'cascade' }),
workflowId: text('workflow_id').references(() => workflow.id, { onDelete: 'cascade' }),
workspaceId: text('workspace_id').references(() => workspace.id, { onDelete: 'cascade' }),
type: chatTypeEnum('type').notNull().default('copilot'),
title: text('title'),
model: text('model').notNull().default('claude-3-7-sonnet-latest'),
conversationId: text('conversation_id'),
previewYaml: text('preview_yaml'),
/**
* @deprecated Nothing reads or writes this any more — the plan artifact
* moved into the message transcript. Kept only so the column survives the
* deploy that removes its last readers; drop it in a follow-up migration
* once that deploy has fully rolled out (expand/contract).
*/
planArtifact: text('plan_artifact'),
config: jsonb('config'),
resources: jsonb('resources').notNull().default('[]'),
// Copilot tool ids the user allowed for the rest of this chat only, as
// opposed to the account-wide list on `settings.copilotAutoAllowedTools`.
autoAllowedTools: jsonb('auto_allowed_tools').notNull().default('[]'),
lastSeenAt: timestamp('last_seen_at'),
pinned: boolean('pinned').notNull().default(false),
deletedAt: timestamp('deleted_at'),
createdAt: timestamp('created_at').notNull().defaultNow(),
updatedAt: timestamp('updated_at').notNull().defaultNow(),
},
(table) => ({
// Primary access patterns
userIdIdx: index('copilot_chats_user_id_idx').on(table.userId),
workflowIdIdx: index('copilot_chats_workflow_id_idx').on(table.workflowId),
userWorkflowIdx: index('copilot_chats_user_workflow_idx').on(table.userId, table.workflowId),
// Workspace access pattern
userWorkspaceIdx2: index('copilot_chats_user_workspace_idx').on(
table.userId,
table.workspaceId
),
// Ordering indexes
createdAtIdx: index('copilot_chats_created_at_idx').on(table.createdAt),
updatedAtIdx: index('copilot_chats_updated_at_idx').on(table.updatedAt),
workspaceCreatedAtIdIdx: index('copilot_chats_workspace_created_at_id_idx').on(
table.workspaceId,
sql`date_trunc('milliseconds', ${table.createdAt})`,
table.id
),
// Soft-deleted chats surfaced in Recently Deleted (listed per user + workspace)
userWorkspaceDeletedPartialIdx: index('copilot_chats_user_workspace_deleted_partial_idx')
.on(table.userId, table.workspaceId)
.where(sql`${table.deletedAt} IS NOT NULL`),
})
)
export const copilotMessages = pgTable(
'copilot_messages',
{
id: uuid('id').primaryKey().defaultRandom(),
chatId: uuid('chat_id')
.notNull()
.references(() => copilotChats.id, { onDelete: 'cascade' }),
messageId: text('message_id').notNull(),
role: text('role').notNull(),
content: jsonb('content').notNull(),
streamId: text('stream_id'),
parentMessageId: text('parent_message_id'),
model: text('model'),
tokensIn: integer('tokens_in'),
tokensOut: integer('tokens_out'),
seq: integer('seq'),
deletedAt: timestamp('deleted_at'),
createdAt: timestamp('created_at').notNull().defaultNow(),
updatedAt: timestamp('updated_at').notNull().defaultNow(),
},
(table) => ({
chatMessageUnique: uniqueIndex('copilot_messages_chat_message_unique').on(
table.chatId,
table.messageId
),
chatCreatedAtIdx: index('copilot_messages_chat_created_at_idx')
.on(table.chatId, table.createdAt, table.id)
.where(sql`${table.deletedAt} IS NULL`),
chatSeqIdx: index('copilot_messages_chat_seq_idx')
.on(table.chatId, table.seq)
.where(sql`${table.deletedAt} IS NULL`),
chatStreamIdx: index('copilot_messages_chat_stream_idx')
.on(table.chatId, table.streamId)
.where(sql`${table.streamId} IS NOT NULL`),
userCreatedAtIdx: index('copilot_messages_user_created_at_idx')
.on(table.createdAt, table.chatId, table.messageId)
.where(sql`${table.role} = 'user' AND ${table.deletedAt} IS NULL`),
})
)
export const copilotWorkflowReadHashes = pgTable(
'copilot_workflow_read_hashes',
{
id: uuid('id').primaryKey().defaultRandom(),
chatId: uuid('chat_id')
.notNull()
.references(() => copilotChats.id, { onDelete: 'cascade' }),
workflowId: text('workflow_id')
.notNull()
.references(() => workflow.id, { onDelete: 'cascade' }),
hash: text('hash').notNull(),
createdAt: timestamp('created_at').notNull().defaultNow(),
updatedAt: timestamp('updated_at').notNull().defaultNow(),
},
(table) => ({
chatIdIdx: index('copilot_workflow_read_hashes_chat_id_idx').on(table.chatId),
workflowIdIdx: index('copilot_workflow_read_hashes_workflow_id_idx').on(table.workflowId),
chatWorkflowUnique: uniqueIndex('copilot_workflow_read_hashes_chat_workflow_unique').on(
table.chatId,
table.workflowId
),
})
)
export const workflowCheckpoints = pgTable(
'workflow_checkpoints',
{
id: uuid('id').primaryKey().defaultRandom(),
userId: text('user_id')
.notNull()
.references(() => user.id, { onDelete: 'cascade' }),
workflowId: text('workflow_id')
.notNull()
.references(() => workflow.id, { onDelete: 'cascade' }),
chatId: uuid('chat_id')
.notNull()
.references(() => copilotChats.id, { onDelete: 'cascade' }),
messageId: text('message_id'), // ID of the user message that triggered this checkpoint
workflowState: json('workflow_state').notNull(), // JSON workflow state
createdAt: timestamp('created_at').notNull().defaultNow(),
updatedAt: timestamp('updated_at').notNull().defaultNow(),
},
(table) => ({
// Primary access patterns
userIdIdx: index('workflow_checkpoints_user_id_idx').on(table.userId),
workflowIdIdx: index('workflow_checkpoints_workflow_id_idx').on(table.workflowId),
chatIdIdx: index('workflow_checkpoints_chat_id_idx').on(table.chatId),
messageIdIdx: index('workflow_checkpoints_message_id_idx').on(table.messageId),
// Combined indexes for common queries
userWorkflowIdx: index('workflow_checkpoints_user_workflow_idx').on(
table.userId,
table.workflowId
),
workflowChatIdx: index('workflow_checkpoints_workflow_chat_idx').on(
table.workflowId,
table.chatId
),
// Ordering indexes
createdAtIdx: index('workflow_checkpoints_created_at_idx').on(table.createdAt),
chatCreatedAtIdx: index('workflow_checkpoints_chat_created_at_idx').on(
table.chatId,
table.createdAt
),
})
)
export const copilotRunStatusEnum = pgEnum('copilot_run_status', [
'active',
'paused_waiting_for_tool',
'resuming',
'complete',
'error',
'cancelled',
])
export const copilotAsyncToolStatusEnum = pgEnum('copilot_async_tool_status', [
'pending',
'running',
'completed',
'failed',
'cancelled',
'delivered',
])
export const copilotToolPermissionDecisionEnum = pgEnum('copilot_tool_permission_decision', [
'allow',
'allow_chat',
'always_allow',
'skip',
])
export type CopilotRunStatus = (typeof copilotRunStatusEnum.enumValues)[number]
export type CopilotAsyncToolStatus = (typeof copilotAsyncToolStatusEnum.enumValues)[number]
export type CopilotToolPermissionDecision =
(typeof copilotToolPermissionDecisionEnum.enumValues)[number]
export const copilotRuns = pgTable(
'copilot_runs',
{
id: uuid('id').primaryKey().defaultRandom(),
executionId: text('execution_id').notNull(),
parentRunId: uuid('parent_run_id'),
chatId: uuid('chat_id')
.notNull()
.references(() => copilotChats.id, { onDelete: 'cascade' }),
userId: text('user_id')
.notNull()
.references(() => user.id, { onDelete: 'cascade' }),
workflowId: text('workflow_id').references(() => workflow.id, { onDelete: 'cascade' }),
workspaceId: text('workspace_id').references(() => workspace.id, { onDelete: 'cascade' }),
streamId: text('stream_id').notNull(),
agent: text('agent'),
model: text('model'),
provider: text('provider'),
status: copilotRunStatusEnum('status').notNull().default('active'),
requestContext: jsonb('request_context').notNull().default('{}'),
startedAt: timestamp('started_at').notNull().defaultNow(),
completedAt: timestamp('completed_at'),
createdAt: timestamp('created_at').notNull().defaultNow(),
updatedAt: timestamp('updated_at').notNull().defaultNow(),
error: text('error'),
},
(table) => ({
executionIdIdx: index('copilot_runs_execution_id_idx').on(table.executionId),
parentRunIdIdx: index('copilot_runs_parent_run_id_idx').on(table.parentRunId),
chatIdIdx: index('copilot_runs_chat_id_idx').on(table.chatId),
userIdIdx: index('copilot_runs_user_id_idx').on(table.userId),
workflowIdIdx: index('copilot_runs_workflow_id_idx').on(table.workflowId),
workspaceIdIdx: index('copilot_runs_workspace_id_idx').on(table.workspaceId),
statusIdx: index('copilot_runs_status_idx').on(table.status),
chatExecutionIdx: index('copilot_runs_chat_execution_idx').on(table.chatId, table.executionId),
executionStartedAtIdx: index('copilot_runs_execution_started_at_idx').on(
table.executionId,
table.startedAt
),
workspaceCompletedAtIdIdx: index('copilot_runs_workspace_completed_at_id_idx').on(
table.workspaceId,
sql`date_trunc('milliseconds', ${table.completedAt})`,
table.id
),
streamIdUnique: uniqueIndex('copilot_runs_stream_id_unique').on(table.streamId),
})
)
export const copilotRunCheckpoints = pgTable(
'copilot_run_checkpoints',
{
id: uuid('id').primaryKey().defaultRandom(),
runId: uuid('run_id')
.notNull()
.references(() => copilotRuns.id, { onDelete: 'cascade' }),
pendingToolCallId: text('pending_tool_call_id').notNull(),
conversationSnapshot: jsonb('conversation_snapshot').notNull().default('{}'),
agentState: jsonb('agent_state').notNull().default('{}'),
providerRequest: jsonb('provider_request').notNull().default('{}'),
createdAt: timestamp('created_at').notNull().defaultNow(),
updatedAt: timestamp('updated_at').notNull().defaultNow(),
},
(table) => ({
runIdIdx: index('copilot_run_checkpoints_run_id_idx').on(table.runId),
pendingToolCallIdIdx: index('copilot_run_checkpoints_pending_tool_call_id_idx').on(
table.pendingToolCallId
),
runPendingUnique: uniqueIndex('copilot_run_checkpoints_run_pending_tool_unique').on(
table.runId,
table.pendingToolCallId
),
})
)
export const copilotAsyncToolCalls = pgTable(
'copilot_async_tool_calls',
{
id: uuid('id').primaryKey().defaultRandom(),
runId: uuid('run_id')
.notNull()
.references(() => copilotRuns.id, { onDelete: 'cascade' }),
checkpointId: uuid('checkpoint_id').references(() => copilotRunCheckpoints.id, {
onDelete: 'cascade',
}),
toolCallId: text('tool_call_id').notNull(),
toolName: text('tool_name').notNull(),
args: jsonb('args').notNull().default('{}'),
status: copilotAsyncToolStatusEnum('status').notNull().default('pending'),
result: jsonb('result'),
error: text('error'),
// Set only for tools declaring requiresApproval in the mothership tool
// catalog. A null decision on such a tool means the prompt is still
// outstanding, which is what lets it survive a reload.
permissionDecision: copilotToolPermissionDecisionEnum('permission_decision'),
permissionDecidedAt: timestamp('permission_decided_at'),
claimedAt: timestamp('claimed_at'),
claimedBy: text('claimed_by'),
completedAt: timestamp('completed_at'),
createdAt: timestamp('created_at').notNull().defaultNow(),
updatedAt: timestamp('updated_at').notNull().defaultNow(),
},
(table) => ({
runIdIdx: index('copilot_async_tool_calls_run_id_idx').on(table.runId),
checkpointIdIdx: index('copilot_async_tool_calls_checkpoint_id_idx').on(table.checkpointId),
toolCallIdIdx: index('copilot_async_tool_calls_tool_call_id_idx').on(table.toolCallId),
statusIdx: index('copilot_async_tool_calls_status_idx').on(table.status),
runStatusIdx: index('copilot_async_tool_calls_run_status_idx').on(table.runId, table.status),
toolCallUnique: uniqueIndex('copilot_async_tool_calls_tool_call_id_unique').on(
table.toolCallId
),
})
)
export const copilotFeedback = pgTable(
'copilot_feedback',
{
feedbackId: uuid('feedback_id').primaryKey().defaultRandom(),
userId: text('user_id')
.notNull()
.references(() => user.id, { onDelete: 'cascade' }),
chatId: uuid('chat_id')
.notNull()
.references(() => copilotChats.id, { onDelete: 'cascade' }),
userQuery: text('user_query').notNull(),
agentResponse: text('agent_response').notNull(),
isPositive: boolean('is_positive').notNull(),
feedback: text('feedback'), // Optional feedback text
workflowYaml: text('workflow_yaml'), // Optional workflow YAML if edit/build workflow was triggered
createdAt: timestamp('created_at').notNull().defaultNow(),
updatedAt: timestamp('updated_at').notNull().defaultNow(),
},
(table) => ({
// Access patterns
userIdIdx: index('copilot_feedback_user_id_idx').on(table.userId),
chatIdIdx: index('copilot_feedback_chat_id_idx').on(table.chatId),
userChatIdx: index('copilot_feedback_user_chat_idx').on(table.userId, table.chatId),
// Query patterns
isPositiveIdx: index('copilot_feedback_is_positive_idx').on(table.isPositive),
// Ordering indexes
createdAtIdx: index('copilot_feedback_created_at_idx').on(table.createdAt),
})
)
// Tracks immutable deployment versions for each workflow
export const workflowDeploymentVersion = pgTable(
'workflow_deployment_version',
{
id: text('id').primaryKey(),
workflowId: text('workflow_id')
.notNull()
.references(() => workflow.id, { onDelete: 'cascade' }),
version: integer('version').notNull(),
name: text('name'),
description: text('description'),
state: json('state').notNull(),
isActive: boolean('is_active').notNull().default(false),
createdAt: timestamp('created_at').notNull().defaultNow(),
createdBy: text('created_by'),
},
(table) => ({
workflowVersionUnique: uniqueIndex('workflow_deployment_version_workflow_version_unique').on(
table.workflowId,
table.version
),
workflowActiveIdx: index('workflow_deployment_version_workflow_active_idx').on(
table.workflowId,
table.isActive
),
createdAtIdx: index('workflow_deployment_version_created_at_idx').on(table.createdAt),
})
)
/**
* Tracks mutable deployment attempts separately from immutable version snapshots.
*/
export const workflowDeploymentOperation = pgTable(
'workflow_deployment_operation',
{
id: text('id').primaryKey(),
workflowId: text('workflow_id')
.notNull()
.references(() => workflow.id, { onDelete: 'cascade' }),
deploymentVersionId: text('deployment_version_id')
.notNull()
.references(() => workflowDeploymentVersion.id, { onDelete: 'cascade' }),
version: integer('version').notNull(),
previousActiveVersionId: text('previous_active_version_id').references(
() => workflowDeploymentVersion.id,
{ onDelete: 'set null' }
),
action: text('action').notNull(),
protocolVersion: integer('protocol_version').notNull(),
generation: integer('generation').notNull(),
status: text('status').notNull().default('preparing'),
componentReadiness: jsonb('component_readiness')
.$type<Record<string, unknown>>()
.notNull()
.default(sql`'{}'::jsonb`),
errorCode: text('error_code'),
errorMessage: text('error_message'),
idempotencyKey: text('idempotency_key'),
requestHash: text('request_hash').notNull(),
actorId: text('actor_id').notNull(),
completedAt: timestamp('completed_at'),
createdAt: timestamp('created_at').notNull().defaultNow(),
updatedAt: timestamp('updated_at').notNull().defaultNow(),
},
(table) => ({
workflowGenerationUnique: uniqueIndex(
'workflow_deployment_operation_workflow_generation_unique'
).on(table.workflowId, table.generation),
workflowIdempotencyUnique: uniqueIndex(
'workflow_deployment_operation_workflow_idempotency_unique'
)
.on(table.workflowId, table.idempotencyKey)
.where(sql`${table.idempotencyKey} IS NOT NULL`),
workflowInFlightUnique: uniqueIndex('workflow_deployment_operation_workflow_in_flight_unique')
.on(table.workflowId)
.where(sql`${table.status} IN ('preparing', 'activating')`),
workflowStatusIdx: index('workflow_deployment_operation_workflow_status_idx').on(
table.workflowId,
table.status
),
deploymentVersionIdx: index('workflow_deployment_operation_deployment_version_idx').on(
table.deploymentVersionId
),
workflowVersionGenerationIdx: index(
'workflow_deployment_operation_workflow_version_generation_idx'
).on(table.workflowId, table.deploymentVersionId, table.generation.desc()),
actionCheck: check(
'workflow_deployment_operation_action_check',
sql`${table.action} IN ('deploy', 'activate')`
),
statusCheck: check(
'workflow_deployment_operation_status_check',
sql`${table.status} IN ('preparing', 'activating', 'active', 'failed', 'superseded')`
),
generationCheck: check(
'workflow_deployment_operation_generation_check',
sql`${table.generation} > 0`
),
protocolVersionCheck: check(
'workflow_deployment_operation_protocol_version_check',
sql`${table.protocolVersion} > 0`
),
})
)
// Idempotency keys for preventing duplicate processing across all webhooks and triggers
export const idempotencyKey = pgTable(
'idempotency_key',
{
key: text('key').primaryKey(),
result: json('result').notNull(),
createdAt: timestamp('created_at').notNull().defaultNow(),
},
(table) => ({
// Index for cleanup operations by creation time
createdAtIdx: index('idempotency_key_created_at_idx').on(table.createdAt),
})
)
export const outboxEvent = pgTable(
'outbox_event',
{
id: text('id').primaryKey(),
eventType: text('event_type').notNull(),
payload: json('payload').notNull(),
status: text('status').notNull().default('pending'),
attempts: integer('attempts').notNull().default(0),
maxAttempts: integer('max_attempts').notNull().default(10),
availableAt: timestamp('available_at').notNull().defaultNow(),
lockedAt: timestamp('locked_at'),
lastError: text('last_error'),
createdAt: timestamp('created_at').notNull().defaultNow(),
processedAt: timestamp('processed_at'),
},
(table) => ({
statusAvailableIdx: index('outbox_event_status_available_idx').on(
table.status,
table.availableAt
),
lockedAtIdx: index('outbox_event_locked_at_idx').on(table.lockedAt),
eventTypeCreatedIdx: index('outbox_event_type_created_idx').on(
table.eventType,
table.createdAt
),
})
)
export const mcpServers = pgTable(
'mcp_servers',
{
id: text('id').primaryKey(),
workspaceId: text('workspace_id')
.notNull()
.references(() => workspace.id, { onDelete: 'cascade' }),
// Track who created the server, but workspace owns it
createdBy: text('created_by').references(() => user.id, { onDelete: 'set null' }),
name: text('name').notNull(),
description: text('description'),
transport: text('transport').notNull(),
url: text('url'),
authType: text('auth_type').notNull().default('headers'),
/**
* Optional pre-registered OAuth credentials for servers that don't
* support Dynamic Client Registration (RFC 7591). When set, these
* shortcut the SDK's DCR step. `oauthClientSecret` is encrypted.
*/
oauthClientId: text('oauth_client_id'),
oauthClientSecret: text('oauth_client_secret'),
headers: json('headers').default('{}'),
timeout: integer('timeout').default(30000),
retries: integer('retries').default(3),
enabled: boolean('enabled').notNull().default(true),
lastConnected: timestamp('last_connected'),
connectionStatus: text('connection_status').default('disconnected'),
lastError: text('last_error'),
statusConfig: jsonb('status_config').default('{}'),
toolCount: integer('tool_count').default(0),
lastToolsRefresh: timestamp('last_tools_refresh'),
totalRequests: integer('total_requests').default(0),
lastUsed: timestamp('last_used'),
deletedAt: timestamp('deleted_at'),
createdAt: timestamp('created_at').notNull().defaultNow(),
updatedAt: timestamp('updated_at').notNull().defaultNow(),
},
(table) => ({
// Primary access pattern - active servers by workspace
workspaceEnabledIdx: index('mcp_servers_workspace_enabled_idx').on(
table.workspaceId,
table.enabled
),
// Soft delete pattern - workspace + not deleted (partial: only deleted rows)
workspaceDeletedIdx: index('mcp_servers_workspace_deleted_partial_idx')
.on(table.workspaceId, table.deletedAt)
.where(sql`${table.deletedAt} IS NOT NULL`),
})
)
/**
* Workspace-scoped OAuth state for an outbound MCP server.
*
* Holds the SDK-managed OAuth artifacts needed to drive the standard MCP
* OAuth 2.1 + PKCE + dynamic-client-registration flow against a remote MCP
* server. One row per MCP server; workspace members share the authorized
* connection just like they share the MCP server definition.
*/
export const mcpServerOauth = pgTable(
'mcp_server_oauth',
{
id: text('id').primaryKey(),
mcpServerId: text('mcp_server_id')
.notNull()
.references(() => mcpServers.id, { onDelete: 'cascade' }),
/** Last workspace user who initiated/completed authorization. */
userId: text('user_id').references(() => user.id, { onDelete: 'set null' }),
workspaceId: text('workspace_id')
.notNull()
.references(() => workspace.id, { onDelete: 'cascade' }),
/**
* Encrypted JSON of the RFC 7591 dynamic client registration result.
* Encrypted because some authorization servers may issue a client_secret
* even for clients advertising `token_endpoint_auth_method: 'none'`.
*/
clientInformation: text('client_information'),
/** Encrypted JSON of the OAuth tokens (access + refresh). */
tokens: text('tokens'),
/** PKCE verifier held only between /authorize and /callback. */
codeVerifier: text('code_verifier'),
/** Opaque state mint to correlate the callback. */
state: text('state'),
/**
* When `state` was minted. Used to expire the active-flow window and the
* state replay window independently of `updatedAt`, which is touched by
* token refreshes and other writes.
*/
stateCreatedAt: timestamp('state_created_at'),
lastRefreshedAt: timestamp('last_refreshed_at'),
createdAt: timestamp('created_at').notNull().defaultNow(),
updatedAt: timestamp('updated_at').notNull().defaultNow(),
},
(table) => ({
serverUnique: uniqueIndex('mcp_server_oauth_server_unique').on(table.mcpServerId),
stateIdx: index('mcp_server_oauth_state_idx').on(table.state),
})
)
// SSO Provider table
export const ssoProvider = pgTable(
'sso_provider',
{
id: text('id').primaryKey(),
issuer: text('issuer').notNull(),
domain: text('domain').notNull(),
oidcConfig: text('oidc_config'),
samlConfig: text('saml_config'),
userId: text('user_id')
.notNull()
.references(() => user.id, { onDelete: 'cascade' }),
providerId: text('provider_id').notNull(),
organizationId: text('organization_id').references(() => organization.id, {
onDelete: 'cascade',
}),
},
(table) => ({
providerIdIdx: index('sso_provider_provider_id_idx').on(table.providerId),
domainIdx: index('sso_provider_domain_idx').on(table.domain),
userIdIdx: index('sso_provider_user_id_idx').on(table.userId),
organizationIdIdx: index('sso_provider_organization_id_idx').on(table.organizationId),
})
)
/**
* An email domain an organization has claimed, and its verification state.
*
* A domain must be **verified** (via a DNS TXT challenge — the org places
* `verificationToken` in a `_sim-challenge.<domain>` record) before it can be
* configured for single sign-on: verifying proves the org controls the domain,
* which is the security precondition for wiring it to an identity provider.
* Existing `sso_provider` domains are grandfathered as `verified` by the
* backfill in migration 0266.
*/
export const ssoDomain = pgTable(
'sso_domain',
{
id: text('id').primaryKey(),
organizationId: text('organization_id')
.notNull()
.references(() => organization.id, { onDelete: 'cascade' }),
/** Normalized (lowercase, registrable) domain — see `normalizeSSODomain`. */
domain: text('domain').notNull(),
/** `'pending'` until the DNS TXT record is observed, then `'verified'`. */
status: text('status').notNull().default('pending'),
/** High-entropy token placed in the domain's `_sim-challenge` TXT record. */
verificationToken: text('verification_token').notNull(),
verifiedAt: timestamp('verified_at'),
createdBy: text('created_by').references(() => user.id, { onDelete: 'set null' }),
createdAt: timestamp('created_at').notNull().defaultNow(),
updatedAt: timestamp('updated_at').notNull().defaultNow(),
},
(table) => ({
organizationIdIdx: index('sso_domain_organization_id_idx').on(table.organizationId),
domainIdx: index('sso_domain_domain_idx').on(table.domain),
/**
* An org holds at most one row per domain. Makes claims idempotent under
* concurrency: two admins racing to add the same domain cannot create
* duplicate pending rows — the second insert hits this constraint.
*/
orgDomainUnique: uniqueIndex('sso_domain_org_domain_unique').on(
table.organizationId,
table.domain
),
/**
* A verified domain is globally unique — exactly one org owns it. Pending
* rows may coexist (multiple orgs can race to prove ownership), so the
* constraint is a partial unique index scoped to verified rows.
*/
verifiedDomainUnique: uniqueIndex('sso_domain_verified_unique')
.on(table.domain)
.where(sql`status = 'verified'`),
})
)
/**
* Workflow MCP Servers - User-created MCP servers that expose workflows as tools.
* These servers are accessible by external MCP clients via API key authentication,
* or publicly if isPublic is set to true.
*/
export const workflowMcpServer = pgTable(
'workflow_mcp_server',
{
id: text('id').primaryKey(),
workspaceId: text('workspace_id')
.notNull()
.references(() => workspace.id, { onDelete: 'cascade' }),
createdBy: text('created_by')
.notNull()
.references(() => user.id, { onDelete: 'cascade' }),
name: text('name').notNull(),
description: text('description'),
isPublic: boolean('is_public').notNull().default(false),
deletedAt: timestamp('deleted_at'),
createdAt: timestamp('created_at').notNull().defaultNow(),
updatedAt: timestamp('updated_at').notNull().defaultNow(),
},
(table) => ({
workspaceIdIdx: index('workflow_mcp_server_workspace_id_idx').on(table.workspaceId),
createdByIdx: index('workflow_mcp_server_created_by_idx').on(table.createdBy),
deletedAtIdx: index('workflow_mcp_server_deleted_at_idx').on(table.deletedAt),
workspaceDeletedAtPartialIdx: index('workflow_mcp_server_workspace_deleted_partial_idx')
.on(table.workspaceId, table.deletedAt)
.where(sql`${table.deletedAt} IS NOT NULL`),
})
)
/**
* Workflow MCP Tools - Workflows registered as tools within a Workflow MCP Server.
* Each tool maps to a deployed workflow's execute endpoint.
*/
export const workflowMcpTool = pgTable(
'workflow_mcp_tool',
{
id: text('id').primaryKey(),
serverId: text('server_id')
.notNull()
.references(() => workflowMcpServer.id, { onDelete: 'cascade' }),
workflowId: text('workflow_id')
.notNull()
.references(() => workflow.id, { onDelete: 'cascade' }),
toolName: text('tool_name').notNull(),
toolDescription: text('tool_description'),
parameterSchema: json('parameter_schema').notNull().default('{}'),
parameterDescriptionOverrides: json('parameter_description_overrides')
.$type<Record<string, string>>()
.notNull()
.default(sql`'{}'::json`),
archivedAt: timestamp('archived_at'),
createdAt: timestamp('created_at').notNull().defaultNow(),
updatedAt: timestamp('updated_at').notNull().defaultNow(),
},
(table) => ({
serverIdIdx: index('workflow_mcp_tool_server_id_idx').on(table.serverId),
workflowIdIdx: index('workflow_mcp_tool_workflow_id_idx').on(table.workflowId),
serverWorkflowUnique: uniqueIndex('workflow_mcp_tool_server_workflow_unique')
.on(table.serverId, table.workflowId)
.where(sql`${table.archivedAt} IS NULL`),
archivedAtPartialIdx: index('workflow_mcp_tool_archived_at_partial_idx')
.on(table.archivedAt)
.where(sql`${table.archivedAt} IS NOT NULL`),
})
)
/**
* Custom Blocks - a deployed workflow published as a reusable, org-wide block.
* Scoped to an organization: available across every workspace in the org. Bound to
* a source `workflowId` and always executes that workflow's latest deployment. Start
* input fields are derived live (not snapshotted). `type` is the stable lowercase
* block-type slug (`custom_block_<shortId>`) that flows into the block registry
* overlay, the palette, and permission-group `allowedIntegrations` access control.
*/
export const customBlock = pgTable(
'custom_block',
{
id: text('id').primaryKey(),
organizationId: text('organization_id')
.notNull()
.references(() => organization.id, { onDelete: 'cascade' }),
workflowId: text('workflow_id')
.notNull()
.references(() => workflow.id, { onDelete: 'cascade' }),
type: text('type').notNull(),
name: text('name').notNull(),
description: text('description').notNull().default(''),
/** Uploaded icon image URL (workspace storage), or null for the default icon. */
iconUrl: text('icon_url'),
/**
* Per-input authored overrides keyed by the source Start field's stable `id`:
* `Array<{ id, placeholder?, required? }>`. Only the placeholder and required
* flag are authored — the input field set and its name/type/description are
* always derived live from the deployed Start (so they can never go stale); an
* override whose field was removed is ignored. Absent/empty → no overrides;
* every deployed Start input is still exposed.
*/
inputs: json('inputs').$type<Array<{ id: string; placeholder?: string; required?: boolean }>>(),
/**
* Curated outputs exposed to consumers: `Array<{ blockId, path, name }>`. Each
* maps a child-workflow block output (blockId + dot-path) to a friendly output
* name on the block. Empty/absent → expose the child's whole `result`. Internal
* plumbing (child workflow id, trace spans) is never exposed.
*/
outputs: json('outputs').$type<Array<{ blockId: string; path: string; name: string }>>(),
enabled: boolean('enabled').notNull().default(true),
createdBy: text('created_by').references(() => user.id, { onDelete: 'set null' }),
createdAt: timestamp('created_at').notNull().defaultNow(),
updatedAt: timestamp('updated_at').notNull().defaultNow(),
},
(table) => ({
organizationIdIdx: index('custom_block_organization_id_idx').on(table.organizationId),
workflowIdIdx: index('custom_block_workflow_id_idx').on(table.workflowId),
orgTypeUnique: uniqueIndex('custom_block_organization_type_unique').on(
table.organizationId,
table.type
),
})
)
export const auditLog = pgTable(
'audit_log',
{
id: text('id').primaryKey(),
workspaceId: text('workspace_id').references(() => workspace.id, { onDelete: 'set null' }),
actorId: text('actor_id').references(() => user.id, { onDelete: 'set null' }),
action: text('action').notNull(),
resourceType: text('resource_type').notNull(),
resourceId: text('resource_id'),
actorName: text('actor_name'),
actorEmail: text('actor_email'),
resourceName: text('resource_name'),
description: text('description'),
metadata: jsonb('metadata').default('{}'),
ipAddress: text('ip_address'),
userAgent: text('user_agent'),
createdAt: timestamp('created_at').notNull().defaultNow(),
},
(table) => ({
workspaceCreatedIdx: index('audit_log_workspace_created_idx').on(
table.workspaceId,
table.createdAt
),
workspaceCreatedIdIdx: index('audit_log_workspace_created_at_id_idx').on(
table.workspaceId,
sql`date_trunc('milliseconds', ${table.createdAt})`,
table.id
),
actorCreatedIdx: index('audit_log_actor_created_idx').on(table.actorId, table.createdAt),
resourceIdx: index('audit_log_resource_idx').on(table.resourceType, table.resourceId),
actionIdx: index('audit_log_action_idx').on(table.action),
})
)
export const usageLogCategoryEnum = pgEnum('usage_log_category', ['model', 'fixed', 'tool'])
export const usageLogSourceEnum = pgEnum('usage_log_source', [
'workflow',
'wand',
'copilot',
'workspace-chat',
'mcp_copilot',
'mothership_block',
'knowledge-base',
'voice-input',
'enrichment',
])
export const usageLog = pgTable(
'usage_log',
{
id: text('id').primaryKey(),
userId: text('user_id')
.notNull()
.references(() => user.id, { onDelete: 'cascade' }),
category: usageLogCategoryEnum('category').notNull(),
source: usageLogSourceEnum('source').notNull(),
description: text('description').notNull(),
metadata: jsonb('metadata'),
cost: decimal('cost').notNull(),
eventKey: text('event_key'),
billingEntityType: billingEntityTypeEnum('billing_entity_type'),
billingEntityId: text('billing_entity_id'),
billingPeriodStart: timestamp('billing_period_start'),
billingPeriodEnd: timestamp('billing_period_end'),
workspaceId: text('workspace_id').references(() => workspace.id, { onDelete: 'set null' }),
workflowId: text('workflow_id').references(() => workflow.id, { onDelete: 'set null' }),
executionId: text('execution_id'),
createdAt: timestamp('created_at').notNull().defaultNow(),
},
(table) => ({
userCreatedAtIdx: index('usage_log_user_created_at_idx').on(table.userId, table.createdAt),
sourceIdx: index('usage_log_source_idx').on(table.source),
workspaceIdIdx: index('usage_log_workspace_id_idx').on(table.workspaceId),
workflowIdIdx: index('usage_log_workflow_id_idx').on(table.workflowId),
eventKeyUnique: uniqueIndex('usage_log_event_key_unique')
.on(table.eventKey)
.where(sql`${table.eventKey} IS NOT NULL`),
billingEntityPeriodIdx: index('usage_log_billing_entity_period_idx')
.on(
table.billingEntityType,
table.billingEntityId,
table.billingPeriodStart,
table.billingPeriodEnd
)
.where(sql`${table.billingEntityType} IS NOT NULL`),
/**
* Covering companion to `billingEntityPeriodIdx` — not a replacement. Carries
* `cost` so the billing-period aggregates resolve index-only rather than taking
* a heap fetch per matched row.
*
* `userId`/`createdAt` sit immediately after the shared equality prefix because
* the daily-refresh rollup filters on them and NOT on `billingPeriodEnd`;
* putting `billingPeriodEnd` in that slot would end the usable prefix at
* `billingPeriodStart` and leave that query scanning the whole period.
* `billingPeriodEnd` is functionally determined by `billingPeriodStart`, so it
* removes no rows and rides along as payload.
*
* `billingEntityPeriodIdx` is kept deliberately: with no high-cardinality key
* column it deduplicates to a fraction of this index's size, which keeps
* prefix-only bitmap scans cheap.
*/
billingPeriodCostIdx: index('usage_log_billing_period_cost_idx')
.on(
table.billingEntityType,
table.billingEntityId,
table.billingPeriodStart,
table.userId,
table.createdAt,
table.billingPeriodEnd,
table.source,
table.cost
)
.where(sql`${table.billingEntityType} IS NOT NULL`),
billingScopeAllOrNone: check(
'usage_log_billing_scope_all_or_none',
sql`(
(${table.billingEntityType} IS NULL AND ${table.billingEntityId} IS NULL AND ${table.billingPeriodStart} IS NULL AND ${table.billingPeriodEnd} IS NULL)
OR
(${table.billingEntityType} IS NOT NULL AND ${table.billingEntityId} IS NOT NULL AND ${table.billingPeriodStart} IS NOT NULL AND ${table.billingPeriodEnd} IS NOT NULL AND ${table.billingPeriodStart} < ${table.billingPeriodEnd})
)`
),
workspaceCreatedAtIdx: index('usage_log_workspace_created_at_idx').on(
table.workspaceId,
table.createdAt
),
executionIdIdx: index('usage_log_execution_id_idx').on(table.executionId),
})
)
export const credentialTypeEnum = pgEnum('credential_type', [
'oauth',
'env_workspace',
'env_personal',
'service_account',
])
export const credential = pgTable(
'credential',
{
id: text('id').primaryKey(),
workspaceId: text('workspace_id')
.notNull()
.references(() => workspace.id, { onDelete: 'cascade' }),
type: credentialTypeEnum('type').notNull(),
displayName: text('display_name').notNull(),
description: text('description'),
providerId: text('provider_id'),
accountId: text('account_id').references(() => account.id, { onDelete: 'cascade' }),
envKey: text('env_key'),
envOwnerUserId: text('env_owner_user_id').references(() => user.id, { onDelete: 'cascade' }),
encryptedServiceAccountKey: text('encrypted_service_account_key'),
createdBy: text('created_by')
.notNull()
.references(() => user.id, { onDelete: 'cascade' }),
createdAt: timestamp('created_at').notNull().defaultNow(),
updatedAt: timestamp('updated_at').notNull().defaultNow(),
},
(table) => ({
workspaceIdIdx: index('credential_workspace_id_idx').on(table.workspaceId),
typeIdx: index('credential_type_idx').on(table.type),
providerIdIdx: index('credential_provider_id_idx').on(table.providerId),
accountIdIdx: index('credential_account_id_idx').on(table.accountId),
envOwnerUserIdIdx: index('credential_env_owner_user_id_idx').on(table.envOwnerUserId),
workspaceAccountUnique: uniqueIndex('credential_workspace_account_unique')
.on(table.workspaceId, table.accountId)
.where(sql`account_id IS NOT NULL`),
workspaceEnvUnique: uniqueIndex('credential_workspace_env_unique')
.on(table.workspaceId, table.type, table.envKey)
.where(sql`type = 'env_workspace'`),
workspacePersonalEnvUnique: uniqueIndex('credential_workspace_personal_env_unique')
.on(table.workspaceId, table.type, table.envKey, table.envOwnerUserId)
.where(sql`type = 'env_personal'`),
oauthSourceConstraint: check(
'credential_oauth_source_check',
sql`(type <> 'oauth') OR (account_id IS NOT NULL AND provider_id IS NOT NULL)`
),
workspaceEnvSourceConstraint: check(
'credential_workspace_env_source_check',
sql`(type <> 'env_workspace') OR (env_key IS NOT NULL AND env_owner_user_id IS NULL)`
),
personalEnvSourceConstraint: check(
'credential_personal_env_source_check',
sql`(type <> 'env_personal') OR (env_key IS NOT NULL AND env_owner_user_id IS NOT NULL)`
),
})
)
export const credentialMemberRoleEnum = pgEnum('credential_member_role', ['admin', 'member'])
export const credentialMemberStatusEnum = pgEnum('credential_member_status', [
'active',
'pending',
'revoked',
])
export const credentialMember = pgTable(
'credential_member',
{
id: text('id').primaryKey(),
credentialId: text('credential_id')
.notNull()
.references(() => credential.id, { onDelete: 'cascade' }),
userId: text('user_id')
.notNull()
.references(() => user.id, { onDelete: 'cascade' }),
role: credentialMemberRoleEnum('role').notNull().default('member'),
status: credentialMemberStatusEnum('status').notNull().default('active'),
joinedAt: timestamp('joined_at'),
invitedBy: text('invited_by').references(() => user.id, { onDelete: 'set null' }),
createdAt: timestamp('created_at').notNull().defaultNow(),
updatedAt: timestamp('updated_at').notNull().defaultNow(),
},
(table) => ({
userIdIdx: index('credential_member_user_id_idx').on(table.userId),
roleIdx: index('credential_member_role_idx').on(table.role),
statusIdx: index('credential_member_status_idx').on(table.status),
uniqueMembership: uniqueIndex('credential_member_unique').on(table.credentialId, table.userId),
})
)
export const pendingCredentialDraft = pgTable(
'pending_credential_draft',
{
id: text('id').primaryKey(),
userId: text('user_id')
.notNull()
.references(() => user.id, { onDelete: 'cascade' }),
workspaceId: text('workspace_id')
.notNull()
.references(() => workspace.id, { onDelete: 'cascade' }),
providerId: text('provider_id').notNull(),
displayName: text('display_name').notNull(),
description: text('description'),
credentialId: text('credential_id').references(() => credential.id, { onDelete: 'cascade' }),
expiresAt: timestamp('expires_at').notNull(),
createdAt: timestamp('created_at').notNull().defaultNow(),
},
(table) => ({
uniqueDraft: uniqueIndex('pending_draft_user_provider_ws').on(
table.userId,
table.providerId,
table.workspaceId
),
})
)
/**
* A named set of access-control restrictions (`config`) governing users within
* an organization.
*
* Scope invariant: the organization's single default group (`isDefault`) is
* org-wide and governs everyone not covered by another group. Every non-default
* group targets specific workspaces (rows in `permission_group_workspace`), and a
* non-default group with no rows governs nothing. Being org-wide is definitionally
* `isDefault` — there is no separate flag. Enforced by the API contracts/routes.
*
* Member invariant: a non-default group with no `permission_group_member` rows
* governs every member of its workspaces (including external members); adding
* members narrows it to only those users. The default group ignores membership.
*/
export const permissionGroup = pgTable(
'permission_group',
{
id: text('id').primaryKey(),
organizationId: text('organization_id')
.notNull()
.references(() => organization.id, { onDelete: 'cascade' }),
name: text('name').notNull(),
description: text('description'),
config: jsonb('config').notNull().default('{}'),
createdBy: text('created_by')
.notNull()
.references(() => user.id, { onDelete: 'cascade' }),
createdAt: timestamp('created_at').notNull().defaultNow(),
updatedAt: timestamp('updated_at').notNull().defaultNow(),
isDefault: boolean('is_default').notNull().default(false),
},
(table) => ({
createdByIdx: index('permission_group_created_by_idx').on(table.createdBy),
organizationNameUnique: uniqueIndex('permission_group_organization_name_unique').on(
table.organizationId,
table.name
),
defaultGroupUnique: uniqueIndex('permission_group_organization_default_unique')
.on(table.organizationId)
.where(sql`is_default = true`),
})
)
/**
* Workspaces a non-default `permission_group` targets. Rows are absent for the
* organization-wide default group; a non-default group with zero rows governs no
* workspace.
*/
export const permissionGroupWorkspace = pgTable(
'permission_group_workspace',
{
id: text('id').primaryKey(),
permissionGroupId: text('permission_group_id')
.notNull()
.references(() => permissionGroup.id, { onDelete: 'cascade' }),
workspaceId: text('workspace_id')
.notNull()
.references(() => workspace.id, { onDelete: 'cascade' }),
organizationId: text('organization_id')
.notNull()
.references(() => organization.id, { onDelete: 'cascade' }),
createdAt: timestamp('created_at').notNull().defaultNow(),
},
(table) => ({
workspaceIdIdx: index('permission_group_workspace_workspace_id_idx').on(table.workspaceId),
groupWorkspaceUnique: uniqueIndex('permission_group_workspace_group_workspace_unique').on(
table.permissionGroupId,
table.workspaceId
),
})
)
/**
* Explicit members of a `permission_group`. Membership narrows a non-default
* group to only these users; a non-default group with no rows here governs every
* member of its workspaces (including external members). The default group
* ignores these rows.
*/
export const permissionGroupMember = pgTable(
'permission_group_member',
{
id: text('id').primaryKey(),
permissionGroupId: text('permission_group_id')
.notNull()
.references(() => permissionGroup.id, { onDelete: 'cascade' }),
organizationId: text('organization_id')
.notNull()
.references(() => organization.id, { onDelete: 'cascade' }),
userId: text('user_id')
.notNull()
.references(() => user.id, { onDelete: 'cascade' }),
assignedBy: text('assigned_by').references(() => user.id, { onDelete: 'set null' }),
assignedAt: timestamp('assigned_at').notNull().defaultNow(),
},
(table) => ({
permissionGroupIdIdx: index('permission_group_member_group_id_idx').on(table.permissionGroupId),
groupUserUnique: uniqueIndex('permission_group_member_group_user_unique').on(
table.permissionGroupId,
table.userId
),
organizationUserIdx: index('permission_group_member_organization_user_idx').on(
table.organizationId,
table.userId
),
})
)
/**
* Async Jobs - Queue for background job processing (Redis/DB backends)
* Used when trigger.dev is not available for async workflow executions
*/
export const asyncJobs = pgTable(
'async_jobs',
{
id: text('id').primaryKey(),
type: text('type').notNull(),
payload: jsonb('payload').notNull(),
status: text('status').notNull().default('pending'),
createdAt: timestamp('created_at').notNull().defaultNow(),
startedAt: timestamp('started_at'),
completedAt: timestamp('completed_at'),
runAt: timestamp('run_at'),
attempts: integer('attempts').notNull().default(0),
maxAttempts: integer('max_attempts').notNull().default(3),
error: text('error'),
output: jsonb('output'),
metadata: jsonb('metadata').notNull().default('{}'),
updatedAt: timestamp('updated_at').notNull().defaultNow(),
},
(table) => ({
statusStartedAtIdx: index('async_jobs_status_started_at_idx').on(table.status, table.startedAt),
statusCompletedAtIdx: index('async_jobs_status_completed_at_idx').on(
table.status,
table.completedAt
),
schedulePendingRunAtIdx: index('async_jobs_schedule_pending_run_at_idx')
.on(table.runAt, table.createdAt, table.id)
.where(sql`${table.type} = 'schedule-execution' AND ${table.status} = 'pending'`),
scheduleProcessingStartedAtIdx: index('async_jobs_schedule_processing_started_at_idx')
.on(table.startedAt, table.id)
.where(sql`${table.type} = 'schedule-execution' AND ${table.status} = 'processing'`),
})
)
/**
* Knowledge Connector - persistent link to an external source (Confluence, Google Drive, etc.)
* that syncs documents into a knowledge base.
*/
export const knowledgeConnector = pgTable(
'knowledge_connector',
{
id: text('id').primaryKey(),
knowledgeBaseId: text('knowledge_base_id')
.notNull()
.references(() => knowledgeBase.id, { onDelete: 'cascade' }),
connectorType: text('connector_type').notNull(),
credentialId: text('credential_id'),
encryptedApiKey: text('encrypted_api_key'),
sourceConfig: json('source_config').notNull(),
syncMode: text('sync_mode').notNull().default('full'),
syncIntervalMinutes: integer('sync_interval_minutes').notNull().default(1440),
status: text('status').notNull().default('active'),
lastSyncAt: timestamp('last_sync_at'),
lastSyncError: text('last_sync_error'),
lastSyncDocCount: integer('last_sync_doc_count'),
nextSyncAt: timestamp('next_sync_at'),
consecutiveFailures: integer('consecutive_failures').notNull().default(0),
createdAt: timestamp('created_at').notNull().defaultNow(),
updatedAt: timestamp('updated_at').notNull().defaultNow(),
archivedAt: timestamp('archived_at'),
deletedAt: timestamp('deleted_at'),
},
(table) => ({
knowledgeBaseIdIdx: index('kc_knowledge_base_id_idx').on(table.knowledgeBaseId),
statusNextSyncIdx: index('kc_status_next_sync_idx').on(table.status, table.nextSyncAt),
archivedAtPartialIdx: index('kc_archived_at_partial_idx')
.on(table.archivedAt)
.where(sql`${table.archivedAt} IS NOT NULL`),
deletedAtPartialIdx: index('kc_deleted_at_partial_idx')
.on(table.deletedAt)
.where(sql`${table.deletedAt} IS NOT NULL`),
})
)
/**
* Knowledge Connector Sync Log - audit trail for connector sync operations.
*/
export const knowledgeConnectorSyncLog = pgTable(
'knowledge_connector_sync_log',
{
id: text('id').primaryKey(),
connectorId: text('connector_id')
.notNull()
.references(() => knowledgeConnector.id, { onDelete: 'cascade' }),
status: text('status').notNull(),
startedAt: timestamp('started_at').notNull().defaultNow(),
completedAt: timestamp('completed_at'),
docsAdded: integer('docs_added').notNull().default(0),
docsUpdated: integer('docs_updated').notNull().default(0),
docsDeleted: integer('docs_deleted').notNull().default(0),
docsUnchanged: integer('docs_unchanged').notNull().default(0),
docsFailed: integer('docs_failed').notNull().default(0),
errorMessage: text('error_message'),
},
(table) => ({
connectorIdIdx: index('kcsl_connector_id_idx').on(table.connectorId),
})
)
/**
* User-defined table definitions
* Stores schema and metadata for custom tables created by users
*/
export const userTableDefinitions = pgTable(
'user_table_definitions',
{
id: text('id').primaryKey(),
workspaceId: text('workspace_id')
.notNull()
.references(() => workspace.id, { onDelete: 'cascade' }),
folderId: text('folder_id').references(() => folder.id, { onDelete: 'set null' }),
name: text('name').notNull(),
description: text('description'),
/**
* @remarks
* Stores the table schema definition. Example: { columns: [{ name: string, type: string, required: boolean }] }
*/
schema: jsonb('schema').notNull(),
/**
* @remarks
* Stores UI-specific metadata separate from the data schema.
* Example: { columnWidths: { name: 200, age: 100 } }
*/
metadata: jsonb('metadata'),
maxRows: integer('max_rows').notNull().default(10000),
rowCount: integer('row_count').notNull().default(0),
/**
* @remarks
* Monotonic counter bumped by a statement-level trigger on `user_table_rows`
* (INSERT/UPDATE/DELETE). Keys the versioned table-snapshot cache so a stored
* CSV under `v{rows_version}` is reused until the table mutates. Never written
* from application code — the trigger is the only writer (bypass-proof).
*/
rowsVersion: bigint('rows_version', { mode: 'number' }).notNull().default(0),
/**
* @remarks
* Per-table mutation locks. Each guards one mutation verb; an admin toggles
* them independently. Enforced at the `lib/table` service layer (see
* `lib/table/mutation-locks.ts`), which covers every entry point — routes,
* workflow blocks, and Mothership — since all funnel through those helpers.
* A locked verb rejects with 423; toggling a lock requires workspace admin.
* Append-only = update + delete locked; read-only = all four locked. These
* are integrity controls (against accidental/agentic mutation), not
* confidentiality controls — reads and exports are never blocked.
*/
schemaLocked: boolean('schema_locked').notNull().default(false),
insertLocked: boolean('insert_locked').notNull().default(false),
updateLocked: boolean('update_locked').notNull().default(false),
deleteLocked: boolean('delete_locked').notNull().default(false),
archivedAt: timestamp('archived_at'),
createdBy: text('created_by')
.notNull()
.references(() => user.id, { onDelete: 'cascade' }),
createdAt: timestamp('created_at').notNull().defaultNow(),
updatedAt: timestamp('updated_at').notNull().defaultNow(),
},
(table) => ({
workspaceIdIdx: index('user_table_def_workspace_id_idx').on(table.workspaceId),
folderIdIdx: index('user_table_def_folder_id_idx').on(table.folderId),
workspaceNameUnique: uniqueIndex('user_table_def_workspace_name_unique')
.on(table.workspaceId, table.name)
.where(sql`${table.archivedAt} IS NULL`),
archivedAtIdx: index('user_table_def_archived_at_idx').on(table.archivedAt),
workspaceArchivedAtPartialIdx: index('user_table_def_workspace_archived_partial_idx')
.on(table.workspaceId, table.archivedAt)
.where(sql`${table.archivedAt} IS NOT NULL`),
})
)
/**
* User-defined table rows
* Stores actual row data as JSONB for flexible schema
*/
export const userTableRows = pgTable(
'user_table_rows',
{
id: text('id').primaryKey(),
tableId: text('table_id')
.notNull()
.references(() => userTableDefinitions.id, { onDelete: 'cascade' }),
workspaceId: text('workspace_id')
.notNull()
.references(() => workspace.id, { onDelete: 'cascade' }),
data: jsonb('data').notNull(),
position: integer('position').notNull().default(0),
/**
* Fractional order key (base-62 string) — the authoritative row order.
* Nullable during the backfill window. Ordered with `id` as a deterministic
* tiebreaker.
*
* Stored with `COLLATE "C"` (migration 0228) so Postgres compares it bytewise,
* matching the fractional-indexing library's ASCII ordering. drizzle can't
* express column collation, so the collation lives only in the migration.
*/
orderKey: text('order_key'),
createdAt: timestamp('created_at').notNull().defaultNow(),
updatedAt: timestamp('updated_at').notNull().defaultNow(),
createdBy: text('created_by').references(() => user.id, { onDelete: 'set null' }),
},
(table) => ({
/**
* Tenant-scoped containment index (requires the `btree_gin` extension,
* created in migration 0232). A plain GIN on `data` matches `@>` candidates
* across every tenant sharing this relation — a hot value in someone else's
* table inflates everyone's scans (measured 1.07M candidates fetched for a
* 33k-row match). Leading with `table_id` intersects inside the index, and
* `jsonb_path_ops` indexes only containment paths: rare-equality probe
* 326ms → 17ms, and the index is smaller than the one it replaces.
*/
dataGinIdx: index('user_table_rows_tenant_data_gin_idx').using(
'gin',
table.tableId,
sql`${table.data} jsonb_path_ops`
),
workspaceTableIdx: index('user_table_rows_workspace_table_idx').on(
table.workspaceId,
table.tableId
),
tablePositionIdx: index('user_table_rows_table_position_idx').on(table.tableId, table.position),
tableOrderKeyIdx: index('user_table_rows_table_order_key_idx').on(
table.tableId,
table.orderKey,
table.id
),
/**
* Keyset pagination by id within one table (the delete-job worker's page walk). Without it
* the planner scans the global pkey in id order, filtering out every other table's rows —
* O(all rows) per page.
*/
tableIdIdIdx: index('user_table_rows_table_id_id_idx').on(table.tableId, table.id),
})
)
/**
* Saved presets for a user-defined table — a named filter + sort + column layout.
* Workspace-shared: anyone who can read the table sees every view, and `write` is
* required to create, update, or delete one. The absence of a view is the built-in
* "All" state, so a table is always reachable unfiltered without a seeded row.
*
* A dedicated table rather than a key on `user_table_definitions.metadata`: that
* column is written read-modify-write with a shallow merge, so a stale snapshot
* from any concurrent column resize would silently drop a view saved in between.
* Per-view rows make each save an independent insert.
*/
export const tableViews = pgTable(
'table_views',
{
id: text('id').primaryKey(),
tableId: text('table_id')
.notNull()
.references(() => userTableDefinitions.id, { onDelete: 'cascade' }),
workspaceId: text('workspace_id')
.notNull()
.references(() => workspace.id, { onDelete: 'cascade' }),
name: text('name').notNull(),
/**
* @remarks
* `TableViewConfig` — `{ filter, sort, hiddenColumns, columnOrder, columnWidths,
* pinnedColumns }`. Every column reference is keyed by stable column id, so a
* rename never touches a saved view; ids of deleted columns are pruned on read.
*/
config: jsonb('config').notNull().default('{}'),
isDefault: boolean('is_default').notNull().default(false),
/**
* Nullable with `set null` rather than the `cascade` used for table ownership:
* a view is workspace-shared, so it must outlive the member who created it.
*/
createdBy: text('created_by').references(() => user.id, { onDelete: 'set null' }),
createdAt: timestamp('created_at').notNull().defaultNow(),
updatedAt: timestamp('updated_at').notNull().defaultNow(),
},
(table) => ({
/** Covers the only read path: list a table's views in creation order. */
tableCreatedIdx: index('table_views_table_created_idx').on(table.tableId, table.createdAt),
/** At most one default view per table, enforced in the DB. */
defaultViewUnique: uniqueIndex('table_views_table_default_unique')
.on(table.tableId)
.where(sql`is_default = true`),
})
)
/**
* Background data-mutation jobs on a user table (CSV import, bulk filtered delete). One row per
* job. A detached worker streams progress into `rows_processed` and flips `status` to a terminal
* state; cancel flips `status` to `'canceled'` and the worker bails at its next ownership check.
*
* The partial-unique index on `table_id WHERE status = 'running'` is the concurrency gate: at most
* one running job per table, so a second import, or an import + delete, can't write into the same
* table at once. Distinct from `table_run_dispatches` — that fans workflow runs across rows via
* trigger.dev; this mutates row data directly.
*/
export const tableJobs = pgTable(
'table_jobs',
{
id: text('id').primaryKey(),
tableId: text('table_id')
.notNull()
.references(() => userTableDefinitions.id, { onDelete: 'cascade' }),
workspaceId: text('workspace_id')
.notNull()
.references(() => workspace.id, { onDelete: 'cascade' }),
/** `'import'` | `'delete'`. */
type: text('type').notNull(),
/** `'running'` → `'ready'` | `'failed'` | `'canceled'`. */
status: text('status').notNull().default('running'),
/** Type-specific descriptor (e.g. delete filter/exclusions). Nullable; reserved for future
* resumability — today's workers carry their payload in-process via `runDetached`. */
payload: jsonb('payload'),
rowsProcessed: integer('rows_processed').notNull().default(0),
error: text('error'),
startedAt: timestamp('started_at').notNull().defaultNow(),
updatedAt: timestamp('updated_at').notNull().defaultNow(),
completedAt: timestamp('completed_at'),
},
(table) => ({
/** One running write-job (import/delete/backfill) per table. Exports are read-only and
* excluded, so they can run alongside any other job. */
oneActivePerTable: uniqueIndex('table_jobs_one_active_per_table')
.on(table.tableId)
.where(sql`${table.status} = 'running' AND ${table.type} <> 'export'`),
watchdogIdx: index('table_jobs_watchdog_idx').on(table.status, table.updatedAt),
tableStartedIdx: index('table_jobs_table_started_idx').on(table.tableId, table.startedAt),
})
)
/**
* Per-row workflow-group execution state. One row per (rowId, groupId) — the
* group's run metadata (status, executionId, jobId, blockErrors, etc.) for
* one row of one user-defined table.
*
* Lives in a sidecar table (not a JSONB column on `user_table_rows`) so the
* dispatcher and "X running" counter can hit `(table_id, status)` and
* `(table_id, group_id)` indexes directly instead of walking JSONB blobs, and
* so each cell-write rewrites only its own row instead of the whole
* executions object on the parent row tuple.
*/
export const tableRowExecutions = pgTable(
'table_row_executions',
{
tableId: text('table_id')
.notNull()
.references(() => userTableDefinitions.id, { onDelete: 'cascade' }),
rowId: text('row_id')
.notNull()
.references(() => userTableRows.id, { onDelete: 'cascade' }),
groupId: text('group_id').notNull(),
status: text('status').notNull(),
executionId: text('execution_id'),
jobId: text('job_id'),
workflowId: text('workflow_id').notNull(),
error: text('error'),
runningBlockIds: text('running_block_ids').array().notNull().default(sql`'{}'::text[]`),
blockErrors: jsonb('block_errors').notNull().default({}),
cancelledAt: timestamp('cancelled_at'),
/**
* Enrichment cascade breakdown (provider outcomes, cost, timing) for
* `enrichment`-type groups. Null for workflow groups and pre-feature runs.
* Deliberately excluded from the hot grid read (`loadExecutionsByRow`) — read
* on demand for the enrichment details panel.
*/
enrichmentDetails: jsonb('enrichment_details'),
updatedAt: timestamp('updated_at').notNull().defaultNow(),
},
(table) => ({
pk: primaryKey({ columns: [table.rowId, table.groupId] }),
tableStatusInFlightIdx: index('table_row_executions_table_status_idx')
.on(table.tableId, table.status)
.where(sql`${table.status} IN ('queued', 'running', 'pending')`),
executionIdIdx: index('table_row_executions_execution_id_idx')
.on(table.executionId)
.where(sql`${table.executionId} IS NOT NULL`),
tableGroupIdx: index('table_row_executions_table_group_idx').on(table.tableId, table.groupId),
})
)
/**
* One row per "Run column / Run row / Run all rows" gesture on a user table.
* The dispatcher task walks the table in row-position windows, advancing
* `cursor` as it enqueues cells into trigger.dev. Cancel flips `status` to
* `'cancelled'` in one write; the dispatcher bails at the next iteration and
* a bulk-SQL cell-cancel sweep neuters anything still in trigger.dev's queue
* (workers no-op on pickup via the cancel-sticky guard).
*/
export const tableRunDispatches = pgTable(
'table_run_dispatches',
{
id: text('id').primaryKey(),
tableId: text('table_id')
.notNull()
.references(() => userTableDefinitions.id, { onDelete: 'cascade' }),
workspaceId: text('workspace_id')
.notNull()
.references(() => workspace.id, { onDelete: 'cascade' }),
requestId: text('request_id').notNull(),
/** `'all'` re-runs completed cells; `'incomplete'` skips them. */
mode: text('mode').notNull(),
/** `{ groupIds: string[], rowIds?: string[] }` — the run's scope. */
scope: jsonb('scope').notNull(),
/** `pending` → `dispatching` → `complete` | `cancelled`. */
status: text('status').notNull().default('pending'),
/** Highest `user_table_rows.position` we've already enqueued cells for. */
cursor: integer('cursor').notNull().default(0),
/** Optional cap on how much work the dispatch does before completing.
* `{ type: 'rows', max: number }` today; the discriminated shape lets
* future caps (cells, cost, duration) extend without a schema change.
* Null = unbounded (process every row in scope). */
limit: jsonb('limit'),
/** Units of `limit.type` already consumed (eligible rows dispatched, for
* `type: 'rows'`). Mutable counter the dispatcher advances per window so
* the budget survives across the checkpointed waits between windows. */
processedCount: integer('processed_count').notNull().default(0),
/** When true, eligibility bypasses `autoRun: false` skip and treats
* terminal states as re-runnable. Auto-fire paths (row inserts,
* CSV import, addWorkflowGroup) set this to false so the dispatch
* honors the autoRun toggle. */
isManualRun: boolean('is_manual_run').notNull().default(true),
/** User who triggered the run, for per-member usage attribution. Null for
* auto-fire (row insert/update, CSV import) with no human initiator —
* those fall back to the workspace billed account. */
triggeredByUserId: text('triggered_by_user_id').references(() => user.id, {
onDelete: 'set null',
}),
requestedAt: timestamp('requested_at').notNull().defaultNow(),
completedAt: timestamp('completed_at'),
cancelledAt: timestamp('cancelled_at'),
},
(table) => ({
activeIdx: index('table_run_dispatches_active_idx').on(table.tableId, table.status),
watchdogIdx: index('table_run_dispatches_watchdog_idx').on(table.status, table.requestedAt),
})
)
export const mothershipInboxAllowedSender = pgTable(
'mothership_inbox_allowed_sender',
{
id: text('id').primaryKey(),
workspaceId: text('workspace_id')
.notNull()
.references(() => workspace.id, { onDelete: 'cascade' }),
email: text('email').notNull(),
label: text('label'),
addedBy: text('added_by')
.notNull()
.references(() => user.id, { onDelete: 'cascade' }),
createdAt: timestamp('created_at').notNull().defaultNow(),
},
(table) => ({
wsEmailIdx: uniqueIndex('inbox_sender_ws_email_idx').on(table.workspaceId, table.email),
})
)
export const mothershipInboxTask = pgTable(
'mothership_inbox_task',
{
id: text('id').primaryKey(),
workspaceId: text('workspace_id')
.notNull()
.references(() => workspace.id, { onDelete: 'cascade' }),
fromEmail: text('from_email').notNull(),
fromName: text('from_name'),
subject: text('subject').notNull(),
bodyPreview: text('body_preview'),
bodyText: text('body_text'),
bodyHtml: text('body_html'),
emailMessageId: text('email_message_id'),
inReplyTo: text('in_reply_to'),
responseMessageId: text('response_message_id'),
agentmailMessageId: text('agentmail_message_id'),
status: text('status').notNull().default('received'),
chatId: uuid('chat_id').references(() => copilotChats.id, { onDelete: 'set null' }),
triggerJobId: text('trigger_job_id'),
resultSummary: text('result_summary'),
errorMessage: text('error_message'),
rejectionReason: text('rejection_reason'),
hasAttachments: boolean('has_attachments').notNull().default(false),
ccRecipients: text('cc_recipients'),
createdAt: timestamp('created_at').notNull().defaultNow(),
processingStartedAt: timestamp('processing_started_at'),
completedAt: timestamp('completed_at'),
},
(table) => ({
wsCreatedAtIdx: index('inbox_task_ws_created_at_idx').on(table.workspaceId, table.createdAt),
wsStatusIdx: index('inbox_task_ws_status_idx').on(table.workspaceId, table.status),
responseMsgIdIdx: index('inbox_task_response_msg_id_idx').on(table.responseMessageId),
emailMsgIdIdx: index('inbox_task_email_msg_id_idx').on(table.emailMessageId),
})
)
export const mothershipInboxWebhook = pgTable('mothership_inbox_webhook', {
id: text('id').primaryKey(),
workspaceId: text('workspace_id')
.notNull()
.unique()
.references(() => workspace.id, { onDelete: 'cascade' }),
webhookId: text('webhook_id').notNull(),
secret: text('secret').notNull(),
createdAt: timestamp('created_at').notNull().defaultNow(),
})
/**
* The application code that read/wrote this table (Academy) was removed in
* the same PR that would have dropped it here — deferred to a follow-up PR
* once that removal has actually shipped, per the expand/contract migration
* safety check (`check:migrations`), since a same-deploy drop would break
* any pod still running the old code during a rolling deploy.
*/
export const academyCertStatusEnum = pgEnum('academy_cert_status', ['active', 'revoked', 'expired'])
/** Partner certification records issued on course completion */
export const academyCertificate = pgTable(
'academy_certificate',
{
id: text('id').primaryKey(),
userId: text('user_id')
.notNull()
.references(() => user.id, { onDelete: 'cascade' }),
/** References the file-based course ID from lib/academy/content */
courseId: text('course_id').notNull(),
status: academyCertStatusEnum('status').notNull().default('active'),
issuedAt: timestamp('issued_at').notNull().defaultNow(),
/** Optional expiry for recertification requirements */
expiresAt: timestamp('expires_at'),
/** Human-readable unique certificate number, e.g. SIM-2026-00042 */
certificateNumber: text('certificate_number').notNull().unique(),
/** Snapshot of name and other metadata at time of issue */
metadata: jsonb('metadata'),
createdAt: timestamp('created_at').notNull().defaultNow(),
},
(table) => ({
userIdIdx: index('academy_certificate_user_id_idx').on(table.userId),
courseIdIdx: index('academy_certificate_course_id_idx').on(table.courseId),
userCourseUnique: uniqueIndex('academy_certificate_user_course_unique').on(
table.userId,
table.courseId
),
certNumberIdx: index('academy_certificate_number_idx').on(table.certificateNumber),
statusIdx: index('academy_certificate_status_idx').on(table.status),
})
)
export const dataDrainSourceEnum = pgEnum('data_drain_source', [
'workflow_logs',
'job_logs',
'audit_logs',
'copilot_chats',
'copilot_runs',
])
export type DataDrainSource = (typeof dataDrainSourceEnum.enumValues)[number]
export const dataDrainDestinationEnum = pgEnum('data_drain_destination', [
's3',
'gcs',
'azure_blob',
'datadog',
'bigquery',
'snowflake',
'webhook',
])
export type DataDrainDestination = (typeof dataDrainDestinationEnum.enumValues)[number]
export const dataDrainCadenceEnum = pgEnum('data_drain_cadence', ['hourly', 'daily'])
export type DataDrainCadence = (typeof dataDrainCadenceEnum.enumValues)[number]
export const dataDrainRunStatusEnum = pgEnum('data_drain_run_status', [
'running',
'success',
'failed',
])
export type DataDrainRunStatus = (typeof dataDrainRunStatusEnum.enumValues)[number]
export const dataDrainRunTriggerEnum = pgEnum('data_drain_run_trigger', ['cron', 'manual'])
export type DataDrainRunTrigger = (typeof dataDrainRunTriggerEnum.enumValues)[number]
export const dataDrains = pgTable(
'data_drains',
{
id: text('id').primaryKey(),
organizationId: text('organization_id')
.notNull()
.references(() => organization.id, { onDelete: 'cascade' }),
name: text('name').notNull(),
source: dataDrainSourceEnum('source').notNull(),
destinationType: dataDrainDestinationEnum('destination_type').notNull(),
/** Non-secret destination config (bucket, region, prefix, url, ...). Validated by destination registry. */
destinationConfig: jsonb('destination_config').$type<Record<string, unknown>>().notNull(),
/** Encrypted JSON blob containing destination credentials. Never returned to clients. */
destinationCredentials: text('destination_credentials').notNull(),
scheduleCadence: dataDrainCadenceEnum('schedule_cadence').notNull(),
enabled: boolean('enabled').notNull().default(true),
/** Opaque cursor — JSON-encoded, source-defined. Advances only on overall run success. */
cursor: text('cursor'),
lastRunAt: timestamp('last_run_at'),
lastSuccessAt: timestamp('last_success_at'),
createdBy: text('created_by')
.notNull()
.references(() => user.id),
createdAt: timestamp('created_at').notNull().defaultNow(),
updatedAt: timestamp('updated_at').notNull().defaultNow(),
},
(table) => ({
orgIdx: index('data_drains_org_idx').on(table.organizationId),
dueIdx: index('data_drains_due_idx').on(table.enabled, table.lastRunAt),
orgNameUnique: uniqueIndex('data_drains_org_name_unique').on(table.organizationId, table.name),
})
)
export const dataDrainRuns = pgTable(
'data_drain_runs',
{
id: text('id').primaryKey(),
drainId: text('drain_id')
.notNull()
.references(() => dataDrains.id, { onDelete: 'cascade' }),
status: dataDrainRunStatusEnum('status').notNull(),
trigger: dataDrainRunTriggerEnum('trigger').notNull(),
startedAt: timestamp('started_at').notNull().defaultNow(),
finishedAt: timestamp('finished_at'),
rowsExported: integer('rows_exported').notNull().default(0),
bytesWritten: bigint('bytes_written', { mode: 'number' }).notNull().default(0),
cursorBefore: text('cursor_before'),
cursorAfter: text('cursor_after'),
error: text('error'),
/** Destination-specific delivery locators for this run (e.g. S3 keys, webhook response ids). */
locators: jsonb('locators').$type<string[]>().notNull().default(sql`'[]'::jsonb`),
},
(table) => ({
drainStartedIdx: index('data_drain_runs_drain_started_idx').on(table.drainId, table.startedAt),
})
)
export const sandboxLanguageEnum = pgEnum('sandbox_language', ['javascript', 'python'])
export type SandboxLanguageValue = (typeof sandboxLanguageEnum.enumValues)[number]
export const sandboxImageStatusEnum = pgEnum('sandbox_image_status', [
'pending',
'building',
'ready',
'failed',
])
export type SandboxImageStatusValue = (typeof sandboxImageStatusEnum.enumValues)[number]
/**
* A workspace's named library of dependency sets. Provider-agnostic: the same
* row drives a prebuilt E2B template and a Daytona runtime install, and only the
* materialization step differs. `specHash` is the content address shared with
* `sandboxImage`, so editing dependencies points the sandbox at a new build
* while the old one stays valid for in-flight executions.
*/
export const workspaceSandbox = pgTable(
'workspace_sandbox',
{
id: text('id').primaryKey(),
workspaceId: text('workspace_id')
.notNull()
.references(() => workspace.id, { onDelete: 'cascade' }),
name: text('name').notNull(),
language: sandboxLanguageEnum('language').notNull(),
dependencies: jsonb('dependencies').$type<string[]>().notNull().default(sql`'[]'::jsonb`),
specHash: text('spec_hash').notNull(),
createdBy: text('created_by').references(() => user.id, { onDelete: 'set null' }),
createdAt: timestamp('created_at').notNull().defaultNow(),
updatedAt: timestamp('updated_at').notNull().defaultNow(),
},
(table) => ({
workspaceNameUnique: uniqueIndex('workspace_sandbox_workspace_name_unique').on(
table.workspaceId,
table.name
),
workspaceIdx: index('workspace_sandbox_workspace_idx').on(table.workspaceId),
specHashIdx: index('workspace_sandbox_spec_hash_idx').on(table.specHash),
})
)
/**
* Build registry for the prebuilt strategy, keyed by content address so two
* workspaces declaring the same dependency set share one build. Never written
* under a runtime-strategy provider.
*/
export const sandboxImage = pgTable(
'sandbox_image',
{
id: text('id').primaryKey(),
provider: text('provider').notNull(),
specHash: text('spec_hash').notNull(),
spec: jsonb('spec').notNull(),
status: sandboxImageStatusEnum('status').notNull().default('pending'),
/** Passed to the provider at create time once `status` is `ready`. */
imageRef: text('image_ref'),
/** Provider-side image identifier, when it differs from `imageRef`. */
providerImageId: text('provider_image_id'),
buildId: text('build_id'),
/** Classified taxonomy code; see lib/execution/remote-sandbox/build-errors.ts. */
errorCode: text('error_code'),
/** User-facing copy rendered from the code at classification time. */
errorMessage: text('error_message'),
/** Installer log tail, shown behind a disclosure. */
errorDetail: text('error_detail'),
lastUsedAt: timestamp('last_used_at'),
createdAt: timestamp('created_at').notNull().defaultNow(),
updatedAt: timestamp('updated_at').notNull().defaultNow(),
},
(table) => ({
providerSpecUnique: uniqueIndex('sandbox_image_provider_spec_unique').on(
table.provider,
table.specHash
),
statusIdx: index('sandbox_image_status_idx').on(table.status),
lastUsedIdx: index('sandbox_image_last_used_idx').on(table.lastUsedAt),
})
)