@rebasepro/server-postgresql 0.0.1-canary.ca2cb6e → 0.0.1-canary.dbf160a

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.
@@ -0,0 +1,592 @@
1
+ /**
2
+ * Introspection logic — pure functions and the pipeline that transforms
3
+ * raw PostgreSQL metadata into Rebase collection definition files.
4
+ *
5
+ * This module contains NO side-effects: no fs writes, no pg.Client creation,
6
+ * no process.exit. It is imported by introspect-db.ts (the CLI entry-point)
7
+ * and consumed directly by tests.
8
+ */
9
+
10
+ // ── Typed interfaces for SQL query results ────────────────────────────
11
+
12
+ export interface TableRow {
13
+ table_name: string;
14
+ }
15
+
16
+ export interface TableColumn {
17
+ table_name: string;
18
+ column_name: string;
19
+ data_type: string;
20
+ udt_name: string;
21
+ is_nullable: string;
22
+ column_default: string | null;
23
+ }
24
+
25
+ export interface EnumValue {
26
+ enum_name: string;
27
+ enum_value: string;
28
+ sort_order: number;
29
+ }
30
+
31
+ export interface PrimaryKeyRow {
32
+ table_name: string;
33
+ column_name: string;
34
+ }
35
+
36
+ export interface ForeignKeyRow {
37
+ table_name: string;
38
+ column_name: string;
39
+ foreign_table_name: string;
40
+ foreign_column_name: string;
41
+ }
42
+
43
+ export interface TableMeta {
44
+ name: string;
45
+ columns: TableColumn[];
46
+ pks: string[];
47
+ fks: ForeignKeyRow[];
48
+ }
49
+
50
+ // ── Irregular plurals that naive rules can't handle ───────────────────
51
+
52
+ const IRREGULAR_SINGULARS: Record<string, string> = {
53
+ people: "person",
54
+ children: "child",
55
+ men: "man",
56
+ women: "woman",
57
+ mice: "mouse",
58
+ geese: "goose",
59
+ teeth: "tooth",
60
+ feet: "foot",
61
+ data: "datum",
62
+ media: "medium",
63
+ criteria: "criterion",
64
+ phenomena: "phenomenon",
65
+ };
66
+
67
+ /** Words ending in 's' that are already singular. */
68
+ const UNCOUNTABLE = new Set([
69
+ "status", "campus", "virus", "bus", "plus", "census",
70
+ "diagnosis", "analysis", "basis", "crisis", "thesis",
71
+ "synopsis", "parenthesis", "hypothesis", "emphasis",
72
+ "news", "series", "species", "means", "athletics",
73
+ "economics", "electronics", "mathematics", "physics",
74
+ "politics", "statistics",
75
+ ]);
76
+
77
+ export function singularize(word: string): string {
78
+ const lower = word.toLowerCase();
79
+
80
+ // Check irregular forms
81
+ if (IRREGULAR_SINGULARS[lower]) {
82
+ // Preserve the original casing of the first character
83
+ const singular = IRREGULAR_SINGULARS[lower];
84
+ return word[0] === word[0].toUpperCase()
85
+ ? singular.charAt(0).toUpperCase() + singular.slice(1)
86
+ : singular;
87
+ }
88
+
89
+ // Check uncountable
90
+ if (UNCOUNTABLE.has(lower)) return word;
91
+
92
+ // Latin/Greek -es endings (diagnosis -> diagnosis is uncountable, but "addresses" -> "address")
93
+ if (lower.endsWith("ices") && lower.length > 5) {
94
+ // e.g. "indices" -> "index", "vertices" -> "vertex"
95
+ return word.slice(0, -4) + "ex";
96
+ }
97
+ if (lower.endsWith("ies") && lower.length > 3) {
98
+ return word.slice(0, -3) + "y";
99
+ }
100
+ if (lower.endsWith("ves")) {
101
+ // e.g. "wolves" -> "wolf", "leaves" -> "leaf"
102
+ return word.slice(0, -3) + "f";
103
+ }
104
+ if (lower.endsWith("ches") || lower.endsWith("shes") || lower.endsWith("sses") || lower.endsWith("xes") || lower.endsWith("zes")) {
105
+ return word.slice(0, -2);
106
+ }
107
+ if (lower.endsWith("ses") && !lower.endsWith("sses")) {
108
+ // e.g. "responses" -> "response", "databases" -> "database"
109
+ return word.slice(0, -1);
110
+ }
111
+ if (lower.endsWith("s") && !lower.endsWith("ss") && !lower.endsWith("us") && !lower.endsWith("is")) {
112
+ return word.slice(0, -1);
113
+ }
114
+
115
+ return word;
116
+ }
117
+
118
+ /**
119
+ * Convert a snake_case name to a human-readable Title Case label.
120
+ * e.g. "created_at" -> "Created At", "customer_id" -> "Customer Id"
121
+ */
122
+ export function humanize(snakeName: string): string {
123
+ return snakeName
124
+ .replace(/_/g, " ")
125
+ .replace(/\b\w/g, (c) => c.toUpperCase());
126
+ }
127
+
128
+ /**
129
+ * Convert a snake_case table name to a camelCase + "Collection" variable name.
130
+ * e.g. "company_token" -> "companyTokenCollection"
131
+ */
132
+ export function toCollectionVarName(tableName: string): string {
133
+ return tableName.replace(/_([a-z])/g, (_g, letter: string) => letter.toUpperCase()) + "Collection";
134
+ }
135
+
136
+ export function getIconForTable(tableName: string): string {
137
+ const table = tableName.toLowerCase();
138
+ if (table.includes("user") || table.includes("account") || table.includes("member") || table.includes("customer") || table.includes("client") || table.includes("patient")) return "Users";
139
+ if (table.includes("post") || table.includes("article") || table.includes("blog") || table.includes("page")) return "FileText";
140
+ if (table.includes("product") || table.includes("item")) return "Package";
141
+ if (table.includes("order") || table.includes("cart") || table.includes("purchase") || table.includes("invoice")) return "ShoppingCart";
142
+ if (table.includes("setting") || table.includes("config")) return "Settings";
143
+ if (table.includes("tag") || table.includes("categor")) return "Tag";
144
+ if (table.includes("image") || table.includes("photo") || table.includes("media") || table.includes("asset")) return "Image";
145
+ if (table.includes("notification") || table.includes("message") || table.includes("email")) return "Mail";
146
+ if (table.includes("log") || table.includes("audit") || table.includes("event")) return "Activity";
147
+ if (table.includes("subscription") || table.includes("plan") || table.includes("billing")) return "CreditCard";
148
+ if (table.includes("comment") || table.includes("review") || table.includes("feedback")) return "MessageCircle";
149
+ return "Database";
150
+ }
151
+
152
+ /**
153
+ * Map a PostgreSQL data type to a Rebase property type.
154
+ */
155
+ export function mapPgType(dataType: string): string {
156
+ const dt = dataType.toLowerCase();
157
+
158
+ // Interval MUST be checked before numeric ("interval" contains "int")
159
+ if (dt === "interval") return "string";
160
+
161
+ // Array types MUST be checked before numeric ("_int4" contains "int")
162
+ if (dt === "array" || dt.startsWith("_")) return "array";
163
+
164
+ // Numeric types
165
+ if (
166
+ dt.includes("int") || // integer, smallint, bigint
167
+ dt.includes("numeric") ||
168
+ dt.includes("decimal") ||
169
+ dt.includes("serial") || // serial, bigserial
170
+ dt === "real" ||
171
+ dt === "float4" ||
172
+ dt === "float8" ||
173
+ dt === "double precision" ||
174
+ dt === "money"
175
+ ) {
176
+ return "number";
177
+ }
178
+
179
+ // Boolean
180
+ if (dt.includes("bool")) return "boolean";
181
+
182
+ // Date / Time
183
+ if (dt.includes("time") || dt.includes("date")) return "date";
184
+
185
+ // JSON
186
+ if (dt === "json" || dt === "jsonb") return "map";
187
+
188
+ // Binary
189
+ if (dt === "bytea") return "string";
190
+
191
+ // Network types
192
+ if (dt === "inet" || dt === "cidr" || dt === "macaddr" || dt === "macaddr8") return "string";
193
+
194
+ // UUID
195
+ if (dt === "uuid") return "string";
196
+
197
+ // Text/varchar/char — default to string
198
+ return "string";
199
+ }
200
+
201
+ // ── Build the enum map from query results ─────────────────────────────
202
+
203
+ export function buildEnumMap(enumValues: EnumValue[]): Map<string, string[]> {
204
+ const enumMap = new Map<string, string[]>();
205
+ for (const ev of enumValues) {
206
+ const existing = enumMap.get(ev.enum_name);
207
+ if (existing) {
208
+ existing.push(ev.enum_value);
209
+ } else {
210
+ enumMap.set(ev.enum_name, [ev.enum_value]);
211
+ }
212
+ }
213
+ return enumMap;
214
+ }
215
+
216
+ // ── Build the tables map from raw query results ───────────────────────
217
+
218
+ export function buildTablesMap(
219
+ tables: TableRow[],
220
+ columns: TableColumn[],
221
+ pks: PrimaryKeyRow[],
222
+ fks: ForeignKeyRow[]
223
+ ): Map<string, TableMeta> {
224
+ const tablesMap = new Map<string, TableMeta>();
225
+ for (const t of tables) {
226
+ tablesMap.set(t.table_name, {
227
+ name: t.table_name,
228
+ columns: columns.filter((c) => c.table_name === t.table_name),
229
+ pks: pks.filter((pk) => pk.table_name === t.table_name).map((pk) => pk.column_name),
230
+ fks: fks.filter((fk) => fk.table_name === t.table_name)
231
+ });
232
+ }
233
+ return tablesMap;
234
+ }
235
+
236
+ // ── Identify join tables ──────────────────────────────────────────────
237
+
238
+ export function identifyJoinTables(tablesMap: Map<string, TableMeta>): Set<string> {
239
+ const joinTables = new Set<string>();
240
+ for (const [tableName, meta] of tablesMap.entries()) {
241
+ if (meta.fks.length === 2) {
242
+ const isLikelyJoinTable = meta.columns.every((c) =>
243
+ meta.fks.some((fk) => fk.column_name === c.column_name) ||
244
+ c.column_name === "id" ||
245
+ c.column_name === "created_at" ||
246
+ c.column_name === "updated_at"
247
+ );
248
+
249
+ if (isLikelyJoinTable) {
250
+ joinTables.add(tableName);
251
+ }
252
+ }
253
+ }
254
+ return joinTables;
255
+ }
256
+
257
+ // ── Generate collection file content ──────────────────────────────────
258
+
259
+ export interface GeneratedFile {
260
+ tableName: string;
261
+ fileName: string;
262
+ content: string;
263
+ }
264
+
265
+ /**
266
+ * Generate the full TypeScript file content for a single collection.
267
+ * Pure function — no I/O.
268
+ */
269
+ export function generateCollectionFile(
270
+ tableName: string,
271
+ meta: TableMeta,
272
+ allFks: ForeignKeyRow[],
273
+ joinTables: Set<string>,
274
+ tablesMap: Map<string, TableMeta>,
275
+ enumMap: Map<string, string[]>,
276
+ ): string {
277
+ const collectionName = humanize(tableName);
278
+ const singular = singularize(collectionName);
279
+ const icon = getIconForTable(tableName);
280
+
281
+ const imports = new Set<string>(['import { PostgresCollection } from "@rebasepro/types";']);
282
+
283
+ let propsOutput = ``;
284
+ const propertiesOrder: string[] = [];
285
+
286
+ // Detect composite primary keys
287
+ const isCompositePk = meta.pks.length > 1;
288
+
289
+ // Map columns
290
+ for (const col of meta.columns) {
291
+ // Skip foreign keys since we handle them as relations
292
+ if (meta.fks.some((fk) => fk.column_name === col.column_name)) continue;
293
+
294
+ propertiesOrder.push(col.column_name);
295
+
296
+ // Check if this column uses a PostgreSQL enum type
297
+ const colEnumValues = enumMap.get(col.udt_name);
298
+ const isEnumColumn = col.data_type === "USER-DEFINED" && colEnumValues !== undefined;
299
+
300
+ const propType = isEnumColumn ? "string" : mapPgType(col.data_type);
301
+ let extra = "";
302
+
303
+ const colNameLower = col.column_name.toLowerCase();
304
+
305
+ // Enum values — generate real enumValues from the PG enum
306
+ if (isEnumColumn && colEnumValues) {
307
+ const enumEntries = colEnumValues
308
+ .map((v) => `{ id: "${v}", label: "${humanize(v)}" }`)
309
+ .join(", ");
310
+ extra = `\n enumValues: [${enumEntries}],`;
311
+ }
312
+
313
+ // Date auto-value heuristics
314
+ if (propType === "date") {
315
+ if (colNameLower === "created_at" || colNameLower === "createdat") {
316
+ extra = `\n autoValue: "on_create",\n readOnly: true,\n hideFromCollection: true,`;
317
+ } else if (colNameLower === "updated_at" || colNameLower === "updatedat") {
318
+ extra = `\n autoValue: "on_update",\n readOnly: true,\n hideFromCollection: true,`;
319
+ } else if (col.column_default && (col.column_default.includes("now()") || col.column_default.includes("CURRENT_TIMESTAMP"))) {
320
+ extra = `\n autoValue: "on_create",\n readOnly: true,`;
321
+ }
322
+ }
323
+
324
+ // Array/Map heuristics
325
+ if (propType === "array") {
326
+ let innerType = "string";
327
+ if (col.udt_name.startsWith("_")) {
328
+ const baseType = col.udt_name.substring(1);
329
+ // Simple recursive check or hardcoded for inner type:
330
+ // We'll just call mapPgType on the baseType
331
+ innerType = mapPgType(baseType);
332
+ }
333
+ extra = `\n of: { type: "${innerType}" },`;
334
+ } else if (propType === "map") {
335
+ extra = `\n keyValue: true,`;
336
+ }
337
+
338
+ // String sub-type heuristics (skip if already handled as enum)
339
+ if (propType === "string" && !isEnumColumn) {
340
+ if (colNameLower.includes("image") || colNameLower.includes("avatar") || colNameLower.includes("photo") || colNameLower.includes("logo") || colNameLower.includes("cover")) {
341
+ extra = `\n storage: {\n storagePath: "${tableName}/${col.column_name}"\n },`;
342
+ } else if (colNameLower === "description" || colNameLower === "summary" || colNameLower === "excerpt") {
343
+ extra = `\n multiline: true,`;
344
+ } else if (colNameLower === "content" || colNameLower === "body") {
345
+ extra = `\n multiline: true,\n markdown: true,`;
346
+ } else if (col.data_type === "text") {
347
+ extra = `\n multiline: true,`;
348
+ }
349
+ }
350
+
351
+ // Identify IDs
352
+ if (meta.pks.includes(col.column_name)) {
353
+ if (isCompositePk) {
354
+ extra += `\n // Part of composite primary key (${meta.pks.join(", ")})`;
355
+ } else if (propType === "number") {
356
+ extra += `\n isId: "increment",`;
357
+ } else if (col.data_type.toLowerCase() === "uuid") {
358
+ extra += `\n isId: "uuid",`;
359
+ } else {
360
+ extra += `\n isId: "uuid", // Verify if this is a UUID or CUID`;
361
+ }
362
+ }
363
+
364
+ if (col.is_nullable === "NO" && !meta.pks.includes(col.column_name) && !col.column_default) {
365
+ extra += `\n validation: {\n required: true\n },`;
366
+ }
367
+
368
+ const humanName = humanize(col.column_name);
369
+
370
+ propsOutput += `
371
+ ${col.column_name}: {
372
+ name: "${humanName}",
373
+ type: "${propType}",${extra}
374
+ },`;
375
+ }
376
+
377
+ // Map Owning Relations (from this table's FKs to other tables)
378
+ for (const fk of meta.fks) {
379
+ const targetTableName = fk.foreign_table_name;
380
+ if (!joinTables.has(targetTableName)) {
381
+ const relName = fk.column_name.replace(/_id$/, "");
382
+ // Push the relation property key, not the FK column name
383
+ propertiesOrder.push(relName);
384
+
385
+ const targetCollectionCamel = toCollectionVarName(targetTableName);
386
+ imports.add(`import ${targetCollectionCamel} from "./${targetTableName}";`);
387
+
388
+ const relHumanName = humanize(relName);
389
+
390
+ propsOutput += `
391
+ ${relName}: {
392
+ name: "${relHumanName}",
393
+ type: "relation",
394
+ target: () => ${targetCollectionCamel},
395
+ cardinality: "one",
396
+ direction: "owning",
397
+ localKey: "${fk.column_name}",
398
+ // mapped from foreign key: ${fk.column_name} -> ${targetTableName}(${fk.foreign_column_name})
399
+ },`;
400
+ }
401
+ }
402
+
403
+ // Map Inverse Relations (1-to-many where OTHER table points to THIS table)
404
+ const inverseFks = allFks.filter((fk) => fk.foreign_table_name === tableName && !joinTables.has(fk.table_name));
405
+ for (const fk of inverseFks) {
406
+ const sourceTableName = fk.table_name;
407
+ propertiesOrder.push(sourceTableName);
408
+
409
+ const targetCollectionCamel = toCollectionVarName(sourceTableName);
410
+ imports.add(`import ${targetCollectionCamel} from "./${sourceTableName}";`);
411
+
412
+ const inverseRelName = fk.column_name.replace(/_id$/, "");
413
+ const relHumanName = humanize(sourceTableName);
414
+
415
+ propsOutput += `
416
+ ${sourceTableName}: {
417
+ name: "${relHumanName}",
418
+ type: "relation",
419
+ target: () => ${targetCollectionCamel},
420
+ cardinality: "many",
421
+ direction: "inverse",
422
+ inverseRelationName: "${inverseRelName}",
423
+ foreignKeyOnTarget: "${fk.column_name}"
424
+ },`;
425
+ }
426
+
427
+ // Map Many-to-Many Relations (Join Tables)
428
+ const relatedJoinTables = Array.from(joinTables).filter((jt) => {
429
+ const jtMeta = tablesMap.get(jt);
430
+ return jtMeta ? jtMeta.fks.some((fk) => fk.foreign_table_name === tableName) : false;
431
+ });
432
+
433
+ for (const jt of relatedJoinTables) {
434
+ const jtMeta = tablesMap.get(jt);
435
+ if (!jtMeta) continue;
436
+
437
+ const joinFks = jtMeta.fks;
438
+
439
+ // Handle self-referencing M2M: both FKs point to the same table
440
+ const selfRefFks = joinFks.filter((fk) => fk.foreign_table_name === tableName);
441
+ if (selfRefFks.length === 2) {
442
+ // Self-referencing M2M — generate a single owning relation
443
+ const thisFk = selfRefFks[0];
444
+ const otherFk = selfRefFks[1];
445
+
446
+ const relPropName = `${tableName}_via_${otherFk.column_name.replace(/_id$/, "")}`;
447
+ propertiesOrder.push(relPropName);
448
+
449
+ // Self-ref: import is the same collection (use a lazy reference)
450
+ const relHumanName = humanize(otherFk.column_name.replace(/_id$/, ""));
451
+
452
+ propsOutput += `
453
+ ${relPropName}: {
454
+ name: "${relHumanName}",
455
+ type: "relation",
456
+ target: () => ${tableName}Collection,
457
+ cardinality: "many",
458
+ direction: "owning",
459
+ through: {
460
+ table: "${jt}",
461
+ sourceColumn: "${thisFk.column_name}",
462
+ targetColumn: "${otherFk.column_name}"
463
+ }
464
+ },`;
465
+ continue;
466
+ }
467
+
468
+ const otherFk = joinFks.find((fk) => fk.foreign_table_name !== tableName);
469
+
470
+ if (otherFk) {
471
+ const targetTableName = otherFk.foreign_table_name;
472
+ propertiesOrder.push(targetTableName);
473
+
474
+ const targetCollectionCamel = toCollectionVarName(targetTableName);
475
+ imports.add(`import ${targetCollectionCamel} from "./${targetTableName}";`);
476
+
477
+ // Determine direction (alphabetically first table is owning)
478
+ const direction = tableName < targetTableName ? "owning" : "inverse";
479
+
480
+ const thisFk = joinFks.find((fk) => fk.foreign_table_name === tableName);
481
+ const relHumanName = humanize(targetTableName);
482
+
483
+ let throughCode = "";
484
+ if (direction === "owning" && thisFk) {
485
+ throughCode = `\n through: {\n table: "${jt}",\n sourceColumn: "${thisFk.column_name}",\n targetColumn: "${otherFk.column_name}"\n }`;
486
+ } else if (direction === "inverse") {
487
+ throughCode = `\n // Make sure the target collection configures the 'through' property.`;
488
+ }
489
+
490
+ propsOutput += `
491
+ ${targetTableName}: {
492
+ name: "${relHumanName}",
493
+ type: "relation",
494
+ target: () => ${targetCollectionCamel},
495
+ cardinality: "many",
496
+ direction: "${direction}",${throughCode}
497
+ },`;
498
+ }
499
+ }
500
+
501
+ const fileContent = `${Array.from(imports).join("\n")}
502
+
503
+ const ${tableName}Collection: PostgresCollection = {
504
+ name: "${collectionName}",
505
+ singularName: "${singular}",
506
+ slug: "${tableName}",
507
+ table: "${tableName}",
508
+ icon: "${icon}",
509
+ group: "App",
510
+ properties: {${propsOutput}
511
+ },
512
+ propertiesOrder: ${JSON.stringify(propertiesOrder, null, 8).replace(/]$/, " ]")}
513
+ };
514
+
515
+ export default ${tableName}Collection;
516
+ `;
517
+
518
+ return fileContent;
519
+ }
520
+
521
+ /**
522
+ * Generate the content for an index.ts file that re-exports all collections.
523
+ */
524
+ export function generateIndexContent(fileNames: string[]): string {
525
+ const sorted = [...fileNames].sort();
526
+ let imports = "";
527
+ let arrayElements = "";
528
+ for (const f of sorted) {
529
+ const varName = toCollectionVarName(f);
530
+ imports += `import ${varName} from "./${f}";\n`;
531
+ arrayElements += ` ${varName},\n`;
532
+ }
533
+ return `${imports}\nexport const collections = [\n${arrayElements}];\n`;
534
+ }
535
+
536
+ /**
537
+ * Merge new exports into existing index.ts content.
538
+ * Returns the merged content string.
539
+ */
540
+ export function mergeIndexContent(existingContent: string, newFileNames: string[]): string {
541
+ const existingImports = new Set(
542
+ [...existingContent.matchAll(/import\s+([a-zA-Z0-9_]+)\s+from\s+"\.\/([^"]+)"/g)].map((m) => m[2])
543
+ );
544
+ const sorted = [...newFileNames].sort();
545
+
546
+ let newImports = "";
547
+ let newElements = "";
548
+
549
+ for (const f of sorted) {
550
+ if (!existingImports.has(f)) {
551
+ const varName = toCollectionVarName(f);
552
+ newImports += `import ${varName} from "./${f}";\n`;
553
+ newElements += ` ${varName},\n`;
554
+ }
555
+ }
556
+
557
+ if (!newImports) return existingContent;
558
+
559
+ // Simple injection logic:
560
+ // Add new imports below the last import or at the top
561
+ const importRegex = /import\s+.*?;/g;
562
+ let lastImportMatch;
563
+ let match;
564
+ while ((match = importRegex.exec(existingContent)) !== null) {
565
+ lastImportMatch = match;
566
+ }
567
+
568
+ let contentWithImports = existingContent;
569
+ if (lastImportMatch) {
570
+ const pos = lastImportMatch.index + lastImportMatch[0].length;
571
+ contentWithImports = existingContent.slice(0, pos) + "\n" + newImports.trimEnd() + existingContent.slice(pos);
572
+ } else {
573
+ contentWithImports = newImports + "\n" + existingContent;
574
+ }
575
+
576
+ // Inject into the `collections = [...]` array
577
+ const arrayRegex = /export\s+const\s+collections\s*=\s*\[([\s\S]*?)\];/;
578
+ return contentWithImports.replace(arrayRegex, (fullMatch, arrayContent) => {
579
+ let mergedArray = arrayContent.trimEnd();
580
+ if (mergedArray && !mergedArray.endsWith(",")) mergedArray += ",";
581
+ if (mergedArray) mergedArray += "\n";
582
+ mergedArray += newElements.trimEnd();
583
+ return `export const collections = [\n ${mergedArray.trim()}\n];`;
584
+ });
585
+ }
586
+
587
+ /**
588
+ * Safely extract the host portion of a database URL for logging.
589
+ */
590
+ export function safeHostFromUrl(url: string): string {
591
+ return url.includes("@") ? url.split("@")[1] : "(local connection)";
592
+ }