@axiom-lattice/pg-stores 5.0.3 → 5.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/.turbo/turbo-build.log +10 -10
  2. package/CHANGELOG.md +95 -0
  3. package/dist/index.d.mts +178 -2
  4. package/dist/index.d.ts +178 -2
  5. package/dist/index.js +1243 -197
  6. package/dist/index.js.map +1 -1
  7. package/dist/index.mjs +1167 -124
  8. package/dist/index.mjs.map +1 -1
  9. package/package.json +3 -3
  10. package/src/__tests__/PostgreSQLInviteLinkStore.test.ts +63 -0
  11. package/src/__tests__/PostgreSQLOidcIdentityClaimStore.test.ts +123 -0
  12. package/src/__tests__/PostgreSQLOidcProviderConfigStore.test.ts +233 -0
  13. package/src/__tests__/PostgreSQLPlatformInvitationStore.test.ts +222 -0
  14. package/src/__tests__/PostgreSQLPlatformMemberStore.test.ts +146 -0
  15. package/src/__tests__/PostgreSQLPlatformSettingsStore.test.ts +115 -0
  16. package/src/__tests__/PostgreSQLUserExternalIdentityStore.test.ts +125 -0
  17. package/src/__tests__/PostgreSQLUserStore.test.ts +75 -0
  18. package/src/createPgStoreConfig.ts +34 -0
  19. package/src/index.ts +16 -0
  20. package/src/migrations/invite_link_migration.ts +35 -0
  21. package/src/migrations/oidc_identity_claim_invite_migration.ts +29 -0
  22. package/src/migrations/oidc_identity_claim_migration.ts +39 -0
  23. package/src/migrations/oidc_provider_allowed_hosts_migration.ts +19 -0
  24. package/src/migrations/oidc_provider_config_migration.ts +27 -0
  25. package/src/migrations/platform_invitation_migration.ts +22 -0
  26. package/src/migrations/platform_member_migration.ts +21 -0
  27. package/src/migrations/platform_settings_migration.ts +21 -0
  28. package/src/migrations/user_email_lower_unique_migration.ts +22 -0
  29. package/src/migrations/user_external_identity_migration.ts +27 -0
  30. package/src/stores/PostgreSQLInviteLinkStore.ts +134 -0
  31. package/src/stores/PostgreSQLOidcIdentityClaimStore.ts +251 -0
  32. package/src/stores/PostgreSQLOidcProviderConfigStore.ts +222 -0
  33. package/src/stores/PostgreSQLPlatformInvitationStore.ts +133 -0
  34. package/src/stores/PostgreSQLPlatformMemberStore.ts +105 -0
  35. package/src/stores/PostgreSQLPlatformSettingsStore.ts +98 -0
  36. package/src/stores/PostgreSQLUserExternalIdentityStore.ts +134 -0
  37. package/src/stores/PostgreSQLUserStore.ts +3 -2
