# From 0.46 to 0.47

> What to check when upgrading from better-supabase 0.46 to 0.47. Block entity keys equal the entity's singular, subject types, hint codes, function names and module tables take the new nouns, and every event type names its entity and a past verb. AI document versions, policy acknowledgements and sequence runs move onto the versions, acknowledgements and enrollments blocks, inbox contacts become people of the contacts block, postmortem action items become tasks, and doctor adds BS423 and BS424.

Source: https://bettersupabase.com/docs/migration/0.46-to-0.47

0.47.0 finishes the naming convention (ADR 0130): the key of every entity in
`blocks.<name>.entities` is the entity's singular, the subject types, hint
codes, function names and module tables follow the new nouns, and each event
type is an entity and a past verb. Run `better-supabase codemod 0.47` and check
what it lists for review. Old entity keys and the old subject types keep working until
0.48, with a warning. The outbox stores the new event types, so subscribers to
an old type miss its events until you rename them; receivers outside the app
can keep the old names for one minor with the outbox `legacyEventTypes`
option. AI document versions move onto the versions block, policy
acknowledgements onto the acknowledgements block and sequence runs onto
enrollments, inbox contacts become people of the contacts block, and with
the tasks block installed, postmortem action items become tasks. Everything this guide calls deprecated is listed in
[Removed in 0.48](#removed-in-048).

| Section                                             | Needs code                                          |
| --------------------------------------------------- | --------------------------------------------------- |
| [Entity keys](#entity-keys)                         | Run `codemod 0.47`                                  |
| [Subject types](#subject-types)                     | Run `upgrade`; rename stored types outside SQL      |
| [Permission keys](#permission-keys)                 | Run `codemod 0.46` if you skipped it                |
| [Hint codes](#hint-codes)                           | Run `codemod 0.47`, then check `ACTIVITY_FORBIDDEN` |
| [Function names](#function-names)                   | Rename calls in your own SQL                        |
| [Event types](#event-types)                         | Run `codemod 0.47`, then check `saved` types        |
| [AI document versions](#ai-document-versions)       | Run `upgrade`; copy a renamed table by hand         |
| [Postmortem action items](#postmortem-action-items) | Use `addPostmortemAction` (needs `tasks`)           |
| [Policy acknowledgements](#policy-acknowledgements) | Call the acknowledgements block                     |
| [Sequence runs](#sequence-runs)                     | Rename SQL that reads `sequence_enrollments`        |
| [Inbox contacts](#inbox-contacts)                   | Install `contacts`; call its identity methods       |
| [Tax codes](#tax-codes)                             | Install `tax`; call its code methods                |
| [General ledger](#general-ledger)                   | Pass the new arguments in your own SQL calls        |
| [Import runs](#import-runs)                         | Rename `kind` to `source`; use `runs.start`         |
| [Table names](#table-names)                         | Rename queries on the old tables in your own code   |
| [The SQL modules](#the-sql-modules)                 | Run `upgrade` and generate a migration              |
| [Removed in 0.48](#removed-in-048)                  | Move off each item before 0.48                      |

## Entity keys [#entity-keys]

A key in `blocks.<name>.entities` now equals the entity's singular, so a
config that renames the tasks entity writes `entities: { task: ... }`
instead of `entities: { work_item: ... }`. Run the codemod; it renames the
keys in `better-supabase.config.ts` and in a JSON config:

```sh
npx better-supabase@latest codemod 0.47
```

| Block             | 0.46 key     | 0.47 key              |
| ----------------- | ------------ | --------------------- |
| `access-requests` | `request`    | `access_request`      |
| `activities`      | `engagement` | `activity`            |
| `ai-chat`         | `chat`       | `ai_chat`             |
| `ai-chat`         | `project`    | `ai_project`          |
| `ai-files`        | `document`   | `ai_document`         |
| `ai-schedules`    | `task`       | `ai_scheduled_task`   |
| `api-keys`        | `key`        | `api_key`             |
| `automations`     | `rule`       | `automation`          |
| `collections`     | `collection` | `subject_collection`  |
| `compliance`      | `policy`     | `compliance_policy`   |
| `connectors`      | `server`     | `connector_server`    |
| `contracts`       | `obligation` | `contract_obligation` |
| `custom-fields`   | `field`      | `field_definition`    |
| `drive`           | `node`       | `drive_node`          |
| `forecasts`       | `snapshot`   | `forecast_snapshot`   |
| `goals`           | `target`     | `goal`                |
| `hiring`          | `job`        | `job_opening`         |
| `incidents`       | `postmortem` | `incident_postmortem` |
| `inventory`       | `location`   | `inventory_location`  |
| `knowledge`       | `document`   | `knowledge_document`  |
| `leave`           | `request`    | `leave_request`       |
| `leave`           | `type`       | `leave_type`          |
| `saved-views`     | `view`       | `saved_view`          |
| `share-links`     | `link`       | `share_link`          |
| `signatures`      | `request`    | `signature_request`   |
| `sla`             | `policy`     | `sla_policy`          |
| `tasks`           | `work_item`  | `task`                |
| `time-tracking`   | `entry`      | `time_entry`          |
| `timeline`        | `entry`      | `timeline_entry`      |
| `waitlist`        | `entry`      | `waitlist_entry`      |
| `webhooks-out`    | `endpoint`   | `webhook_endpoint`    |
| `workflow-editor` | `definition` | `workflow_definition` |

The full list is `ENTITY_KEY_RENAMES` in `better-supabase`. An old key still
resolves, with a warning, and doctor reports it
([BS423](/docs/cli/doctor#bs423)). An entry that sets both the old and the new
key is listed for review instead of renamed. The tasks binding
`workItemChecklistBinding` is now `taskChecklistBinding`; the codemod moves
the import.

An install that names an entity keeps its tables and subject type. An install
that doesn't keeps its tables too: the tasks block still writes `work_items`.
Its events are renamed (see [Event types](#event-types)).

## Subject types [#subject-types]

Three block subjects take the new noun as their type:

| 0.46         | 0.47                |
| ------------ | ------------------- |
| `work_item`  | `task`              |
| `engagement` | `activity`          |
| `policy`     | `compliance_policy` |

`upgrade` renders a data step for each module that moves the stored rows:
every `subject_type`, `source_type` and `target_type` column of the installed
blocks (relations, comments, attachments, the timeline and the rest) that
holds the old type gets the new one. The audit log and the ledger, accounting
and billable time entries are append-only and keep the type they recorded.
The step skips a type when the install
names the entity, and skips `policy` when your `config.subjects` declares a
`policy` subject of its own. It can run again without changing anything.

If one of your own tables is already a `task`, `activity` or
`compliance_policy` subject (a `@bsb subject` tag or a `config.subjects`
entry), the CLI stops with an error naming the table. Keep the block on its
old noun, which also keeps its subject type and function names:

```ts title="better-supabase.config.ts"
tasks: { names: { task: "work_item" } },
```

Until 0.48 the subject functions (`subject_title`, `can_subject` and the rest)
still accept the old type. Rename the types your code passes, such as
`{ type: "work_item", id }`; the codemod lists them for review. Rename them
in `blocks.<name>.on` too (`on: { compliance_policy: ... }`); doctor reports an
`on` type that is no record type (BS346). Rename types you store outside
better-supabase tables yourself.

## Permission keys [#permission-keys]

0.46 named every permission area after its block (`engagements.read` became
`activities.read`, `parties.write` became `contacts.write`); the
[0.45 to 0.46 guide](/docs/migration/0.45-to-0.46#permission-keys) has the
details. In 0.47 a role in `blocks.permissions.roles` that still grants a
0.46 key also grants its new key, with a warning, and doctor reports the old
key ([BS422](/docs/cli/doctor#bs422)). That fallback goes in 0.48, so run the
0.46 codemod if you skipped it:

```sh
npx better-supabase@latest codemod 0.46
```

0.47 adds one rename: `compliance.acknowledge` is now
`acknowledgements.acknowledge`, because policy acknowledgements moved onto the
acknowledgements block (see [Policy acknowledgements](#policy-acknowledgements)).
`PERMISSION_KEY_RENAMES` maps it with the 0.46 keys.

## Hint codes [#hint-codes]

Hint codes follow the entity's new noun: `WORK_ITEM_NOT_FOUND` becomes
`TASK_NOT_FOUND`, `ENGAGEMENT_FORBIDDEN` becomes `ACTIVITY_FORBIDDEN` and
`RULE_CONFLICT` becomes `AUTOMATION_CONFLICT`. The codemod renames the quoted
codes in your code and SQL. The full list is `HINT_RENAMES`, and
`legacyHintOf(hint)` returns a hint's 0.46 code for code that compares stored
errors. Until 0.48 a `DbError` with a renamed hint also carries the old code
in `legacyHint`.

`ACTIVITY_FORBIDDEN` was the timeline's code and is now the activities
block's. The timeline uses `TIMELINE_ENTRY_FORBIDDEN`. The codemod lists every
`ACTIVITY_FORBIDDEN` for review instead of renaming it.

## Function names [#function-names]

Block functions take the entity's new noun. The client methods don't change;
rename the calls in your own SQL:

| Block        | 0.46 noun in the name | 0.47 noun             | Example                                                    |
| ------------ | --------------------- | --------------------- | ---------------------------------------------------------- |
| `tasks`      | `work_item`           | `task`                | `create_work_item` becomes `create_task`                   |
| `activities` | `engagement`          | `activity`            | `log_engagement` becomes `log_activity`                    |
| `goals`      | `target`              | `goal`                | `save_target` becomes `save_goal`                          |
| `compliance` | `policy`              | `compliance_policy`   | `publish_policy` becomes `publish_compliance_policy`       |
| `contracts`  | `obligation`          | `contract_obligation` | `save_obligation` becomes `save_contract_obligation`       |
| `incidents`  | `postmortem`          | `incident_postmortem` | `publish_postmortem` becomes `publish_incident_postmortem` |

A key in `blocks.<name>.functions` that names an old function still applies
to the new one.

## Event types [#event-types]

Every event type is now `<entity>.<past verb>`: the prefix is the entity the
event is about, and a `saved` event is `created` or `updated`, depending on
whether the save inserted the row. Run the codemod. It renames the types in
strings in your code and config and in quoted types in your own SQL:

| 0.46                                                                        | 0.47                                                                                    |
| --------------------------------------------------------------------------- | --------------------------------------------------------------------------------------- |
| `work_item.*`                                                               | `task.*`                                                                                |
| `engagement.*`                                                              | `activity.*`                                                                            |
| `rule.*`, `rule.removed`                                                    | `automation.*`, `automation.deleted`                                                    |
| `access.requested`, `access.request_approved`, `access.ended`               | `access_request.requested`, `access_request.approved`, `access_request.ended`           |
| `custom_field.*`                                                            | `field_definition.*`                                                                    |
| `drive.node_created` and the other `drive.node_*` events                    | `drive_node.created` and so on                                                          |
| `forecast.snapshotted`                                                      | `forecast_snapshot.created`                                                             |
| `job.*` (hiring)                                                            | `job_opening.*`                                                                         |
| `leave.adjusted`                                                            | `leave_balance.adjusted`                                                                |
| `leave.requested` and the other `leave.*` events                            | `leave_request.requested` and so on                                                     |
| `obligation.*`                                                              | `contract_obligation.*`                                                                 |
| `organization.domain_added`, `organization.domain_removed`                  | `sso_domain.created`, `sso_domain.deleted`                                              |
| `organization.domain_updated`, `organization.domain_verified`               | `sso_domain.updated`, `sso_domain.verified`                                             |
| `policy.*` (compliance)                                                     | `compliance_policy.*`                                                                   |
| `postmortem.*`                                                              | `incident_postmortem.*`                                                                 |
| `signature.*`                                                               | `signature_request.*`                                                                   |
| `support.*`                                                                 | `support_session.*`                                                                     |
| `time.invoiced`                                                             | `time_entry.invoiced`                                                                   |
| `variant.*`                                                                 | `product_variant.*`                                                                     |
| `waitlist.*`                                                                | `waitlist_entry.*`                                                                      |
| `webhook.*`                                                                 | `webhook_endpoint.*`                                                                    |
| `workflow.published`                                                        | `workflow_definition.published`                                                         |
| `segment.removed`, `hostname.removed`, `approval_policy.removed`            | `segment.deleted`, `hostname.deleted`, `approval_policy.deleted`                        |
| `marketplace_installation.removed`                                          | `marketplace_installation.deleted`                                                      |
| `credential.set`, `ai_tool_policy.set`                                      | `credential.updated`, `ai_tool_policy.updated`                                          |
| `flag.override_set`, `hostname.primary_set`, `incoming_webhook.enabled_set` | `flag.override_changed`, `hostname.primary_changed`, `incoming_webhook.enabled_changed` |
| `marketplace_invoice.state_changed`                                         | `marketplace_invoice.status_changed`                                                    |
| `<entity>.saved`, `marketplace_installation.upserted`                       | `<entity>.created` or `<entity>.updated`                                                |
| `party.enriched`                                                            | `company.enriched` or `person.enriched`                                                 |

The `saved` events were `agent`, `ai_provider_key`, `announcement`,
`approval_policy`, `connector`, `control`, `course`, `custom_field` (now
`field_definition`), `enrollment_program`, `flag`, `obligation` (now
`contract_obligation`), `pipeline_stage`, `post_board`, `rule` (now
`automation`), `saved_view`, `scim_group`, `scim_user`, `score_model`,
`segment` and `sequence`. The `sla.*` events keep their names: they are about
a record's SLA clock, and no table of their own fits. Relation events such as
`collection.entry_removed` keep `removed`, and `field_value.set` keeps `set`.
Notification types, which are their own names, don't change: the tasks
block still sends `work_item.assigned` notifications.

An install that names an entity names its events after that name, as in
0.46: with `entities: { task: "ticket" }` the tasks block emits
`ticket.created`, and the data step leaves those rows alone.

The full list is `EVENT_TYPE_RENAMES` and `EVENT_TYPE_SPLITS` from
`better-supabase`, with `isOldEventType(type)`, `currentEventTypes(type)` and
`legacyEventType(type)` for code that matches types stored outside
better-supabase.

The codemod lists some strings for review instead of guessing:

- A split type, such as `agent.saved`: subscribe to `agent.created`,
  `agent.updated` or both.
- `support.*` and `workflow.*`, which were permission wildcards before 0.46
  too. As event patterns, `support.*` is now `support_session.*` and
  `workflow.*` is `workflow_definition.*`.
- `leave.*` and `waitlist.*`, which are current permission wildcards. As event
  patterns they are now `leave_request.*` plus `leave_balance.*`, and
  `waitlist_entry.*`.
- `drive.*` and `organization.*`, which still match some events: the node
  events moved to `drive_node.*` and the SSO domain events to `sso_domain.*`.

Doctor reports each old type it finds in your code
([BS424](/docs/cli/doctor#bs424)).

The outbox stores the new types. `upgrade` renders a data step that renames the
stored types one to one: the outbox events and dead letters, automation
triggers and timeline entries. For the type lists of outbox consumers and
webhook endpoints it adds the new types and keeps the old ones, so a consumer
of `agent.saved` gets `agent.created` and `agent.updated`. An automation
whose trigger is a split type keeps it, and the rule sink also tests it
against the events the old type became.

Receivers outside the app, such as a service that reads the relayed
CloudEvents or a webhook endpoint, can keep the old names for one minor with
`legacyEventTypes`. The relay then sends each renamed type under its old name,
with the new one in the `currenttype` extension, which the library's sinks
read:

```ts
const outbox = createOutbox({
  transport,
  source: "https://crm.example.com",
  legacyEventTypes: true,
});
```

The option is deprecated and goes away in the next minor, so move the
receivers to the new types first.

## AI document versions [#ai-document-versions]

AI documents keep their versions in the [versions](/docs/blocks/versions)
block, so `ai-files` now requires `versions`. Each version is a `versions` row
with `subject_type = 'ai_document'`, the document id as `subject_id`, and the
text and the message that wrote it in `content` (`{ text, messageId }`).
`better-supabase upgrade` writes a migration that creates the versions table
when it is missing, copies the rows of `ai_document_versions` and drops that
table. The client is unchanged: `documents.versions(documentId)` and
`documents.rollback(documentId, version)` return the same shapes.

Three things behave differently. An edit that leaves the text unchanged writes
no version. A document keeps its latest 50 versions, the versions block's
retention. Document versions stay private to ai-files: `list_versions` and the
other versions functions refuse `ai_document` for members, and RLS hides those
rows, so only the ai-files functions read them.

SQL that read the old table reads the versions table:

```sql
-- 0.46
select version, content, created_by_message_id
from better_supabase.ai_document_versions
where document_id = $1;

-- 0.47
select number, content ->> 'text', content ->> 'messageId'
from better_supabase.versions
where subject_type = 'ai_document' and subject_id = $1::text;
```

The `blocks["ai-files"].tables.versions` key is gone. If you renamed or adopted
`ai_document_versions`, the generated migration doesn't find it: copy its rows
into the versions table yourself before you drop it. `codemod 0.47` lists the
lines that name `ai_document_versions`.

## Postmortem action items [#postmortem-action-items]

With the [tasks](/docs/blocks/tasks) block installed, postmortem action items
are tasks. `addPostmortemAction(postmortemId, { title, assigneeId, dueAt })`
creates one and links it in the new `incident_postmortem_actions` table,
`removePostmortemAction(actionId)` removes the link, and a postmortem's
`actions` lists the linked tasks. `savePostmortem` no longer takes
`actionItems`, and a postmortem has no `actionItems` field.

```ts
// 0.46
await db.incidents.savePostmortem(incidentId, {
  actionItems: [{ title: "Add a disk alert" }],
});

// 0.47 (needs the tasks block)
const postmortem = (
  await db.incidents.savePostmortem(incidentId, {})
).orThrow();
await db.incidents.addPostmortemAction(postmortem.id, {
  title: "Add a disk alert",
  assigneeId,
});
```

The `action_items` column is gone. `upgrade` writes a step that copies each
item of an existing postmortem into a task through
`add_incident_postmortem_action`, then drops the column, before the schema diff
runs (ADR 0149), so the diff never drops a column that holds data. Install
`tasks` first. The step stops with an error, instead of dropping the column,
when it holds items and the tasks block can't take them: add `tasks`, or clear
the column, then run `upgrade` again. `codemod 0.47` lists the lines that use
`actionItems`.

## Policy acknowledgements [#policy-acknowledgements]

The compliance block now requires the
[acknowledgements block](/docs/blocks/acknowledgements) and keeps its policy
acknowledgements there (ADR 0131). `upgrade` installs it with compliance, and
the data step copies every `policy_acknowledgements` row into
`acknowledgements` as a `compliance_policy` acknowledgement of the same
version, assigns every live policy that requires acknowledgement to every
member (the role principal `*`, so owners and admins are included), and drops `policy_acknowledgements`. Saving a policy that
requires acknowledgement assigns it the same way.

| 0.46                                                           | 0.47                                                         |
| -------------------------------------------------------------- | ------------------------------------------------------------ |
| `acknowledge_compliance_policy(policy_id)`                     | `acknowledge('compliance_policy', policy_id::text)`          |
| `list_compliance_policies(tenant, ..., pending_only)`          | `list_my_acknowledgements(tenant)`                           |
| `compliance_policy_acknowledgements(policy_id)`                | `acknowledgement_status` and `acknowledgements_of`           |
| `policy_acknowledgements` table                                | `acknowledgements`, `subject_type = 'compliance_policy'`     |
| `compliance.acknowledge` permission                            | `acknowledgements.acknowledge`                               |
| `policy.acknowledged`, `compliance_policy.acknowledged` events | `acknowledgement.created`                                    |
| `POLICY_NOT_PUBLISHED` hint                                    | `ACKNOWLEDGEMENT_NO_VERSION`, `ACKNOWLEDGEMENT_NOT_ASSIGNED` |

The client methods `acknowledgePolicy`, `acknowledgements(policyId)` and
`policies(organizationId, { pendingOnly })` are removed in 0.47. Use
`db.acknowledgements.acknowledge`, `mine` and `status`. A role that granted
`compliance.acknowledge` grants `acknowledgements.acknowledge`
(`PERMISSION_KEY_RENAMES`), and `EVENT_TYPE_RENAMES` maps the old event. The
codemod lists the old calls, the table, the hint and the event for review.

## Sequence runs [#sequence-runs]

A sequence run is now an [enrollment](/docs/blocks/enrollments) in the
program `sequence:<sequence id>` (ADR 0131), and the `sequence_enrollments`
table is gone. The sequences client doesn't change: `enroll`, `pause`,
`resume`, `exit` and `enrollments` take and return the same runs. SQL that
reads the table reads `enrollments` instead:

```sql
-- 0.46
select address, status, step, next_run_at
from better_supabase.sequence_enrollments
where sequence_id = $1;

-- 0.47
select x.sequence_address, x.status, x.paused_at, x.cancel_reason, s.step, s.due_at
from better_supabase.enrollments x
left join better_supabase.enrollment_steps s
  on s.enrollment_id = x.id and s.completed_at is null
where x.sequence_id = $1;
```

The run's columns move onto `enrollments` with a `sequence_` prefix
(`address` is `sequence_address`, `mail_thread_id` is
`sequence_mail_thread_id`, and so on), the next step is the open
`enrollment_steps` row with `due_at`, a paused run has `paused_at`, and an
exited run is `cancelled` with the reason in `cancel_reason`. `upgrade`
renders a data step that copies each `sequence_enrollments` row onto its
enrollment and its next step, then drops the table. A `members` table you
adopted for sequences in 0.46 isn't moved; copy its rows the same way before
you drop it. Doctor reports `sequence_enrollments` in your code (BS424) and an
adopted `members` table in `blocks.sequences.tables` (BS425), and the codemod
lists the same key for review.

The run events move too: a run writes the enrollment events with `program`
set to `sequence:<sequence id>`.

| 0.46                                                   | 0.47                                                     |
| ------------------------------------------------------ | -------------------------------------------------------- |
| `sequence_enrollment.created`, `.step_completed`       | `enrollment.created`, `.step_completed`                  |
| `sequence_enrollment.paused`, `.resumed`, `.completed` | `enrollment.paused`, `.resumed`, `.completed`            |
| `sequence_enrollment.exited`                           | `enrollment.cancelled`, with the exit reason in `reason` |

Other programs, such as onboarding checklists and courses, emit the same
events, so the codemod lists these types for review instead of renaming
them: subscribe to the new type and check `data.program`. The data step
renames the stored types and adds the new ones to the type lists of outbox
consumers and webhook endpoints, which then also receive the other programs'
events. A subscription is a list of type patterns with no payload filter, so it
can't keep only the `sequence:` programs: check `data.program` in the receiver.
Until 0.48, `legacyEventTypes` relays a run event under its old name,
and `legacyEventType(type, data)` returns it for a payload whose `program`
starts with `sequence:`. `SequenceEnrollmentEventData` is a deprecated alias
of `EnrollmentEventData` until then. `PROGRAM_EVENT_MOVES` (`better-supabase`)
lists the moves.

## Inbox contacts [#inbox-contacts]

The inbox now takes its contacts from the [contacts](/docs/blocks/contacts)
block (ADR 0131): a conversation's contact is a person, and the inbox
`contacts` and `contact_identities` tables and the contacts block's
`person_inbox_contacts` link are gone. The inbox requires `contacts`, so add
it before you upgrade:

```sh
npx better-supabase@latest add contacts
npx better-supabase@latest upgrade
```

`upgrade` renders a data step that turns each inbox contact into a person:
the person it was linked to, else the active person with the same email,
else a new person with the contact's id, so `contactId` values you stored
keep pointing at the same contact. It fills the person's user, phone and
avatar when they are empty, copies `contact_identities` into
`person_identities`, points conversations and identity links at the person,
then drops the old tables. An inbox `contacts` table you adopted in 0.46
isn't moved; copy its rows into `people` the same way before you drop it.

The conversation API keeps its shape: `contactId` is the person's id, and
`conversations.open` and the Chat SDK adapters find or create the person by
identity, user or email. Code that linked contacts to people calls the
identity methods instead:

```ts
// 0.46
const person = await db.contacts
  .personForInboxContact(conversation.contactId, { create: true })
  .orThrow();
await db.contacts.linkInboxContact(personId, contactId).orThrow();

// 0.47
const person = await db.contacts
  .findByIdentity(organizationId, "whatsapp", "+31612345678")
  .orThrow();
await db.contacts
  .addIdentity(personId, { channel: "whatsapp", address: "+31612345678" })
  .orThrow();
```

| 0.46                                                              | 0.47                                                        |
| ----------------------------------------------------------------- | ----------------------------------------------------------- |
| `contacts` (inbox)                                                | `people`, with `avatar_url` and `metadata`                  |
| `contact_identities` (`contact_id`, `external_id`)                | `person_identities` (`person_id`, `address`, `verified_at`) |
| `person_inbox_contacts`                                           | none: the conversation's `contact_id` is the person         |
| `personForInboxContact`, `linkInboxContact`, `unlinkInboxContact` | `findByIdentity`, `addIdentity`, `removeIdentity`           |
| `inbox.contacts.upsert` and `upsert_contact` (removed)            | `contacts.savePerson` and `addIdentity`                     |

A person has no user lifecycle of its own, so deleting a user no longer
erases their inbox contact: call `purgeContact(personId)` for a data subject
request, which deletes the person, their identities and their
conversations. The codemod lists the old calls and tables for review, and
doctor reports the removed methods.

## Tax codes [#tax-codes]

Tax codes moved out of accounting into the new [tax block](/docs/blocks/tax),
which adds dated rates, return boxes, tax returns and the intra-EU sales
list. The `accounting_tax_codes` table is gone and `accounting.tax_report`
reads from `tax_codes`. Accounting now requires `tax` instead of working
without it, so `upgrade` installs it next to accounting and the check that
keeps accounting running without `tax` doesn't exist.

| 0.46                                  | 0.47                                              |
| ------------------------------------- | ------------------------------------------------- |
| `accounting.saveTaxCode(...)`         | `tax.saveCode({ ..., rateBps, type })`            |
| `accounting.taxCodes(organizationId)` | `tax.codes()`                                     |
| `save_accounting_tax_code`            | `save_tax_code`                                   |
| `list_accounting_tax_codes`           | `list_tax_codes`                                  |
| `accounting_tax_codes.kind`           | `tax_codes.type` (`sales` or `purchase`)          |
| `accounting_tax_codes.rate_bps`       | a row in `tax_rates` valid from the start of time |
| `accounting_tax_codes.report_boxes`   | `tax_codes.return_boxes`                          |
| `accounting_tax_codes.account_id`     | `sales_account_id` or `purchase_account_id`       |

`codemod 0.47` lists the lines that use the old table, functions and
methods. After `upgrade` and `db diff`, add this data step to the generated
migration before it drops `accounting_tax_codes`. It keeps every id, so the
`tax_code_id` of existing accounting lines stays valid:

```sql
alter table better_supabase.accounting_entry_lines
  drop constraint if exists accounting_entry_lines_tax_code_id_fkey;

insert into better_supabase.tax_codes
  (id, tenant_id, code, name, type, sales_account_id, purchase_account_id,
   return_boxes, archived_at, created_at, updated_at)
select id, tenant_id, code, name, kind,
  case when kind = 'sales' then account_id end,
  case when kind = 'purchase' then account_id end,
  report_boxes, archived_at, created_at, updated_at
from better_supabase.accounting_tax_codes;

insert into better_supabase.tax_rates
  (tenant_id, tax_code_id, rate_bps, valid_from)
select tenant_id, id, rate_bps, date '1970-01-01'
from better_supabase.accounting_tax_codes;

alter table better_supabase.accounting_entry_lines
  add constraint accounting_entry_lines_tax_code_id_fkey
    foreign key (tax_code_id) references better_supabase.tax_codes (id)
    on delete restrict;

drop table better_supabase.accounting_tax_codes;
```

Invoice lines gain optional `tax_code_id` and `unit_cost` columns. An existing
caller that sets only `tax_code` and `tax_rate_bps` keeps working: `issue_invoice`
resolves the code by its text and falls back to the line's rate when no code
has that name. Quote lines keep `tax_code` and `tax_rate_bps` only.

## General ledger [#general-ledger]

The `accounting` block gains account levels, locked periods, opening balances
and ledger reports (ADR 0144). Nothing changes for the TypeScript client
except that an account now reads `normalSide`, `level` and `active`, a journal
reads `contraAccountId` and `bankAccountId`, and a period can have the status
`locked`. In SQL, `save_accounting_account` takes `normal_side`,
`default_tax_code_id` and `active`, and `save_accounting_journal` takes
`contra_account_id` and `bank_account_id`; the old signatures are dropped, so
add the new arguments (they default to null) to calls in your own functions,
then run `better-supabase upgrade`.

## Import runs [#import-runs]

Imports keep an external record map (ADR 0141), and the run table changes
shape, so this is a breaking change:

| 0.46                                  | 0.47                                                                            |
| ------------------------------------- | ------------------------------------------------------------------------------- |
| `import_runs.kind`                    | `import_runs.source`                                                            |
| `start_import(tenant, kind, mapping)` | `start_import(tenant, source, mapping)`, or `start_import_run`                  |
| `ImportDefinition.kind`               | `ImportDefinition.source`                                                       |
| `counts`: `{ imported: n }`           | `counts`: `{ <entity>: { inserted, updated, unchanged, conflicted, missing } }` |
| `errors`: a list of messages          | `errors`: `[{ entity, key, code, message }]`                                    |
| `import.completed` payload `kind`     | `import.completed` payload `id`, `count`                                        |

`upgrade` renames the column on `import_runs` and adds `canceled` to the
status, the `dry_run` and `full` flags, `input_checksum`, `correlation_id`,
`scope_aliases`, `started_at` and `finished_at`, and creates `import_records`.
A table you adopted for `tables.runs` gets the new columns added, and its
`kind` column is renamed by the generated `better_supabase_block_upgrade`
migration. Only one run per tenant and source can be `pending` or `running`;
close or cancel stuck runs before you upgrade. The client's `start(tenant,
source, mapping)` keeps working, and `imports.runs.start`, `upsertBatch`,
`resolve` and `finish` are new (see [imports](/docs/blocks/imports)). The
codemod lists code that names `import_runs`, `start_import`, `defineImport`
or `appTableImport` so you can rename `kind` by hand.

## Custom field entities [#custom-field-entities]

Field definitions name a registered subject type (ADR 0156), so this is a
breaking change:

| 0.46                                                          | 0.47                                                                                       |
| ------------------------------------------------------------- | ------------------------------------------------------------------------------------------ |
| `field_definitions.entity` is any text                        | `entity` is a key of `config.subjects` or a type an installed block registers              |
| `save_field_definition` accepts every entity                  | it fails with `FIELD_ENTITY_UNKNOWN` (`22023`) for an unregistered one                     |
| `read_field_values(tenant, subject_type, subject_id)`         | the same, plus `legal_entity uuid default null` when the legal-entities block is installed |
| `set_field_value(tenant, subject_type, subject_id, def, val)` | the same, plus `legal_entity uuid default null` with the legal-entities block              |

`upgrade` adds `pack` and `pack_version` to `field_definitions` and creates
`install_field_pack(tenant, pack jsonb)`. With the legal-entities block,
`field_values` gains a nullable `legal_entity_id`, and the unique key on the
tenant, subject type, subject id and definition gains the entity, so the
organization and each entity keep one value per field. An app without
registered subject types keeps free-text entities.

Rename the stored entities before the upgrade by mapping each to a registered
type. The upgrade renames the definitions and the value rows that follow them:

```ts title="better-supabase.config.ts"
blocks: {
  "custom-fields": { options: { entityMap: { invoice: "sales_invoice" } } },
},
```

A definition whose new name already has the same key in the organization is
left as it was. The codemod lists calls of `saveDefinition`,
`listDefinitions` and `save_field_definition` that name a literal entity, so
you can check each against the registered types. `better_supabase.subject_types()`
returns the registered names. Field packs, per-entity values and the
`field_value` import target are new (see [custom fields](/docs/blocks/custom-fields)).

## Attachment document types [#attachment-document-types]

Attachments carry a document type, a source and an external id (ADR 0161), so
`create_attachment` changes signature, and this is a breaking change:

| 0.46                                  | 0.47                                                                                     |
| ------------------------------------- | ---------------------------------------------------------------------------------------- |
| `create_attachment(..., pages)`       | the same, plus `document_type`, `source`, `external_id` and `source_path` (all nullable) |
| `attachments` has no document columns | `document_type_id`, `source`, `external_id`, `storage_status` and `source_path`          |
| `subject_type` is any text            | it is validated against `better_supabase.subject_types()` when the subjects block exists |
| none                                  | `attachment_document_types`, `claim_attachment_upload`, `mark_attachment_missing`        |

`upgrade` drops the old `create_attachment` overloads, adds the columns, the
unique index on the organization, source and external id, and the new table.
Existing files get `storage_status` from `uploaded_at`. A table you adopted
for `tables.attachments` maps the five columns or sets each to `null`:

```ts title="better-supabase.config.ts"
blocks: {
  attachments: {
    tables: {
      attachments: {
        name: "documents",
        columns: { documentType: null, source: null, externalId: null, storage: null, sourcePath: null },
      },
    },
  },
},
```

Rows whose `subject_type` is not registered keep working until they are
written again; register the type or map it first. Declare `options.sources`
for every source your app writes besides import runs. The codemod lists calls
of `create_attachment` so you can check them. See [attachments](/docs/blocks/attachments).

## Table names [#table-names]

A module table is its entity's plural, or starts with its entity's singular.
`upgrade` writes a migration that renames each table in place, with its
indexes, policies, triggers, constraints and sequences, so no row moves and
nothing is dropped. The new names are in `TABLE_RENAMES`:

| Block         | 0.46 tables                                                                                 | 0.47 tables                                                                                                               |
| ------------- | ------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------- |
| `activities`  | `engagements`                                                                               | `activities`                                                                                                              |
| `automations` | `rule_defaults`, `rule_evaluations`, `rule_fields`, `rules`, `rule_versions`                | `automation_defaults`, `automation_evaluations`, `automation_fields`, `automations`, `automation_versions`                |
| `catalog`     | `categories`, `price_lists`, `storefronts`                                                  | `catalog_categories`, `catalog_price_lists`, `catalog_storefronts`                                                        |
| `collections` | `favorites`                                                                                 | `collection_favorites`                                                                                                    |
| `compliance`  | `policies`                                                                                  | `compliance_policies`                                                                                                     |
| `domains`     | `organization_hostnames`                                                                    | `hostnames`                                                                                                               |
| `goals`       | `targets`                                                                                   | `goals`                                                                                                                   |
| `idempotency` | `leases`                                                                                    | `idempotency_leases`                                                                                                      |
| `inbox`       | `message_deliveries`, `conversation_events`, `inbound_events`, `message_templates`          | `inbox_message_deliveries`, `inbox_conversation_events`, `inbox_inbound_events`, `inbox_message_templates`                |
| `incidents`   | `status_components`, `status_component_history`, `status_pages`                             | `incident_status_components`, `incident_status_component_history`, `incident_status_pages`                                |
| `leases`      | `lease_agreements`                                                                          | `leases`                                                                                                                  |
| `memory`      | `ai_message_embeddings`                                                                     | `memory_message_embeddings`                                                                                               |
| `orders`      | `fulfillment_lines`, `fulfillments`, `kitchen_routes`, `kitchen_stations`, `shipping_rates` | `order_fulfillment_lines`, `order_fulfillments`, `order_kitchen_routes`, `order_kitchen_stations`, `order_shipping_rates` |
| `sso`         | `organization_domains`, `organization_sso_providers`                                        | `sso_domains`, `sso_providers`                                                                                            |
| `tasks`       | `work_item_assignees`, `work_item_checklist_items`, `work_items`                            | `task_assignees`, `task_checklist_items`, `tasks`                                                                         |
| `timeline`    | `activity_entries`                                                                          | `timeline_entries`                                                                                                        |

The idempotency `leases` table is renamed before the leases block takes the
name. A table you adopted (`blocks.<name>.tables`) keeps its name, and so
does every table of an entity you renamed: with `names: { task: "work_item" }`
the tasks block still writes `work_items`.
The codemod lists queries on an old `better_supabase` table for review;
rename them in your own SQL, views and `queryRaw` calls.

### Foreign key columns [#foreign-key-columns]

The columns that point at a task or an activity follow the entity (ADR 0152):

| Block        | Table                                                              | Old column      | New column    |
| ------------ | ------------------------------------------------------------------ | --------------- | ------------- |
| `tasks`      | `task_checklist_items`, `task_assignees`                           | `work_item_id`  | `task_id`     |
| `activities` | `engagement_subjects`, `engagement_recordings`, `engagement_notes` | `engagement_id` | `activity_id` |
| `mail-sync`  | `mail_threads`                                                     | `engagement_id` | `activity_id` |

`upgrade` renames each column in the block tables. A column of an entity you
renamed keeps the name that entity gives it, and a column of an adopted table
or one you mapped in `blocks.<name>.columns` keeps its name. `codemod 0.47`
lists the lines that use `work_item_id` or `engagement_id`, so rename them in
your own SQL, views and policies.

## The SQL modules [#the-sql-modules]

Regenerate the module SQL and the migration. The schema diff drops the old
function names and creates the new ones, and the data steps move the subject
types and the stored event types:

```sh
npx better-supabase@latest upgrade
npx better-supabase@latest db diff -f upgrade_0_46
```

## Removed in 0.48 [#removed-in-048]

These keep working in 0.47 and go in 0.48. The codemods and doctor point at
the old keys, types and imports in your code.

| Deprecated in 0.47                       | Use instead                                |
| ---------------------------------------- | ------------------------------------------ |
| Old keys in `blocks.<name>.entities`     | The entity's singular                      |
| The old subject types                    | `task`, `activity` and `compliance_policy` |
| 0.46 permission keys in configured roles | The keys in `PERMISSION_KEY_RENAMES`       |
| `DbError.legacyHint`                     | Match `hint`, or call `legacyHintOf(hint)` |
| The outbox `legacyEventTypes` option     | The new event types in every receiver      |
| `SequenceEnrollmentEventData`            | `EnrollmentEventData`                      |
| `workItemChecklistBinding`               | `taskChecklistBinding`                     |

Removed in 0.47 already, without a shim: the compliance client methods
`acknowledgePolicy`, `acknowledgements(policyId)` and
`policies(organizationId, { pendingOnly })`, the inbox `contacts.upsert`
method and `upsert_contact`, the postmortem `actionItems` field and the
`blocks["ai-files"].tables.versions` key.