chore(saas): make schema ownership explicit and enforce it (#7489)

## The problem

The SaaS database has two writers and always has: the Supabase
migrations in the SaaS repo, and Hibernate's `ddl-auto`. That was a
convention rather than a rule, and it leaked twice.

- An older `ddl-auto` run widened `team_memberships.role` to
varchar(255), which needed [a dedicated
migration](https://github.com/Stirling-Tools/Stirling-PDF-SaaS/blob/v3/supabase/migrations/20260804000000_fix_team_memberships_role_varchar50.sql)
to repair, because RLS policies depended on the column.
- `payg_instance_usage` shipped with an entity and **no migration**, and
nobody noticed for months — staging already had the table from an
earlier `ddl-auto` run. It surfaced only when a fresh preview branch,
built from migrations alone, threw `relation does not exist`.

Both are the same bug: nobody had to *say* who owned a table, so the
answer got decided by accident.

## The fix

`SaasSchemaOwnership` is the register — **29 migration-owned, 29
inherited** and left to Hibernate.

`MigrationOwnedSchemaFilter` applies it via Hibernate's
`hbm2ddl.schema_filter_provider`, wired on the **saas profile only**.
Hibernate is never shown a migration-owned table, so it cannot create,
alter, drop or truncate one whatever `ddl-auto` is set to. Inherited
tables stay managed, so a fresh preview branch still heals itself on
first boot. Self-hosted is untouched — there Hibernate rightly owns
everything.

**Why a filter rather than just `ddl-auto=none`:** off, and a fresh
branch is missing the 29 inherited tables. On, and Hibernate can reach
the other 29. The filter is what lets both be true at once.

**Why per-table, not per-schema:** Hibernate's schema management runs
over every mapped entity regardless of namespace. Moving SaaS tables to
their own schema would *not* by itself keep Hibernate out of them —
worth knowing, because that was the intuitive fix and it doesn't work.

## The part that makes it stick

`SaasSchemaOwnershipTest` makes the register binding: every `@Entity` on
the SaaS classpath must appear in exactly one set, so **a new entity
fails the build until someone states who owns its table**. That's the
forcing function that would have caught `payg_instance_usage`.

I verified it bites rather than assuming it — removing a single entry
fails with:

```
These entity tables are not declared in SaasSchemaOwnership, so nobody owns them.
Offending tables -> entities: [policies (stirling.software.proprietary.policy.store.PolicyEntity)]
```

naming both the table and the class, which is what the next person
actually needs.

## One debatable call

The **validate** filter excludes them too. Letting validation through
would flag drift, which is genuinely useful — but `ddl-auto=validate`
fails startup, and it would fail on differences we've deliberately
accepted (`ai_create_sessions` carries columns from a reverted Typst
feature that nothing maps). A boot failure over a table we chose not to
manage is noise. Argued in the javadoc; happy to flip it if you'd rather
have the signal.

## Dependency

Depends on
[Stirling-PDF-SaaS#324](https://github.com/Stirling-Tools/Stirling-PDF-SaaS/pull/324),
which adds migrations for the four SaaS-owned tables that had none.
They're listed here as migration-owned on that basis, so #324 should
land first.

Companion to
[#7483](https://github.com/Stirling-Tools/Stirling-PDF/pull/7483)
(dev/staging profiles with per-profile `ddl-auto`).

## Verification

`:saas:test` green including the 5 new tests, `spotlessCheck` green, and
the mutation check above.
This commit is contained in:
ConnorYoh
2026-08-17 10:08:09 +00:00
committed by GitHub
parent 08b08aa8a1
commit f15832b2bb
5 changed files with 528 additions and 0 deletions
@@ -0,0 +1,108 @@
package stirling.software.saas.config;
import org.hibernate.boot.model.relational.Namespace;
import org.hibernate.boot.model.relational.Sequence;
import org.hibernate.mapping.Table;
import org.hibernate.tool.schema.spi.SchemaFilter;
import org.hibernate.tool.schema.spi.SchemaFilterProvider;
/**
* Hides the migration-owned tables from Hibernate's schema management.
*
* <p>Wired on the SaaS profile only, via {@code hibernate.hbm2ddl.schema_filter_provider}.
* Self-hosted is untouched: there Hibernate rightly owns everything.
*
* <p>Why a filter rather than simply turning {@code ddl-auto} off: the SaaS database has two
* writers. The Supabase migrations own the SaaS tables, and Hibernate owns roughly thirty tables
* inherited from the self-hosted app that no migration has ever created. Turn {@code ddl-auto} off
* and a fresh preview branch is missing that second half; leave it on and Hibernate is free to
* reconcile migration-owned tables, which is how {@code team_memberships.role} ended up widened to
* varchar(255) and needed a migration to put back. A filter keeps the first half working and makes
* the second impossible.
*
* <p>Note that this is a per-table filter, not a per-schema one. Hibernate's schema management runs
* over every mapped entity regardless of namespace, so moving SaaS tables to their own schema would
* not by itself keep Hibernate out of them. {@link SaasSchemaOwnership} is the register; this class
* only applies it.
*
* <p><b>Foreign keys still cross the line, on purpose.</b> Several inherited tables reference
* migration-owned ones — {@code folders}, {@code stored_files} and {@code file_shares} all point at
* {@code users}/{@code teams}. Hibernate's {@code SchemaCreatorImpl.createForeignKeys} and {@code
* AbstractSchemaMigrator.applyForeignKeys} check {@code includeTable} against the *owning* table
* only and then emit every foreign key on it, without consulting the referenced table. So excluding
* {@code users} does not cost the branch its referential integrity, and a branch ends up matching
* staging. It does mean the referenced tables have to exist by the time Hibernate runs, which holds
* because a Supabase branch applies its migrations at build time and the app connects afterwards.
*
* <p><b>Known gap: this cannot detect drift.</b> Filtering means Hibernate never inspects these
* tables, and {@link #getValidateFilter()} extends that to {@code validate}, so nothing here
* compares a migration-owned table against its entity. Combined with the register being a
* hand-maintained list of another repo's contents (see {@link SaasSchemaOwnership}), there is
* currently no automated signal when the register, the entities and the database disagree. That is
* a deliberate trade for a boot that does not fail on differences we accept, not a claim that drift
* cannot happen; a non-fatal drift report is the missing piece and belongs outside this class.
*/
public class MigrationOwnedSchemaFilter implements SchemaFilterProvider, SchemaFilter {
/**
* The one decision this class makes. Everything Hibernate might do to a table it does not own —
* create, alter, drop, truncate — is refused.
*/
@Override
public boolean includeTable(Table table) {
return !SaasSchemaOwnership.isMigrationOwned(table.getName());
}
/**
* Namespaces are never filtered. The inherited tables and the migration-owned ones share {@code
* stirling_pdf}, so excluding the namespace would take both with it.
*/
@Override
public boolean includeNamespace(Namespace namespace) {
return true;
}
/**
* Sequences are left alone. Every id here is an identity column rather than a mapped generator,
* so there is nothing for Hibernate to create; filtering them would be dead code pretending to
* be a safeguard.
*/
@Override
public boolean includeSequence(Sequence sequence) {
return true;
}
@Override
public SchemaFilter getCreateFilter() {
return this;
}
@Override
public SchemaFilter getMigrateFilter() {
return this;
}
@Override
public SchemaFilter getDropFilter() {
return this;
}
@Override
public SchemaFilter getTruncatorFilter() {
return this;
}
/**
* Validation is filtered too, which is the one debatable call here.
*
* <p>Letting it through would give a useful signal when a migration-owned table drifts from its
* entity. But {@code ddl-auto=validate} fails startup, and it would fail on differences we have
* deliberately accepted — {@code ai_create_sessions} carries columns from a reverted feature
* that nothing maps, for instance. A boot failure over a table we have chosen not to manage is
* noise, so the rule stays uniform: Hibernate does not concern itself with these tables at all.
*/
@Override
public SchemaFilter getValidateFilter() {
return this;
}
}
@@ -0,0 +1,117 @@
package stirling.software.saas.config;
import java.util.Set;
/**
* Which side owns each table in the SaaS database.
*
* <p>The SaaS schema has two writers and always has: the Supabase migrations in the
* Stirling-PDF-SaaS repo, and Hibernate's {@code ddl-auto}. That was a convention rather than a
* rule, and it leaked twice. An older {@code ddl-auto} run widened {@code team_memberships.role} to
* varchar(255), which needed a dedicated migration to repair because RLS policies depended on the
* column. Separately {@code payg_instance_usage} went months with an entity and no migration, so it
* simply did not exist on a fresh preview branch.
*
* <p>This class makes the boundary explicit and {@code SaasSchemaOwnershipTest} makes it binding:
* every {@code @Entity} the SaaS app maps must appear in exactly one of these two sets. A new
* entity fails the build until someone states who owns its table, which is the decision that was
* previously made by accident.
*
* <p>{@link MigrationOwnedSchemaFilter} enforces it at runtime: Hibernate is never shown the
* migration-owned tables, so it cannot create, alter or drop them whatever {@code ddl-auto} says.
* Inherited tables stay under Hibernate, so a preview branch built from migrations alone still
* heals itself on first boot.
*
* <p><b>What this does not catch.</b> The register is a hand-maintained copy of what lives in
* another repository, and only one direction is enforced. The test fails when a *new* entity
* appears with no owner. It cannot notice a table changing sides: write a migration for {@code
* folders} in Stirling-PDF-SaaS and nothing here changes, the test still passes, and Hibernate
* carries on managing a table the migrations now own — which is precisely how {@code
* team_memberships.role} got widened. Adding a migration for anything in {@link #HIBERNATE_MANAGED}
* therefore means moving it to {@link #MIGRATION_OWNED} in the same change; nothing will remind
* you. Making that structural rather than remembered is what moving the SaaS tables into their own
* schema would buy, and is the reason this class is a stepping stone rather than the answer.
*/
public final class SaasSchemaOwnership {
/**
* Created and altered by the Supabase migrations. Hibernate must not touch these: the
* migrations carry constraints, defaults and RLS policies it knows nothing about and would
* reconcile away.
*/
public static final Set<String> MIGRATION_OWNED =
Set.of(
"ai_create_sessions",
"audit_events",
"authorities",
"billing_subscriptions",
"job_artifact_hash",
"legal_consent",
"linked_instance",
"payg_instance_usage",
"payg_meter_event_log",
"payg_prepaid_bundle",
"payg_shadow_charge",
"payg_team_extensions",
"persistent_logins",
"pricing_policy",
"processing_job",
"processing_job_step",
"procurement_agreement_signature",
"procurement_deal",
"procurement_quote",
"saas_team_extensions",
"saas_user_extensions",
"sessions",
"team_invitations",
"team_memberships",
"teams",
"users",
"wallet_entitlement_snapshot",
"wallet_ledger",
"wallet_policy");
/**
* Inherited from the self-hosted app, where {@code ddl-auto} owns the schema and no Supabase
* migration exists. Deliberately left under Hibernate so a fresh branch gets them on first
* boot.
*/
public static final Set<String> HIBERNATE_MANAGED =
Set.of(
"account_link_device_credential",
"account_link_metered_signature",
"account_link_sync_state",
"account_link_usage_counter",
"api_key_daily_usage",
"api_keys",
"file_encryption_keys",
"file_run_events",
"file_share_accesses",
"file_shares",
"folders",
"integration_configs",
"invite_tokens",
"jwt_signing_keys",
"policies",
"policy_assets",
"policy_completed_migrations",
"policy_processed_files",
"policy_source_doc_counts",
"policy_source_doc_totals",
"policy_sources",
"resource_grants",
"storage_cleanup_entries",
"stored_file_blobs",
"stored_files",
"user_license_settings",
"user_server_certificates",
"workflow_participants",
"workflow_sessions");
private SaasSchemaOwnership() {}
/** Case-insensitive: Hibernate hands us whatever casing the mapping used. */
public static boolean isMigrationOwned(String tableName) {
return tableName != null && MIGRATION_OWNED.contains(tableName.toLowerCase());
}
}
@@ -27,6 +27,13 @@ spring.jpa.properties.hibernate.hbm2ddl.create_namespaces=true
spring.jpa.hibernate.ddl-auto=update
# ...but only over the tables Hibernate actually owns. The SaaS database has two writers: the
# Supabase migrations own the SaaS tables, Hibernate owns ~30 inherited from the self-hosted app that
# no migration has ever created. This filter hides the former from schema management, so ddl-auto can
# still heal a fresh preview branch without being free to reconcile a migration-owned table — which
# is how team_memberships.role ended up widened to varchar(255). Register: SaasSchemaOwnership.
spring.jpa.properties.hibernate.hbm2ddl.schema_filter_provider=stirling.software.saas.config.MigrationOwnedSchemaFilter
# ---------- Supabase JWT auth ----------
# Required: set SAAS_DB_PROJECT_REF via env.
app.supabase.project-ref=${SAAS_DB_PROJECT_REF:}
@@ -0,0 +1,129 @@
package stirling.software.saas.config;
import static org.assertj.core.api.Assertions.assertThat;
import java.io.InputStream;
import java.util.Properties;
import org.hibernate.mapping.Table;
import org.hibernate.tool.schema.spi.SchemaFilter;
import org.hibernate.tool.schema.spi.SchemaFilterProvider;
import org.junit.jupiter.api.Test;
/**
* Covers {@link MigrationOwnedSchemaFilter} and, just as importantly, its wiring.
*
* <p>{@link SaasSchemaOwnershipTest} proves the register is complete; nothing proved the filter
* applies it, or that Hibernate is even asking. A typo in the {@code
* hibernate.hbm2ddl.schema_filter_provider} key, a stale fully-qualified name after a package move,
* or a getter returning null would all leave every migration-owned table exposed to {@code
* ddl-auto} with a fully green build. Hence the property assertion below, which is the only thing
* here that would catch that.
*/
class MigrationOwnedSchemaFilterTest {
private static final String FILTER_PROPERTY =
"spring.jpa.properties.hibernate.hbm2ddl.schema_filter_provider";
private final MigrationOwnedSchemaFilter filter = new MigrationOwnedSchemaFilter();
/** "orm" is Hibernate's own default contributor; the value is irrelevant to the filter. */
private static Table table(String name) {
return new Table("orm", name);
}
@Test
void migrationOwnedTablesAreHiddenFromHibernate() {
assertThat(filter.includeTable(table("teams"))).isFalse();
assertThat(filter.includeTable(table("users"))).isFalse();
assertThat(filter.includeTable(table("team_memberships"))).isFalse();
assertThat(filter.includeTable(table("payg_instance_usage"))).isFalse();
}
@Test
void inheritedTablesStayUnderHibernate() {
assertThat(filter.includeTable(table("folders"))).isTrue();
assertThat(filter.includeTable(table("stored_files"))).isTrue();
assertThat(filter.includeTable(table("api_keys"))).isTrue();
}
@Test
void anUnknownTableIsLeftToHibernate() {
// Fail-open is the right default: an unrecognised table is either brand new or from a
// module
// we do not know about, and SaasSchemaOwnershipTest is what stops it staying unrecognised.
assertThat(filter.includeTable(table("no_such_table"))).isTrue();
}
@Test
void casingDoesNotDefeatTheFilter() {
assertThat(filter.includeTable(table("TEAMS"))).isFalse();
assertThat(filter.includeTable(table("Team_Memberships"))).isFalse();
}
/**
* Foreign keys from an inherited table into a migration-owned one survive the filter.
*
* <p>Worth pinning, because it is not obvious and it decides whether a preview branch keeps
* referential integrity. {@code folders}, {@code stored_files} and {@code file_shares} all
* reference {@code users}/{@code teams}, which the filter excludes. Hibernate 7.2's {@code
* SchemaCreatorImpl.createForeignKeys} (and {@code AbstractSchemaMigrator.applyForeignKeys})
* tests {@code includeTable} against the *owning* table only, then emits every foreign key on
* it; the referenced table is never consulted. So the constraints are still created and a
* branch matches staging.
*
* <p>The one thing this depends on is ordering: the referenced tables have to exist first. They
* do, because a Supabase branch runs its migrations at build time and the app connects after.
*/
@Test
void foreignKeysIntoMigrationOwnedTablesAreStillEmitted() {
assertThat(filter.includeTable(table("folders"))).isTrue();
assertThat(filter.includeTable(table("file_shares"))).isTrue();
assertThat(filter.includeTable(table("users"))).isFalse();
assertThat(filter.includeTable(table("teams"))).isFalse();
}
@Test
void everySchemaActionGetsTheSameFilter() {
assertThat(filter.getCreateFilter()).isSameAs(filter);
assertThat(filter.getMigrateFilter()).isSameAs(filter);
assertThat(filter.getDropFilter()).isSameAs(filter);
assertThat(filter.getTruncatorFilter()).isSameAs(filter);
assertThat(filter.getValidateFilter()).isSameAs(filter);
}
@Test
void namespacesAndSequencesAreNeverFiltered() {
// Both share the stirling_pdf namespace, so filtering it would take the inherited tables
// with it. Neither argument is read, so nulls are fine and keep the test free of Hibernate
// bootstrap machinery.
assertThat(filter.includeNamespace(null)).isTrue();
assertThat(filter.includeSequence(null)).isTrue();
}
@Test
void theFilterIsActuallyWiredIntoHibernate() throws Exception {
Properties properties = new Properties();
try (InputStream in = getClass().getResourceAsStream("/application-saas.properties")) {
assertThat(in)
.as("application-saas.properties must be on the test classpath to check wiring")
.isNotNull();
properties.load(in);
}
String configured = properties.getProperty(FILTER_PROPERTY);
assertThat(configured)
.as(
"%s is unset, so Hibernate installs its default filter and every"
+ " migration-owned table is back under ddl-auto",
FILTER_PROPERTY)
.isNotBlank();
Class<?> wired = Class.forName(configured.trim());
assertThat(SchemaFilterProvider.class)
.as("Hibernate only accepts a SchemaFilterProvider here")
.isAssignableFrom(wired);
assertThat(SchemaFilter.class).isAssignableFrom(wired);
assertThat(wired).isEqualTo(MigrationOwnedSchemaFilter.class);
}
}
@@ -0,0 +1,167 @@
package stirling.software.saas.config;
import static org.assertj.core.api.Assertions.assertThat;
import java.util.Arrays;
import java.util.List;
import java.util.Set;
import java.util.TreeMap;
import java.util.TreeSet;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.config.BeanDefinition;
import org.springframework.boot.persistence.autoconfigure.EntityScan;
import org.springframework.context.annotation.ClassPathScanningCandidateComponentProvider;
import org.springframework.core.type.filter.AnnotationTypeFilter;
import org.springframework.util.ClassUtils;
import jakarta.persistence.Entity;
import jakarta.persistence.Table;
import stirling.software.proprietary.security.configuration.DatabaseConfig;
/**
* Makes {@link SaasSchemaOwnership} binding rather than decorative.
*
* <p>Every {@code @Entity} the SaaS app maps has to be declared as owned by either the Supabase
* migrations or Hibernate. Adding an entity without saying which fails here, at build time, instead
* of months later on a preview branch that has no such table. That is not hypothetical: {@code
* payg_instance_usage} shipped with an entity and no migration and went unnoticed until a branch
* tried to use it.
*
* <p>"Maps" is meant precisely: the scan covers the packages named by the {@code @EntityScan}
* declarations the app actually boots with, not everything under {@code stirling.software}. See
* {@link #mappedPackages()}. Note this only enforces one direction — {@link SaasSchemaOwnership}
* documents the drift it cannot see.
*/
class SaasSchemaOwnershipTest {
/**
* The packages the running app actually maps, read off the two {@code @EntityScan} declarations
* that define them rather than hardcoded.
*
* <p>Scanning all of {@code stirling.software} would be easier and wrong in a quiet way: it is
* a superset, so it would force ownership declarations for entities Hibernate never sees and
* let the register claim tables that do not exist as far as the SaaS app is concerned. Deriving
* the list means this test measures the same set Hibernate does, and follows a package being
* added or moved without anyone updating it here.
*/
private static Set<String> mappedPackages() {
Set<String> packages = new TreeSet<>();
for (Class<?> config : List.of(SaasJpaConfig.class, DatabaseConfig.class)) {
EntityScan scan = config.getAnnotation(EntityScan.class);
assertThat(scan)
.as("%s must carry @EntityScan, or its entities are not mapped", config)
.isNotNull();
packages.addAll(Arrays.asList(scan.value()));
}
return packages;
}
private static TreeMap<String, String> mappedTables() {
ClassPathScanningCandidateComponentProvider scanner =
new ClassPathScanningCandidateComponentProvider(false);
scanner.addIncludeFilter(new AnnotationTypeFilter(Entity.class));
TreeMap<String, String> byTable = new TreeMap<>();
for (String basePackage : mappedPackages()) {
for (BeanDefinition bd : scanner.findCandidateComponents(basePackage)) {
String className = bd.getBeanClassName();
Class<?> type;
try {
type =
ClassUtils.forName(
className, SaasSchemaOwnershipTest.class.getClassLoader());
} catch (ClassNotFoundException | LinkageError e) {
continue; // not on this module's runtime classpath; nothing to own
}
Table table = type.getAnnotation(Table.class);
String name =
table != null && !table.name().isBlank()
? table.name()
: camelToSnake(type.getSimpleName());
byTable.put(name.toLowerCase(), className);
}
}
return byTable;
}
/** Mirrors Spring Boot's default CamelCaseToUnderscoresNamingStrategy for an unnamed @Table. */
private static String camelToSnake(String name) {
return name.replaceAll("([a-z0-9])([A-Z])", "$1_$2").toLowerCase();
}
@Test
void everyEntityTableIsOwnedByExactlyOneSide() {
TreeMap<String, String> mapped = mappedTables();
assertThat(mapped)
.as("entity scan found nothing, so this test proves nothing")
.isNotEmpty();
// The scan is derived from @EntityScan now, so a package quietly dropped from either
// declaration would shrink it and weaken this test rather than fail it. These four straddle
// the two declarations, so losing either side fails here instead of silently checking less.
assertThat(mapped.keySet())
.as("both @EntityScan declarations must have contributed to the scan")
.contains("users", "teams", "payg_instance_usage", "folders");
Set<String> undeclared = new TreeSet<>();
Set<String> both = new TreeSet<>();
for (String table : mapped.keySet()) {
boolean migration = SaasSchemaOwnership.MIGRATION_OWNED.contains(table);
boolean hibernate = SaasSchemaOwnership.HIBERNATE_MANAGED.contains(table);
if (migration && hibernate) both.add(table);
if (!migration && !hibernate) undeclared.add(table);
}
assertThat(undeclared)
.as(
"""
These entity tables are not declared in SaasSchemaOwnership, so nobody owns \
them. Decide and add each to exactly one set:
- MIGRATION_OWNED: also add a migration in Stirling-PDF-SaaS, or the table \
will not exist on a fresh preview branch.
- HIBERNATE_MANAGED: only correct for a table inherited from the \
self-hosted app that no Supabase migration creates.
Offending tables -> entities: %s"""
.formatted(
undeclared.stream()
.map(t -> t + " (" + mapped.get(t) + ")")
.toList()))
.isEmpty();
assertThat(both)
.as("declared as owned by both sides, which is the one thing it cannot be")
.isEmpty();
}
@Test
void theTwoSetsDoNotOverlap() {
Set<String> overlap = new TreeSet<>(SaasSchemaOwnership.MIGRATION_OWNED);
overlap.retainAll(SaasSchemaOwnership.HIBERNATE_MANAGED);
assertThat(overlap).isEmpty();
}
@Test
void tableNamesAreLowercaseSoLookupsCannotMiss() {
// isMigrationOwned() lowercases its input; a capital in either set would be unreachable.
assertThat(SaasSchemaOwnership.MIGRATION_OWNED)
.allSatisfy(t -> assertThat(t).isEqualTo(t.toLowerCase()));
assertThat(SaasSchemaOwnership.HIBERNATE_MANAGED)
.allSatisfy(t -> assertThat(t).isEqualTo(t.toLowerCase()));
}
@Test
void migrationOwnedTablesIncludeTheOnesThatBitUs() {
// team_memberships is the table an old ddl-auto run widened; payg_instance_usage is the one
// that had an entity and no migration. Both must be on the migrations' side of the line.
assertThat(SaasSchemaOwnership.MIGRATION_OWNED)
.contains("team_memberships", "payg_instance_usage", "teams", "users");
}
@Test
void isMigrationOwnedIsCaseInsensitiveAndNullSafe() {
assertThat(SaasSchemaOwnership.isMigrationOwned("TEAM_MEMBERSHIPS")).isTrue();
assertThat(SaasSchemaOwnership.isMigrationOwned("team_memberships")).isTrue();
assertThat(SaasSchemaOwnership.isMigrationOwned(null)).isFalse();
assertThat(SaasSchemaOwnership.isMigrationOwned("no_such_table")).isFalse();
}
}