@@ -0,0 +1,27 @@
1
+ import type { Migration } from "./migration";
2
+
3
+ export const createOidcProviderConfigsTable: Migration = {
4
+ version: 211,
5
+ name: "create_oidc_provider_configs",
6
+ up: async (client) => {
7
+ await client.query(`
8
+ CREATE TABLE IF NOT EXISTS lattice_oidc_provider_configs (
9
+ id VARCHAR(64) PRIMARY KEY,
10
+ display_name TEXT NOT NULL,
11
+ issuer TEXT NOT NULL,
12
+ client_id TEXT NOT NULL,
13
+ client_secret TEXT NOT NULL,
14
+ scopes TEXT NOT NULL,
15
+ enabled BOOLEAN NOT NULL DEFAULT TRUE,
16
+ jit_provisioning BOOLEAN NOT NULL DEFAULT FALSE,
17
+ auto_link_email BOOLEAN NOT NULL DEFAULT FALSE,
18
+ default_tenant_id TEXT,
19
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
20
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
21
+ )
22
+ `);
23
+ },
24
+ down: async (client) => {
25
+ await client.query("DROP TABLE IF EXISTS lattice_oidc_provider_configs");
26
+ },
27
+ };
@@ -0,0 +1,22 @@
1
+ import type { Migration } from "./migration";
2
+
3
+ /** Creates the platform invitation table keyed by normalized email. */
4
+ export const createPlatformInvitationsTable: Migration = {
5
+ version: 213,
6
+ name: "create_platform_invitations",
7
+ up: async (client) => {
8
+ await client.query(`
9
+ CREATE TABLE IF NOT EXISTS lattice_platform_invitations (
10
+ email VARCHAR(320) PRIMARY KEY,
11
+ role VARCHAR(16) NOT NULL,
12
+ invited_by VARCHAR(255),
13
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
14
+ expires_at TIMESTAMPTZ,
15
+ consumed_at TIMESTAMPTZ
16
+ )
17
+ `);
18
+ },
19
+ down: async (client) => {
20
+ await client.query("DROP TABLE IF EXISTS lattice_platform_invitations");
21
+ },
22
+ };
@@ -0,0 +1,21 @@
1
+ import type { Migration } from "./migration";
2
+
3
+ /** Creates the platform member table keyed by user id. */
4
+ export const createPlatformMembersTable: Migration = {
5
+ version: 212,
6
+ name: "create_platform_members",
7
+ up: async (client) => {
8
+ await client.query(`
9
+ CREATE TABLE IF NOT EXISTS lattice_platform_members (
10
+ user_id VARCHAR(255) PRIMARY KEY,
11
+ role VARCHAR(16) NOT NULL,
12
+ granted_by VARCHAR(255),
13
+ granted_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
14
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
15
+ )
16
+ `);
17
+ },
18
+ down: async (client) => {
19
+ await client.query("DROP TABLE IF EXISTS lattice_platform_members");
20
+ },
21
+ };
@@ -0,0 +1,21 @@
1
+ import type { Migration } from "./migration";
2
+
3
+ /** Creates the single-row durable platform settings table. */
4
+ export const createPlatformSettingsTable: Migration = {
5
+ version: 217,
6
+ name: "create_platform_settings",
7
+ up: async (client) => {
8
+ await client.query(`
9
+ CREATE TABLE IF NOT EXISTS lattice_platform_settings (
10
+ id VARCHAR(32) PRIMARY KEY,
11
+ document JSONB NOT NULL,
12
+ version INTEGER NOT NULL,
13
+ updated_by VARCHAR(255),
14
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
15
+ )
16
+ `);
17
+ },
18
+ down: async (client) => {
19
+ await client.query("DROP TABLE IF EXISTS lattice_platform_settings");
20
+ },
21
+ };
@@ -0,0 +1,22 @@
1
+ import type { Migration } from "./migration";
2
+
3
+ /**
4
+ * Enforce case-insensitive email uniqueness.
5
+ *
6
+ * `lower(email)` is the lookup identity used by every UserStore, so the
7
+ * database must reject rows that differ only by case. This migration fails
8
+ * closed on an existing database that still has duplicate lower(email) rows.
9
+ */
10
+ export const createUserEmailLowerUniqueIndex: Migration = {
11
+ version: 210,
12
+ name: "create_user_email_lower_unique_index",
13
+ up: async (client) => {
14
+ await client.query(`
15
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_lattice_users_email_lower
16
+ ON lattice_users (lower(email))
17
+ `);
18
+ },
19
+ down: async (client) => {
20
+ await client.query("DROP INDEX IF EXISTS idx_lattice_users_email_lower");
21
+ },
22
+ };
@@ -0,0 +1,27 @@
1
+ import type { Migration } from "./migration";
2
+
3
+ export const createUserExternalIdentityTable: Migration = {
4
+ version: 209,
5
+ name: "create_user_external_identity",
6
+ up: async (client) => {
7
+ await client.query(`
8
+ CREATE TABLE IF NOT EXISTS lattice_user_identities (
9
+ user_id VARCHAR(255) NOT NULL,
10
+ provider_id VARCHAR(255) NOT NULL,
11
+ issuer TEXT NOT NULL,
12
+ subject TEXT NOT NULL,
13
+ email TEXT,
14
+ linked_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
15
+ PRIMARY KEY (provider_id, issuer, subject)
16
+ )
17
+ `);
18
+ await client.query(`
19
+ CREATE INDEX IF NOT EXISTS idx_lattice_user_identities_user
20
+ ON lattice_user_identities (user_id)
21
+ `);
22
+ },
23
+ down: async (client) => {
24
+ await client.query("DROP INDEX IF EXISTS idx_lattice_user_identities_user");
25
+ await client.query("DROP TABLE IF EXISTS lattice_user_identities");
26
+ },
27
+ };
@@ -0,0 +1,134 @@
1
+ import { encrypt, safeDecrypt } from "@axiom-lattice/core";
2
+ import {
3
+ normalizeInviteLinkEmail,
4
+ type CreateInviteLinkInput,
5
+ type InviteLink,
6
+ type InviteLinkScope,
7
+ type InviteLinkStore,
8
+ } from "@axiom-lattice/protocols";
9
+ import type { PlatformRole, UserTenantRole } from "@axiom-lattice/protocols";
10
+ import type { Pool } from "pg";
11
+
12
+ const TABLE = "lattice_invite_links";
13
+
14
+ interface LinkRow {
15
+ id: string;
16
+ scope: InviteLinkScope;
17
+ token_hash: string;
18
+ token_encrypted: string;
19
+ email: string | null;
20
+ platform_role: PlatformRole | null;
21
+ tenant_id: string | null;
22
+ tenant_role: UserTenantRole | null;
23
+ created_by: string | null;
24
+ created_at: Date;
25
+ updated_at: Date;
26
+ expires_at: Date;
27
+ max_uses: number;
28
+ used_count: number;
29
+ revoked_at: Date | null;
30
+ }
31
+
32
+ function mapRow(row: LinkRow): InviteLink {
33
+ return {
34
+ id: row.id,
35
+ scope: row.scope,
36
+ token: safeDecrypt(row.token_encrypted, `Invite link "${row.id}" token`),
37
+ tokenHash: row.token_hash,
38
+ ...(row.email !== null ? { email: row.email } : {}),
39
+ ...(row.platform_role !== null ? { platformRole: row.platform_role } : {}),
40
+ ...(row.tenant_id !== null ? { tenantId: row.tenant_id } : {}),
41
+ ...(row.tenant_role !== null ? { tenantRole: row.tenant_role } : {}),
42
+ ...(row.created_by !== null ? { createdBy: row.created_by } : {}),
43
+ createdAt: row.created_at,
44
+ updatedAt: row.updated_at,
45
+ expiresAt: row.expires_at,
46
+ maxUses: Number(row.max_uses),
47
+ usedCount: Number(row.used_count),
48
+ ...(row.revoked_at !== null ? { revokedAt: row.revoked_at } : {}),
49
+ };
50
+ }
51
+
52
+ /** PostgreSQL implementation of {@link InviteLinkStore}. */
53
+ export class PostgreSQLInviteLinkStore implements InviteLinkStore {
54
+ constructor(private readonly options: { pool: Pool }) {}
55
+
56
+ private get pool(): Pool {
57
+ return this.options.pool;
58
+ }
59
+
60
+ async list(): Promise<InviteLink[]> {
61
+ const result = await this.pool.query<LinkRow>(
62
+ `SELECT * FROM ${TABLE} ORDER BY created_at DESC, id DESC`,
63
+ );
64
+ return result.rows.map(mapRow);
65
+ }
66
+
67
+ async get(id: string): Promise<InviteLink | null> {
68
+ const result = await this.pool.query<LinkRow>(
69
+ `SELECT * FROM ${TABLE} WHERE id = $1`,
70
+ [id],
71
+ );
72
+ return result.rows[0] ? mapRow(result.rows[0]) : null;
73
+ }
74
+
75
+ async getByTokenHash(tokenHash: string): Promise<InviteLink | null> {
76
+ const result = await this.pool.query<LinkRow>(
77
+ `SELECT * FROM ${TABLE} WHERE token_hash = $1`,
78
+ [tokenHash],
79
+ );
80
+ return result.rows[0] ? mapRow(result.rows[0]) : null;
81
+ }
82
+
83
+ async create(input: CreateInviteLinkInput): Promise<InviteLink> {
84
+ const now = new Date();
85
+ const result = await this.pool.query<LinkRow>(
86
+ `INSERT INTO ${TABLE}
87
+ (id, scope, token_hash, token_encrypted, email, platform_role,
88
+ tenant_id, tenant_role, created_by, created_at, updated_at,
89
+ expires_at, max_uses, used_count)
90
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $10, $11, $12, 0)
91
+ RETURNING *`,
92
+ [
93
+ input.id,
94
+ input.scope,
95
+ input.tokenHash,
96
+ encrypt(input.token),
97
+ input.email ? normalizeInviteLinkEmail(input.email) : null,
98
+ input.platformRole ?? null,
99
+ input.tenantId ?? null,
100
+ input.tenantRole ?? null,
101
+ input.createdBy ?? null,
102
+ now,
103
+ input.expiresAt,
104
+ input.maxUses,
105
+ ],
106
+ );
107
+ return mapRow(result.rows[0]);
108
+ }
109
+
110
+ async consume(tokenHash: string, now: Date): Promise<InviteLink | null> {
111
+ const result = await this.pool.query<LinkRow>(
112
+ `UPDATE ${TABLE}
113
+ SET used_count = used_count + 1, updated_at = $1
114
+ WHERE token_hash = $2
115
+ AND revoked_at IS NULL
116
+ AND expires_at > $1
117
+ AND used_count < max_uses
118
+ RETURNING *`,
119
+ [now, tokenHash],
120
+ );
121
+ return result.rows[0] ? mapRow(result.rows[0]) : null;
122
+ }
123
+
124
+ async revoke(id: string, now: Date): Promise<InviteLink | null> {
125
+ const result = await this.pool.query<LinkRow>(
126
+ `UPDATE ${TABLE}
127
+ SET revoked_at = COALESCE(revoked_at, $1), updated_at = $1
128
+ WHERE id = $2
129
+ RETURNING *`,
130
+ [now, id],
131
+ );
132
+ return result.rows[0] ? mapRow(result.rows[0]) : null;
133
+ }
134
+ }
@@ -0,0 +1,251 @@
1
+ import {
2
+ normalizeOidcIdentityEmail,
3
+ OidcIdentityClaimConflictError,
4
+ type CreateOidcIdentityClaimInput,
5
+ type OidcIdentityClaim,
6
+ type OidcIdentityClaimApproval,
7
+ type OidcIdentityClaimStatus,
8
+ type OidcIdentityClaimStore,
9
+ type UpdateOidcIdentityClaimInput,
10
+ } from "@axiom-lattice/protocols";
11
+ import type { Pool } from "pg";
12
+
13
+ const TABLE = "lattice_oidc_identity_claims";
14
+
15
+ interface ClaimRow {
16
+ id: string;
17
+ provider_id: string;
18
+ issuer: string;
19
+ subject: string;
20
+ email: string;
21
+ name: string | null;
22
+ picture: string | null;
23
+ status: OidcIdentityClaimStatus;
24
+ created_at: Date;
25
+ updated_at: Date;
26
+ expires_at: Date;
27
+ reviewed_by: string | null;
28
+ reviewed_at: Date | null;
29
+ linked_user_id: string | null;
30
+ source: "email_completion" | "invite_link" | null;
31
+ invite_link_id: string | null;
32
+ requested_user_id: string | null;
33
+ requested_platform_role: string | null;
34
+ requested_tenant_id: string | null;
35
+ requested_tenant_role: string | null;
36
+ }
37
+
38
+ function mapRow(row: ClaimRow): OidcIdentityClaim {
39
+ return {
40
+ id: row.id,
41
+ providerId: row.provider_id,
42
+ issuer: row.issuer,
43
+ subject: row.subject,
44
+ email: row.email,
45
+ ...(row.name !== null ? { name: row.name } : {}),
46
+ ...(row.picture !== null ? { picture: row.picture } : {}),
47
+ status: row.status,
48
+ createdAt: row.created_at,
49
+ updatedAt: row.updated_at,
50
+ expiresAt: row.expires_at,
51
+ ...(row.reviewed_by !== null ? { reviewedBy: row.reviewed_by } : {}),
52
+ ...(row.reviewed_at !== null ? { reviewedAt: row.reviewed_at } : {}),
53
+ ...(row.linked_user_id !== null
54
+ ? { linkedUserId: row.linked_user_id }
55
+ : {}),
56
+ ...(row.source !== null ? { source: row.source } : {}),
57
+ ...(row.invite_link_id !== null
58
+ ? { inviteLinkId: row.invite_link_id }
59
+ : {}),
60
+ ...(row.requested_user_id !== null
61
+ ? { requestedUserId: row.requested_user_id }
62
+ : {}),
63
+ ...(row.requested_platform_role !== null
64
+ ? { requestedPlatformRole: row.requested_platform_role as never }
65
+ : {}),
66
+ ...(row.requested_tenant_id !== null
67
+ ? { requestedTenantId: row.requested_tenant_id }
68
+ : {}),
69
+ ...(row.requested_tenant_role !== null
70
+ ? { requestedTenantRole: row.requested_tenant_role as never }
71
+ : {}),
72
+ };
73
+ }
74
+
75
+ function isUniqueViolation(error: unknown): boolean {
76
+ return (
77
+ typeof error === "object" &&
78
+ error !== null &&
79
+ (error as { code?: unknown }).code === "23505"
80
+ );
81
+ }
82
+
83
+ /** PostgreSQL implementation of {@link OidcIdentityClaimStore}. */
84
+ export class PostgreSQLOidcIdentityClaimStore
85
+ implements OidcIdentityClaimStore
86
+ {
87
+ private readonly pool: Pool;
88
+
89
+ constructor(options: { pool: Pool }) {
90
+ this.pool = options.pool;
91
+ }
92
+
93
+ async list(status?: OidcIdentityClaimStatus): Promise<OidcIdentityClaim[]> {
94
+ const result = status
95
+ ? await this.pool.query<ClaimRow>(
96
+ `SELECT * FROM ${TABLE} WHERE status = $1
97
+ ORDER BY created_at ASC, id ASC`,
98
+ [status],
99
+ )
100
+ : await this.pool.query<ClaimRow>(
101
+ `SELECT * FROM ${TABLE} ORDER BY created_at ASC, id ASC`,
102
+ );
103
+ return result.rows.map(mapRow);
104
+ }
105
+
106
+ async get(id: string): Promise<OidcIdentityClaim | null> {
107
+ const result = await this.pool.query<ClaimRow>(
108
+ `SELECT * FROM ${TABLE} WHERE id = $1`,
109
+ [id],
110
+ );
111
+ return result.rows[0] ? mapRow(result.rows[0]) : null;
112
+ }
113
+
114
+ async getPendingBySubject(
115
+ providerId: string,
116
+ issuer: string,
117
+ subject: string,
118
+ now: Date,
119
+ ): Promise<OidcIdentityClaim | null> {
120
+ const result = await this.pool.query<ClaimRow>(
121
+ `SELECT * FROM ${TABLE}
122
+ WHERE provider_id = $1 AND issuer = $2 AND subject = $3
123
+ AND status = 'pending' AND expires_at > $4`,
124
+ [providerId, issuer, subject, now],
125
+ );
126
+ return result.rows[0] ? mapRow(result.rows[0]) : null;
127
+ }
128
+
129
+ async create(input: CreateOidcIdentityClaimInput): Promise<OidcIdentityClaim> {
130
+ const now = new Date();
131
+ await this.pool.query(
132
+ `UPDATE ${TABLE}
133
+ SET status = 'expired', updated_at = $1
134
+ WHERE provider_id = $2 AND issuer = $3 AND subject = $4
135
+ AND status = 'pending' AND expires_at <= $1`,
136
+ [now, input.providerId, input.issuer, input.subject],
137
+ );
138
+
139
+ try {
140
+ const result = await this.pool.query<ClaimRow>(
141
+ `INSERT INTO ${TABLE}
142
+ (id, provider_id, issuer, subject, email, name, picture, status,
143
+ created_at, updated_at, expires_at, source, invite_link_id,
144
+ requested_user_id, requested_platform_role, requested_tenant_id,
145
+ requested_tenant_role)
146
+ VALUES ($1, $2, $3, $4, $5, $6, $7, 'pending', $8, $8, $9,
147
+ $10, $11, $12, $13, $14, $15)
148
+ RETURNING *`,
149
+ [
150
+ input.id,
151
+ input.providerId,
152
+ input.issuer,
153
+ input.subject,
154
+ normalizeOidcIdentityEmail(input.email),
155
+ input.name ?? null,
156
+ input.picture ?? null,
157
+ now,
158
+ input.expiresAt,
159
+ input.source ?? null,
160
+ input.inviteLinkId ?? null,
161
+ input.requestedUserId ?? null,
162
+ input.requestedPlatformRole ?? null,
163
+ input.requestedTenantId ?? null,
164
+ input.requestedTenantRole ?? null,
165
+ ],
166
+ );
167
+ return mapRow(result.rows[0]);
168
+ } catch (error) {
169
+ if (isUniqueViolation(error)) {
170
+ throw new OidcIdentityClaimConflictError(
171
+ input.providerId,
172
+ input.issuer,
173
+ input.subject,
174
+ );
175
+ }
176
+ throw error;
177
+ }
178
+ }
179
+
180
+ async updatePending(
181
+ id: string,
182
+ updates: UpdateOidcIdentityClaimInput,
183
+ now: Date,
184
+ ): Promise<OidcIdentityClaim | null> {
185
+ const sets: string[] = [];
186
+ const params: unknown[] = [];
187
+ if (updates.email !== undefined) {
188
+ params.push(normalizeOidcIdentityEmail(updates.email));
189
+ sets.push(`email = $${params.length}`);
190
+ }
191
+ if (updates.name !== undefined) {
192
+ params.push(updates.name);
193
+ sets.push(`name = $${params.length}`);
194
+ }
195
+ if (updates.picture !== undefined) {
196
+ params.push(updates.picture);
197
+ sets.push(`picture = $${params.length}`);
198
+ }
199
+ if (sets.length === 0) return this.get(id);
200
+ params.push(now);
201
+ sets.push(`updated_at = $${params.length}`);
202
+ params.push(id);
203
+ const result = await this.pool.query<ClaimRow>(
204
+ `UPDATE ${TABLE} SET ${sets.join(", ")}
205
+ WHERE id = $${params.length} AND status = 'pending'
206
+ RETURNING *`,
207
+ params,
208
+ );
209
+ return result.rows[0] ? mapRow(result.rows[0]) : null;
210
+ }
211
+
212
+ async approve(
213
+ id: string,
214
+ approval: OidcIdentityClaimApproval,
215
+ now: Date,
216
+ ): Promise<OidcIdentityClaim | null> {
217
+ const result = await this.pool.query<ClaimRow>(
218
+ `UPDATE ${TABLE}
219
+ SET status = 'approved', reviewed_by = $1, reviewed_at = $2,
220
+ linked_user_id = $3, updated_at = $2
221
+ WHERE id = $4 AND status = 'pending'
222
+ RETURNING *`,
223
+ [approval.reviewedBy, now, approval.linkedUserId, id],
224
+ );
225
+ return result.rows[0] ? mapRow(result.rows[0]) : null;
226
+ }
227
+
228
+ async reject(
229
+ id: string,
230
+ reviewedBy: string,
231
+ now: Date,
232
+ ): Promise<OidcIdentityClaim | null> {
233
+ const result = await this.pool.query<ClaimRow>(
234
+ `UPDATE ${TABLE}
235
+ SET status = 'rejected', reviewed_by = $1, reviewed_at = $2,
236
+ updated_at = $2
237
+ WHERE id = $3 AND status = 'pending'
238
+ RETURNING *`,
239
+ [reviewedBy, now, id],
240
+ );
241
+ return result.rows[0] ? mapRow(result.rows[0]) : null;
242
+ }
243
+
244
+ async delete(id: string): Promise<boolean> {
245
+ const result = await this.pool.query(
246
+ `DELETE FROM ${TABLE} WHERE id = $1`,
247
+ [id],
248
+ );
249
+ return (result.rowCount ?? 0) > 0;
250
+ }
251
+ }