From 59597dccb3ec9b84d0e938f7cf7309ed79a8f613 Mon Sep 17 00:00:00 2001 From: James Sadler Date: Sat, 4 Jul 2026 13:03:30 +1000 Subject: [PATCH] chore(stack): EQL v3 maintainability follow-ups from the #547 review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - encryption/v3: reconstructRow → rowReconstructor factory — the table config (build() + buildColumnKeyMap()) is row-invariant but was rebuilt per row on the bulk decrypt path; it is now derived once per call site, with date columns resolved up front - encrypt operations: replace the two inline NaN/Infinity guard copies with the existing assertValidNumericValue helper (validation.ts) - schema/match-defaults: single source of truth for the default match index parameters (previously duplicated between the v2 freeTextSearch builder and the v3 domain builders) plus a shared cloneMatchOpts deep-clone used at all three v3 clone sites - tests: one shared live-gate helper (LIVE_CIPHERSTASH_ENABLED / LIVE_EQL_V3_PG_ENABLED + describeLive/describeLivePg) replaces the gate blocks copy-pasted across seven live suites No behavioral changes: emitted encrypt configs are byte-identical (schema-v3 fixture tests unchanged), guard error messages unchanged, gating semantics unchanged. --- packages/stack/__tests__/helpers/live-gate.ts | 28 ++++++++ .../stack/__tests__/schema-v3-client.test.ts | 10 +-- packages/stack/__tests__/schema-v3-pg.test.ts | 11 +-- .../__tests__/v3-matrix/matrix-bulk.test.ts | 9 +-- .../v3-matrix/matrix-identity-live.test.ts | 9 +-- .../__tests__/v3-matrix/matrix-keyset.test.ts | 9 +-- .../v3-matrix/matrix-live-pg.test.ts | 9 +-- .../__tests__/v3-matrix/matrix-live.test.ts | 9 +-- .../src/encryption/operations/encrypt.ts | 23 +----- packages/stack/src/encryption/v3.ts | 41 ++++++----- packages/stack/src/eql/v3/columns.ts | 71 ++++--------------- packages/stack/src/schema/index.ts | 19 +++-- packages/stack/src/schema/match-defaults.ts | 54 ++++++++++++++ 13 files changed, 139 insertions(+), 163 deletions(-) create mode 100644 packages/stack/__tests__/helpers/live-gate.ts create mode 100644 packages/stack/src/schema/match-defaults.ts diff --git a/packages/stack/__tests__/helpers/live-gate.ts b/packages/stack/__tests__/helpers/live-gate.ts new file mode 100644 index 00000000..8ad3675e --- /dev/null +++ b/packages/stack/__tests__/helpers/live-gate.ts @@ -0,0 +1,28 @@ +import { describe } from 'vitest' + +/** + * Shared env gates for the live (network-touching) suites. Previously each + * live suite re-declared these; one definition keeps the credential list — + * and therefore what "live" means — from drifting between files. + * + * Callers must `import 'dotenv/config'` BEFORE importing this module (all + * live suites already do, as their first import) so the env is populated + * when these are evaluated. + */ + +/** True when live CipherStash (ZeroKMS/CTS) credentials are configured. */ +export const LIVE_CIPHERSTASH_ENABLED = Boolean( + process.env.CS_WORKSPACE_CRN && + process.env.CS_CLIENT_ID && + process.env.CS_CLIENT_KEY && + process.env.CS_CLIENT_ACCESS_KEY, +) + +/** True when live credentials AND a Postgres `DATABASE_URL` are configured. */ +export const LIVE_EQL_V3_PG_ENABLED = Boolean( + process.env.DATABASE_URL && LIVE_CIPHERSTASH_ENABLED, +) + +export const describeLive = LIVE_CIPHERSTASH_ENABLED ? describe : describe.skip + +export const describeLivePg = LIVE_EQL_V3_PG_ENABLED ? describe : describe.skip diff --git a/packages/stack/__tests__/schema-v3-client.test.ts b/packages/stack/__tests__/schema-v3-client.test.ts index 247f9d98..86db4aa2 100644 --- a/packages/stack/__tests__/schema-v3-client.test.ts +++ b/packages/stack/__tests__/schema-v3-client.test.ts @@ -5,6 +5,7 @@ import { typedClient } from '@/encryption/v3' import { encryptedTable, types } from '@/eql/v3' import { Encryption } from '@/index' import { unwrapResult } from './fixtures' +import { describeLive, LIVE_CIPHERSTASH_ENABLED } from './helpers/live-gate' const users = encryptedTable('schema_v3_client_users', { email: types.TextSearch('email'), @@ -20,15 +21,6 @@ const users = encryptedTable('schema_v3_client_users', { occurredAt: types.Timestamptz('occurred_at'), }) -const LIVE_CIPHERSTASH_ENABLED = Boolean( - process.env.CS_WORKSPACE_CRN && - process.env.CS_CLIENT_ID && - process.env.CS_CLIENT_KEY && - process.env.CS_CLIENT_ACCESS_KEY, -) - -const describeLive = LIVE_CIPHERSTASH_ENABLED ? describe : describe.skip - describeLive('eql_v3 client integration', () => { let protectClient: EncryptionClient diff --git a/packages/stack/__tests__/schema-v3-pg.test.ts b/packages/stack/__tests__/schema-v3-pg.test.ts index ae4682f4..42dbe2d0 100644 --- a/packages/stack/__tests__/schema-v3-pg.test.ts +++ b/packages/stack/__tests__/schema-v3-pg.test.ts @@ -7,16 +7,7 @@ import { Encryption } from '@/index' import type { Encrypted } from '@/types' import { unwrapResult } from './fixtures' import { installEqlV3IfNeeded } from './helpers/eql-v3' - -const LIVE_EQL_V3_PG_ENABLED = Boolean( - process.env.DATABASE_URL && - process.env.CS_WORKSPACE_CRN && - process.env.CS_CLIENT_ID && - process.env.CS_CLIENT_KEY && - process.env.CS_CLIENT_ACCESS_KEY, -) - -const describeLivePg = LIVE_EQL_V3_PG_ENABLED ? describe : describe.skip +import { describeLivePg, LIVE_EQL_V3_PG_ENABLED } from './helpers/live-gate' const databaseUrl = process.env.DATABASE_URL const sql = LIVE_EQL_V3_PG_ENABLED diff --git a/packages/stack/__tests__/v3-matrix/matrix-bulk.test.ts b/packages/stack/__tests__/v3-matrix/matrix-bulk.test.ts index 8bba5162..6c815da6 100644 --- a/packages/stack/__tests__/v3-matrix/matrix-bulk.test.ts +++ b/packages/stack/__tests__/v3-matrix/matrix-bulk.test.ts @@ -9,14 +9,7 @@ import 'dotenv/config' import { beforeAll, describe, expect, it } from 'vitest' import { EncryptionV3, encryptedTable, types } from '@/encryption/v3' import { unwrapResult } from '../fixtures' - -const LIVE_CIPHERSTASH_ENABLED = Boolean( - process.env.CS_WORKSPACE_CRN && - process.env.CS_CLIENT_ID && - process.env.CS_CLIENT_KEY && - process.env.CS_CLIENT_ACCESS_KEY, -) -const describeLive = LIVE_CIPHERSTASH_ENABLED ? describe : describe.skip +import { describeLive, LIVE_CIPHERSTASH_ENABLED } from '../helpers/live-gate' const people = encryptedTable('v3_bulk_people', { nickname: types.TextEq('nickname'), diff --git a/packages/stack/__tests__/v3-matrix/matrix-identity-live.test.ts b/packages/stack/__tests__/v3-matrix/matrix-identity-live.test.ts index 2dcef6c4..8f047706 100644 --- a/packages/stack/__tests__/v3-matrix/matrix-identity-live.test.ts +++ b/packages/stack/__tests__/v3-matrix/matrix-identity-live.test.ts @@ -11,14 +11,7 @@ import { beforeAll, describe, expect, it } from 'vitest' import { EncryptionV3, encryptedTable, types } from '@/encryption/v3' import { LockContext } from '@/identity' import { unwrapResult } from '../fixtures' - -const LIVE_CIPHERSTASH_ENABLED = Boolean( - process.env.CS_WORKSPACE_CRN && - process.env.CS_CLIENT_ID && - process.env.CS_CLIENT_KEY && - process.env.CS_CLIENT_ACCESS_KEY, -) -const describeLive = LIVE_CIPHERSTASH_ENABLED ? describe : describe.skip +import { describeLive, LIVE_CIPHERSTASH_ENABLED } from '../helpers/live-gate' const users = encryptedTable('v3_identity_live_users', { email: types.TextEq('email'), diff --git a/packages/stack/__tests__/v3-matrix/matrix-keyset.test.ts b/packages/stack/__tests__/v3-matrix/matrix-keyset.test.ts index b78b7e5d..cd6c2a77 100644 --- a/packages/stack/__tests__/v3-matrix/matrix-keyset.test.ts +++ b/packages/stack/__tests__/v3-matrix/matrix-keyset.test.ts @@ -8,19 +8,12 @@ import { ensureKeyset } from '@cipherstash/protect-ffi' import { beforeAll, describe, expect, it } from 'vitest' import { EncryptionV3, encryptedTable, types } from '@/encryption/v3' import { unwrapResult } from '../fixtures' +import { describeLive, LIVE_CIPHERSTASH_ENABLED } from '../helpers/live-gate' const users = encryptedTable('v3_keyset_users', { email: types.TextEq('email'), }) -const LIVE_CIPHERSTASH_ENABLED = Boolean( - process.env.CS_WORKSPACE_CRN && - process.env.CS_CLIENT_ID && - process.env.CS_CLIENT_KEY && - process.env.CS_CLIENT_ACCESS_KEY, -) -const describeLive = LIVE_CIPHERSTASH_ENABLED ? describe : describe.skip - describe('EncryptionV3 keyset config (deterministic)', () => { it('rejects an invalid keyset id before touching the network', async () => { await expect( diff --git a/packages/stack/__tests__/v3-matrix/matrix-live-pg.test.ts b/packages/stack/__tests__/v3-matrix/matrix-live-pg.test.ts index 2a9aae11..c525d10c 100644 --- a/packages/stack/__tests__/v3-matrix/matrix-live-pg.test.ts +++ b/packages/stack/__tests__/v3-matrix/matrix-live-pg.test.ts @@ -36,6 +36,7 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest' import { EncryptionV3, encryptedTable } from '@/encryption/v3' import { unwrapResult } from '../fixtures' import { installEqlV3IfNeeded } from '../helpers/eql-v3' +import { describeLivePg, LIVE_EQL_V3_PG_ENABLED } from '../helpers/live-gate' import { type DomainSpec, type EqlV3TypeName, @@ -43,13 +44,6 @@ import { V3_MATRIX, } from './catalog' -const LIVE_EQL_V3_PG_ENABLED = Boolean( - process.env.DATABASE_URL && - process.env.CS_WORKSPACE_CRN && - process.env.CS_CLIENT_ID && - process.env.CS_CLIENT_KEY && - process.env.CS_CLIENT_ACCESS_KEY, -) // Previously force-skipped (CI run 28569708268, PR #540): `beforeAll` crashed // with `PostgresError: invalid input syntax for type json` on the dynamic // 35-column INSERT. Root cause was a postgres.js serialization gap — a bare @@ -58,7 +52,6 @@ const LIVE_EQL_V3_PG_ENABLED = Boolean( // after the skip and the skip was simply left stale). Re-enabled here as an // ordinary credential-gated suite: it runs in CI (which supplies DATABASE_URL + // CS_* creds) and self-skips locally when they are absent. -const describeLivePg = LIVE_EQL_V3_PG_ENABLED ? describe : describe.skip const databaseUrl = process.env.DATABASE_URL const sql = LIVE_EQL_V3_PG_ENABLED diff --git a/packages/stack/__tests__/v3-matrix/matrix-live.test.ts b/packages/stack/__tests__/v3-matrix/matrix-live.test.ts index a6ae0352..0a8a2e35 100644 --- a/packages/stack/__tests__/v3-matrix/matrix-live.test.ts +++ b/packages/stack/__tests__/v3-matrix/matrix-live.test.ts @@ -21,6 +21,7 @@ import 'dotenv/config' import { beforeAll, describe, expect, it } from 'vitest' import { EncryptionV3, encryptedTable } from '@/encryption/v3' import { unwrapResult } from '../fixtures' +import { describeLive, LIVE_CIPHERSTASH_ENABLED } from '../helpers/live-gate' import { type DomainSpec, type EqlV3TypeName, @@ -28,14 +29,6 @@ import { V3_MATRIX, } from './catalog' -const LIVE_CIPHERSTASH_ENABLED = Boolean( - process.env.CS_WORKSPACE_CRN && - process.env.CS_CLIENT_ID && - process.env.CS_CLIENT_KEY && - process.env.CS_CLIENT_ACCESS_KEY, -) -const describeLive = LIVE_CIPHERSTASH_ENABLED ? describe : describe.skip - /** `eql_v3.int4_ord` → `int4_ord`: a valid, per-domain-unique column name. */ const slug = (t: EqlV3TypeName): string => t.replace('eql_v3.', '') diff --git a/packages/stack/src/encryption/operations/encrypt.ts b/packages/stack/src/encryption/operations/encrypt.ts index eba1a278..de380613 100644 --- a/packages/stack/src/encryption/operations/encrypt.ts +++ b/packages/stack/src/encryption/operations/encrypt.ts @@ -4,6 +4,7 @@ import { type JsPlaintext, } from '@cipherstash/protect-ffi' import { getErrorCode } from '@/encryption/helpers/error-code' +import { assertValidNumericValue } from '@/encryption/helpers/validation' import { type EncryptionError, EncryptionErrorTypes } from '@/errors' import { type LockContextInput, resolveLockContext } from '@/identity' import type { @@ -71,19 +72,7 @@ export class EncryptOperation extends EncryptionOperation { return null as unknown as Encrypted } - if ( - typeof this.plaintext === 'number' && - Number.isNaN(this.plaintext) - ) { - throw new Error('[encryption]: Cannot encrypt NaN value') - } - - if ( - typeof this.plaintext === 'number' && - !Number.isFinite(this.plaintext) - ) { - throw new Error('[encryption]: Cannot encrypt Infinity value') - } + assertValidNumericValue(this.plaintext) const { metadata } = this.getAuditData() @@ -160,13 +149,7 @@ export class EncryptOperationWithLockContext extends EncryptionOperation { } /** - * Reconstruct `Date` values on a decrypted row from the table's encrypt-config - * `cast_as`. The FFI returns `JsPlaintext` (string/number/boolean/…) with no - * `Date`, so those columns arrive as their serialized form and are rebuilt here. - * Safe (idempotent) if the FFI ever returns `Date` directly: `new Date(date)` is - * a no-op. + * Build a per-row reconstructor of `Date` values from the table's + * encrypt-config `cast_as`. The FFI returns `JsPlaintext` + * (string/number/boolean/…) with no `Date`, so those columns arrive as their + * serialized form and are rebuilt here. Safe (idempotent) if the FFI ever + * returns `Date` directly: `new Date(date)` is a no-op. + * + * A factory rather than a `(row, table)` function so the table config — + * row-invariant, but non-trivial to build — is derived once per call site, + * not once per row on the bulk path. * * NOTE: `bigint` (int8) reconstruction is intentionally absent — int8 domains are * omitted from the v3 SDK until the native FFI supports lossless bigint I/O. */ -function reconstructRow( - row: Record, +function rowReconstructor( table: AnyV3Table, -): Record { +): (row: Record) => Record { // The decrypted row is keyed by JS property name, but `cast_as` lives on the // config keyed by DB name — bridge the two via the table's property→DB map. const { columns } = table.build() const propToDb = table.buildColumnKeyMap() - const out: Record = { ...row } - for (const [property, dbName] of Object.entries(propToDb)) { - const value = out[property] - if (value == null) continue - if (columns[dbName]?.cast_as === 'date') { + // Only date columns need per-row work; resolve them up front. + const dateProperties = Object.entries(propToDb) + .filter(([, dbName]) => columns[dbName]?.cast_as === 'date') + .map(([property]) => property) + + return (row) => { + const out: Record = { ...row } + for (const property of dateProperties) { + const value = out[property] + if (value == null) continue out[property] = new Date(value as string | number | Date) } + return out } - return out } /** @@ -172,15 +180,16 @@ export function typedClient( const op = client.decryptModel(input as never) const result = await (lockContext ? op.withLockContext(lockContext) : op) if (result.failure) return result as never - return { data: reconstructRow(result.data, table) } as never + return { data: rowReconstructor(table)(result.data) } as never }, bulkDecryptModels: async (input, table, lockContext) => { const op = client.bulkDecryptModels(input as never) const result = await (lockContext ? op.withLockContext(lockContext) : op) if (result.failure) return result as never + const reconstruct = rowReconstructor(table) return { data: result.data.map((row) => - reconstructRow(row as Record, table), + reconstruct(row as Record), ), } as never }, diff --git a/packages/stack/src/eql/v3/columns.ts b/packages/stack/src/eql/v3/columns.ts index 1172a555..b70c3ef6 100644 --- a/packages/stack/src/eql/v3/columns.ts +++ b/packages/stack/src/eql/v3/columns.ts @@ -1,4 +1,9 @@ import type { ColumnSchema, MatchIndexOpts } from '@/schema' +import { + type BuiltMatchIndexOpts, + cloneMatchOpts, + defaultMatchOpts, +} from '@/schema/match-defaults' /** * The query capabilities a v3 concrete domain exposes. These are SDK-facing @@ -274,47 +279,6 @@ export const FLOAT8_ORD = { capabilities: ORDER_AND_RANGE, } as const -/** - * Fully-resolved match-index options: every field present and non-`undefined`. - * - * `MatchIndexOpts` (the user-facing tuning input) has all fields optional — - * each is `.default(...).optional()` in the zod schema, so its inferred type is - * `T | undefined`. This type pins the BUILT/resolved shape explicitly via - * `NonNullable<...>`, which states the non-null intent directly and is robust - * regardless of `Required<>`'s subtle, `exactOptionalPropertyTypes`-dependent - * stripping semantics. (v2 uses `Required` and that compiles - * fine under this repo's tsconfig — `strict: true`, NO `exactOptionalPropertyTypes` - * — so this is a clarity/robustness choice, not a fix for a present break.) - */ -type BuiltMatchIndexOpts = { - tokenizer: NonNullable - token_filters: NonNullable - k: NonNullable - m: NonNullable - include_original: NonNullable -} - -/** - * Default match-index parameters. These mirror the v2 `freeTextSearch()` - * builder defaults EXACTLY (note `include_original: true`, which is the v2 - * builder default rather than the zod-schema default of `false`). - * - * This is a FACTORY (not a shared `const`) so every caller gets fresh, unaliased - * nested objects (`tokenizer`, `token_filters` and the `{ kind: 'downcase' }` - * inside it). A shared const would be shallow-copied by `{ ...DEFAULT }`, leaving - * those nested objects aliased across every column — a caller mutating one built - * config could then corrupt the defaults used by later columns. - */ -function defaultMatchOpts(): BuiltMatchIndexOpts { - return { - tokenizer: { kind: 'ngram', token_length: 3 }, - token_filters: [{ kind: 'downcase' }], - k: 6, - m: 2048, - include_original: true, - } -} - /** * Translate a domain's semantic {@link QueryCapabilities} (plus its plaintext * `castAs`, which decides how equality is answered) into the concrete EQL index @@ -351,12 +315,9 @@ function indexesForCapabilities( } if (capabilities.freeTextSearch) { - const match = defaultMatchOpts() - indexes.match = { - ...match, - tokenizer: { ...match.tokenizer }, - token_filters: match.token_filters.map((f) => ({ ...f })), - } + // The factory returns fresh, unaliased nested objects per call, so no + // column's emitted match block ever shares state with another's. + indexes.match = defaultMatchOpts() } return indexes @@ -467,15 +428,13 @@ export class EncryptedTextSearchColumn extends EncryptedV3Column< // Clone-on-write: deep-copy the nested tokenizer / token_filters when // storing them so a caller mutating their own opts object between // freeTextSearch(opts) and build() cannot leak into the emitted config. - const tokenizer = opts?.tokenizer ?? defaults.tokenizer - const token_filters = opts?.token_filters ?? defaults.token_filters - this.matchOpts = { - tokenizer: { ...tokenizer }, - token_filters: token_filters.map((f) => ({ ...f })), + this.matchOpts = cloneMatchOpts({ + tokenizer: opts?.tokenizer ?? defaults.tokenizer, + token_filters: opts?.token_filters ?? defaults.token_filters, k: opts?.k ?? defaults.k, m: opts?.m ?? defaults.m, include_original: opts?.include_original ?? defaults.include_original, - } + }) return this } @@ -490,11 +449,7 @@ export class EncryptedTextSearchColumn extends EncryptedV3Column< indexes: { unique: { token_filters: [] }, ore: {}, - match: { - ...this.matchOpts, - tokenizer: { ...this.matchOpts.tokenizer }, - token_filters: this.matchOpts.token_filters.map((f) => ({ ...f })), - }, + match: cloneMatchOpts(this.matchOpts), }, } } diff --git a/packages/stack/src/schema/index.ts b/packages/stack/src/schema/index.ts index 01058cc6..8672c328 100644 --- a/packages/stack/src/schema/index.ts +++ b/packages/stack/src/schema/index.ts @@ -1,5 +1,6 @@ import { z } from 'zod' import type { BuildableTable, Encrypted } from '@/types' +import { defaultMatchOpts } from './match-defaults' // ------------------------ // Zod schemas @@ -351,17 +352,15 @@ export class EncryptedColumn { * ``` */ freeTextSearch(opts?: MatchIndexOpts) { - // Provide defaults + // Shared defaults (schema/match-defaults) — one source of truth with the + // EQL v3 domain builders. The factory returns fresh nested objects. + const defaults = defaultMatchOpts() this.indexesValue.match = { - tokenizer: opts?.tokenizer ?? { kind: 'ngram', token_length: 3 }, - token_filters: opts?.token_filters ?? [ - { - kind: 'downcase', - }, - ], - k: opts?.k ?? 6, - m: opts?.m ?? 2048, - include_original: opts?.include_original ?? true, + tokenizer: opts?.tokenizer ?? defaults.tokenizer, + token_filters: opts?.token_filters ?? defaults.token_filters, + k: opts?.k ?? defaults.k, + m: opts?.m ?? defaults.m, + include_original: opts?.include_original ?? defaults.include_original, } return this } diff --git a/packages/stack/src/schema/match-defaults.ts b/packages/stack/src/schema/match-defaults.ts new file mode 100644 index 00000000..e1a4090b --- /dev/null +++ b/packages/stack/src/schema/match-defaults.ts @@ -0,0 +1,54 @@ +import type { MatchIndexOpts } from './index' + +/** + * Fully-resolved match-index options: every field present and non-`undefined`. + * + * `MatchIndexOpts` (the user-facing tuning input) has all fields optional — + * each is `.default(...).optional()` in the zod schema, so its inferred type is + * `T | undefined`. This type pins the BUILT/resolved shape explicitly via + * `NonNullable<...>`, which states the non-null intent directly and is robust + * regardless of `Required<>`'s subtle, `exactOptionalPropertyTypes`-dependent + * stripping semantics. + */ +export type BuiltMatchIndexOpts = { + tokenizer: NonNullable + token_filters: NonNullable + k: NonNullable + m: NonNullable + include_original: NonNullable +} + +/** + * Default match-index parameters — the single source of truth shared by the + * v2 `freeTextSearch()` builder and the v3 domain builders (note + * `include_original: true`, which is the v2 builder default rather than the + * zod-schema default of `false`). + * + * This is a FACTORY (not a shared `const`) so every caller gets fresh, unaliased + * nested objects (`tokenizer`, `token_filters` and the `{ kind: 'downcase' }` + * inside it). A shared const would be shallow-copied by `{ ...DEFAULT }`, leaving + * those nested objects aliased across every column — a caller mutating one built + * config could then corrupt the defaults used by later columns. + */ +export function defaultMatchOpts(): BuiltMatchIndexOpts { + return { + tokenizer: { kind: 'ngram', token_length: 3 }, + token_filters: [{ kind: 'downcase' }], + k: 6, + m: 2048, + include_original: true, + } +} + +/** + * Deep-clone a built match block (`tokenizer` and `token_filters` are its only + * nested values) so no emitted config or stored builder state ever aliases + * another's nested objects — a caller mutating one cannot corrupt the other. + */ +export function cloneMatchOpts(opts: BuiltMatchIndexOpts): BuiltMatchIndexOpts { + return { + ...opts, + tokenizer: { ...opts.tokenizer }, + token_filters: opts.token_filters.map((f) => ({ ...f })), + } +}