@nexa-stack/framework 1.0.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 (81) hide show
  1. package/.env.example +46 -0
  2. package/LICENSE +21 -0
  3. package/README.md +72 -0
  4. package/bin/nexa.mjs +41 -0
  5. package/bin/nexa.ts +334 -0
  6. package/docs/AI.md +69 -0
  7. package/docs/ARCHITECTURE.md +74 -0
  8. package/docs/EXAMPLES.md +114 -0
  9. package/docs/FRAMEWORK.md +226 -0
  10. package/docs/LANGUAGE.md +39 -0
  11. package/docs/README.md +7 -0
  12. package/docs/READY.md +51 -0
  13. package/docs/REFERENCE.md +255 -0
  14. package/docs/START.md +98 -0
  15. package/docs/advanced.md +97 -0
  16. package/docs/authentication.md +54 -0
  17. package/docs/cli.md +15 -0
  18. package/docs/compare.md +51 -0
  19. package/docs/configuration.md +65 -0
  20. package/docs/database.md +396 -0
  21. package/docs/installation.md +57 -0
  22. package/docs/localization.md +47 -0
  23. package/docs/resources.md +75 -0
  24. package/docs/routing.md +59 -0
  25. package/docs/seeding.md +33 -0
  26. package/docs/services.md +146 -0
  27. package/package.json +77 -0
  28. package/packages/auth/src/auth.test.ts +23 -0
  29. package/packages/auth/src/auth.ts +287 -0
  30. package/packages/auth/src/index.ts +17 -0
  31. package/packages/cache/src/index.ts +203 -0
  32. package/packages/client/src/index.ts +49 -0
  33. package/packages/core/src/app.ts +97 -0
  34. package/packages/core/src/config.ts +55 -0
  35. package/packages/core/src/dev.ts +104 -0
  36. package/packages/core/src/fields.test.ts +81 -0
  37. package/packages/core/src/fields.ts +309 -0
  38. package/packages/core/src/index.ts +60 -0
  39. package/packages/core/src/lang.ts +79 -0
  40. package/packages/core/src/loader.ts +42 -0
  41. package/packages/core/src/migrate.ts +91 -0
  42. package/packages/core/src/policy.ts +36 -0
  43. package/packages/core/src/registry.ts +17 -0
  44. package/packages/core/src/reload.ts +92 -0
  45. package/packages/core/src/resource.test.ts +32 -0
  46. package/packages/core/src/resource.ts +87 -0
  47. package/packages/core/src/routes.ts +22 -0
  48. package/packages/core/src/runtime.ts +11 -0
  49. package/packages/database/src/builder.ts +266 -0
  50. package/packages/database/src/database.ts +252 -0
  51. package/packages/database/src/dialect.ts +186 -0
  52. package/packages/database/src/index.ts +6 -0
  53. package/packages/database/src/mysql.ts +114 -0
  54. package/packages/database/src/postgres.ts +117 -0
  55. package/packages/database/src/query.ts +115 -0
  56. package/packages/database/src/sqlite.ts +216 -0
  57. package/packages/database/src/types.ts +104 -0
  58. package/packages/events/src/index.ts +17 -0
  59. package/packages/export/src/index.ts +36 -0
  60. package/packages/log/src/index.ts +38 -0
  61. package/packages/mail/src/index.ts +130 -0
  62. package/packages/notifications/src/index.ts +84 -0
  63. package/packages/plugins/src/index.ts +39 -0
  64. package/packages/queue/src/index.ts +185 -0
  65. package/packages/queue/src/jobs.ts +9 -0
  66. package/packages/schedule/src/index.ts +64 -0
  67. package/packages/server/src/index.ts +1 -0
  68. package/packages/server/src/middleware.ts +143 -0
  69. package/packages/server/src/query.ts +40 -0
  70. package/packages/server/src/router.ts +813 -0
  71. package/packages/sms/src/index.ts +33 -0
  72. package/packages/storage/src/upload.ts +36 -0
  73. package/packages/testing/src/index.ts +67 -0
  74. package/packages/validation/src/index.ts +1 -0
  75. package/packages/validation/src/validate.test.ts +35 -0
  76. package/packages/validation/src/validate.ts +112 -0
  77. package/public/admin.html +369 -0
  78. package/public/compare.html +66 -0
  79. package/public/dev-bar.js +213 -0
  80. package/public/docs.html +315 -0
  81. package/public/index.html +66 -0
@@ -0,0 +1,92 @@
1
+ import { join } from "path";
2
+ import { existsSync, watch } from "fs";
3
+ import { clearRegistry, getResources, registerResource } from "./registry.js";
4
+ import { loadResources, importFreshTs } from "./loader.js";
5
+ import { clearLangCache } from "./lang.js";
6
+ import { clearCustomRoutes, getCustomRoutes } from "./routes.js";
7
+ import { recordDevReload } from "./dev.js";
8
+ import { syncResourceTables } from "./migrate.js";
9
+ import type { Router } from "../../server/src/router.js";
10
+ import type { Database } from "../../database/src/database.js";
11
+ import { log } from "../../log/src/index.js";
12
+ import { config } from "./config.js";
13
+
14
+ let reloadTimer: ReturnType<typeof setTimeout> | null = null;
15
+
16
+ function restoreRegistry(snapshot: ReturnType<typeof getResources>, router: Router): void {
17
+ clearRegistry();
18
+ for (const def of snapshot) registerResource(def);
19
+ router.reloadResources();
20
+ }
21
+
22
+ /** Re-import app + resources and refresh API routes (dev hot reload). */
23
+ export async function reloadApplication(
24
+ router: Router,
25
+ db: Database,
26
+ cwd = process.cwd()
27
+ ): Promise<number> {
28
+ const snapshot = getResources().map((d) => ({ ...d, fields: { ...d.fields } }));
29
+
30
+ clearRegistry();
31
+ clearLangCache();
32
+ clearCustomRoutes();
33
+
34
+ try {
35
+ const appFile = join(cwd, "app.ts");
36
+ if (existsSync(appFile)) await importFreshTs(appFile);
37
+
38
+ const count = await loadResources(cwd, { reload: true });
39
+ const loaded = getResources();
40
+
41
+ if (loaded.length === 0 && snapshot.length > 0) {
42
+ restoreRegistry(snapshot, router);
43
+ throw new Error("Reload produced 0 resources — kept previous registry");
44
+ }
45
+
46
+ await syncResourceTables(db);
47
+ router.reloadResources();
48
+ router.registerCustomRoutes([...getCustomRoutes()]);
49
+ recordDevReload(getResources().length);
50
+ return count;
51
+ } catch (err) {
52
+ if (getResources().length === 0 && snapshot.length > 0) {
53
+ restoreRegistry(snapshot, router);
54
+ }
55
+ recordDevReload(snapshot.length, String(err));
56
+ throw err;
57
+ }
58
+ }
59
+
60
+ function scheduleReload(router: Router, db: Database, cwd: string): void {
61
+ if (reloadTimer) clearTimeout(reloadTimer);
62
+ reloadTimer = setTimeout(async () => {
63
+ try {
64
+ const n = await reloadApplication(router, db, cwd);
65
+ log.info(`Hot reload — ${getResources().length} resource(s)${n ? `, ${n} file(s) in resources/` : ""}`);
66
+ } catch (err) {
67
+ log.error("Hot reload failed", { err: String(err) });
68
+ }
69
+ }, 280);
70
+ }
71
+
72
+ /** Watch app.ts and resources/*.ts — enabled by default outside production. */
73
+ export function startHotReload(router: Router, db: Database, cwd = process.cwd()): void {
74
+ if (config("HOT_RELOAD") === "false") return;
75
+ if (config("NODE_ENV") === "production") return;
76
+
77
+ const appFile = join(cwd, "app.ts");
78
+ if (existsSync(appFile)) {
79
+ watch(appFile, () => scheduleReload(router, db, cwd));
80
+ }
81
+
82
+ const resourcesDir = join(cwd, "resources");
83
+ if (existsSync(resourcesDir)) {
84
+ watch(resourcesDir, { recursive: true }, (_event, file) => {
85
+ if (file && !file.endsWith(".ts")) return;
86
+ if (file?.includes(".nexa-reload-")) return;
87
+ scheduleReload(router, db, cwd);
88
+ });
89
+ }
90
+
91
+ log.info("Hot reload on — edit app.ts or resources/ without restart");
92
+ }
@@ -0,0 +1,32 @@
1
+ import { describe, expect, test } from "vitest";
2
+ import { resource } from "./resource.js";
3
+ import { string, number } from "./fields.js";
4
+ import { getResources } from "./registry.js";
5
+
6
+ describe("resource", () => {
7
+ test("auto registers with crud and api", () => {
8
+ const before = getResources().length;
9
+ resource("items", { name: string().required(), qty: number() }, { label: "عناصر" })
10
+ .auth("admin")
11
+ .admin();
12
+ const def = getResources()[getResources().length - 1];
13
+ expect(getResources().length).toBe(before + 1);
14
+ expect(def.label).toBe("عناصر");
15
+ expect(def.hasAdmin).toBe(true);
16
+ expect(def.authRole).toBe("admin");
17
+ expect(def.softDelete).toBe(true);
18
+ });
19
+
20
+ test("hardDeletes() disables soft delete on admin resources", () => {
21
+ resource("hard_demo", { name: string().required() }).admin().hardDeletes();
22
+ const def = getResources()[getResources().length - 1];
23
+ expect(def.softDelete).toBe(false);
24
+ });
25
+
26
+ test("admin() implies auth when missing", () => {
27
+ resource("locked_demo", { name: string().required() }).admin();
28
+ const def = getResources()[getResources().length - 1];
29
+ expect(def.hasAdmin).toBe(true);
30
+ expect(def.authRole).toBe("admin");
31
+ });
32
+ });
@@ -0,0 +1,87 @@
1
+ import { resolveFields, type FieldBuilder, type FieldDef } from "./fields.js";
2
+ import { registerResource } from "./registry.js";
3
+ import type { ResourcePolicy } from "./policy.js";
4
+ import { policyFromRole } from "./policy.js";
5
+
6
+ type Fields = Record<string, FieldBuilder>;
7
+
8
+ export interface ResourceOptions {
9
+ label?: string;
10
+ labelKey?: string;
11
+ }
12
+
13
+ export interface ResourceDefinition {
14
+ name: string;
15
+ label: string;
16
+ labelKey: string | null;
17
+ table: string;
18
+ fields: Record<string, FieldDef>;
19
+ hasCrud: boolean;
20
+ hasApi: boolean;
21
+ authRole: string | null;
22
+ policy: ResourcePolicy | null;
23
+ hasAdmin: boolean;
24
+ softDelete: boolean;
25
+ }
26
+
27
+ export class Resource {
28
+ private def: ResourceDefinition;
29
+
30
+ constructor(name: string, fields: Fields, opts: ResourceOptions = {}) {
31
+ this.def = {
32
+ name,
33
+ label: opts.label ?? opts.labelKey ?? name,
34
+ labelKey: opts.labelKey ?? null,
35
+ table: name.toLowerCase(),
36
+ fields: resolveFields(fields),
37
+ hasCrud: true,
38
+ hasApi: true,
39
+ authRole: null,
40
+ policy: null,
41
+ hasAdmin: false,
42
+ softDelete: false,
43
+ };
44
+ registerResource(this.def);
45
+ }
46
+
47
+ auth(role = "admin"): this {
48
+ this.def.authRole = role;
49
+ this.def.policy = policyFromRole(role);
50
+ return this;
51
+ }
52
+
53
+ policy(rules: ResourcePolicy | string): this {
54
+ this.def.policy = typeof rules === "string" ? policyFromRole(rules) : rules;
55
+ return this;
56
+ }
57
+
58
+ admin(): this {
59
+ this.def.hasAdmin = true;
60
+ this.def.softDelete = true;
61
+ // Admin UI resources must not be world-writable by default
62
+ if (!this.def.authRole) {
63
+ this.def.authRole = "admin";
64
+ this.def.policy = policyFromRole("admin");
65
+ }
66
+ return this;
67
+ }
68
+
69
+ /** Disable soft delete (admin() enables it by default). */
70
+ hardDeletes(): this {
71
+ this.def.softDelete = false;
72
+ return this;
73
+ }
74
+
75
+ softDeletes(): this {
76
+ this.def.softDelete = true;
77
+ return this;
78
+ }
79
+
80
+ getDefinition(): ResourceDefinition {
81
+ return this.def;
82
+ }
83
+ }
84
+
85
+ export function resource(name: string, fields: Fields, opts?: ResourceOptions): Resource {
86
+ return new Resource(name, fields, opts);
87
+ }
@@ -0,0 +1,22 @@
1
+ import type { AuthUser } from "../../auth/src/auth.js";
2
+
3
+ export type RouteHandler = (
4
+ req: Request,
5
+ user: AuthUser | null
6
+ ) => Promise<Response> | Response;
7
+
8
+ const customRoutes: { method: string; path: string; handler: RouteHandler }[] = [];
9
+
10
+ /** Register a custom API route — mounted when `start()` runs. */
11
+ export function route(method: string, path: string, handler: RouteHandler): void {
12
+ const normalized = path.startsWith("/") ? path : `/${path}`;
13
+ customRoutes.push({ method: method.toUpperCase(), path: normalized, handler });
14
+ }
15
+
16
+ export function getCustomRoutes(): readonly { method: string; path: string; handler: RouteHandler }[] {
17
+ return customRoutes;
18
+ }
19
+
20
+ export function clearCustomRoutes(): void {
21
+ customRoutes.length = 0;
22
+ }
@@ -0,0 +1,11 @@
1
+ import { dirname } from "node:path";
2
+ import { fileURLToPath } from "node:url";
3
+
4
+ /** Directory of the calling ESM module (`import.meta.url`). */
5
+ export function moduleDir(importMetaUrl: string): string {
6
+ return dirname(fileURLToPath(importMetaUrl));
7
+ }
8
+
9
+ export function sleep(ms: number): Promise<void> {
10
+ return new Promise((resolve) => setTimeout(resolve, ms));
11
+ }
@@ -0,0 +1,266 @@
1
+ import type { Database } from "./database.js";
2
+ import type { DbRow } from "./types.js";
3
+ import { safeIdent } from "./query.js";
4
+ import { getResources } from "../../core/src/registry.js";
5
+ import { fieldColumn, foreignKeyFor, pivotTableFor } from "../../core/src/fields.js";
6
+
7
+ type BoolOp = "and" | "or";
8
+
9
+ interface WhereClause {
10
+ sql: string;
11
+ params: unknown[];
12
+ op: BoolOp;
13
+ }
14
+
15
+ /** Fluent query builder */
16
+ export class QueryBuilder {
17
+ private wheres: WhereClause[] = [];
18
+ private orderSql: string | null = null;
19
+ private limitN: number | null = null;
20
+ private offsetN: number | null = null;
21
+ private withRels: string[] = [];
22
+
23
+ constructor(
24
+ private db: Database,
25
+ private table: string
26
+ ) {
27
+ this.table = safeIdent(table, "");
28
+ if (!this.table) throw new Error("Invalid table name");
29
+ }
30
+
31
+ where(column: string, operatorOrValue: unknown, value?: unknown): this {
32
+ return this.addWhere("and", column, operatorOrValue, value);
33
+ }
34
+
35
+ orWhere(column: string, operatorOrValue: unknown, value?: unknown): this {
36
+ return this.addWhere("or", column, operatorOrValue, value);
37
+ }
38
+
39
+ whereIn(column: string, values: unknown[]): this {
40
+ const col = safeIdent(column, "");
41
+ if (!col || values.length === 0) {
42
+ this.wheres.push({ sql: "1=0", params: [], op: "and" });
43
+ return this;
44
+ }
45
+ const ph = values.map(() => "?").join(", ");
46
+ this.wheres.push({ sql: `${col} IN (${ph})`, params: values, op: "and" });
47
+ return this;
48
+ }
49
+
50
+ whereNull(column: string): this {
51
+ const col = safeIdent(column, "");
52
+ if (!col) return this;
53
+ this.wheres.push({ sql: `${col} IS NULL`, params: [], op: "and" });
54
+ return this;
55
+ }
56
+
57
+ whereNotNull(column: string): this {
58
+ const col = safeIdent(column, "");
59
+ if (!col) return this;
60
+ this.wheres.push({ sql: `${col} IS NOT NULL`, params: [], op: "and" });
61
+ return this;
62
+ }
63
+
64
+ orderBy(column: string, direction: "asc" | "desc" = "asc"): this {
65
+ const col = safeIdent(column, "id");
66
+ this.orderSql = `${col} ${direction === "asc" ? "ASC" : "DESC"}`;
67
+ return this;
68
+ }
69
+
70
+ limit(n: number): this {
71
+ this.limitN = Math.max(0, n);
72
+ return this;
73
+ }
74
+
75
+ offset(n: number): this {
76
+ this.offsetN = Math.max(0, n);
77
+ return this;
78
+ }
79
+
80
+ with(...relations: string[]): this {
81
+ this.withRels.push(...relations);
82
+ return this;
83
+ }
84
+
85
+ async get(): Promise<DbRow[]> {
86
+ const { sql, params } = this.buildSelect();
87
+ const rows = await this.db.query(sql, params);
88
+ return this.eager(rows);
89
+ }
90
+
91
+ async first(): Promise<DbRow | null> {
92
+ const prev = this.limitN;
93
+ this.limitN = 1;
94
+ const rows = await this.get();
95
+ this.limitN = prev;
96
+ return rows[0] ?? null;
97
+ }
98
+
99
+ async count(): Promise<number> {
100
+ const { where, params } = this.buildWhere();
101
+ const sql = `SELECT COUNT(*) as total FROM ${this.table} ${where}`;
102
+ const row = await this.db.getOne(sql, params);
103
+ return Number(row?.total ?? 0);
104
+ }
105
+
106
+ async sum(column: string): Promise<number> {
107
+ const col = safeIdent(column, "");
108
+ if (!col) return 0;
109
+ const { where, params } = this.buildWhere();
110
+ const sql = `SELECT COALESCE(SUM(${col}), 0) as total FROM ${this.table} ${where}`;
111
+ const row = await this.db.getOne(sql, params);
112
+ return Number(row?.total ?? 0);
113
+ }
114
+
115
+ async insert(data: Record<string, unknown>): Promise<DbRow> {
116
+ return this.db.insert(this.table, data);
117
+ }
118
+
119
+ async update(data: Record<string, unknown>): Promise<number> {
120
+ const keys = Object.keys(data);
121
+ if (keys.length === 0) return 0;
122
+ const { where, params } = this.buildWhere();
123
+ if (!where) throw new Error("Refusing update without where()");
124
+ const set = keys.map((k) => `${safeIdent(k, k)} = ?`).join(", ");
125
+ await this.db.run(`UPDATE ${this.table} SET ${set} ${where}`, [
126
+ ...keys.map((k) => data[k]),
127
+ ...params,
128
+ ]);
129
+ return this.count();
130
+ }
131
+
132
+ async delete(): Promise<number> {
133
+ const { where, params } = this.buildWhere();
134
+ if (!where) throw new Error("Refusing delete without where()");
135
+ const before = await this.count();
136
+ await this.db.run(`DELETE FROM ${this.table} ${where}`, params);
137
+ return before;
138
+ }
139
+
140
+ private addWhere(
141
+ op: BoolOp,
142
+ column: string,
143
+ operatorOrValue: unknown,
144
+ value?: unknown
145
+ ): this {
146
+ const col = safeIdent(column, "");
147
+ if (!col) return this;
148
+ let operator = "=";
149
+ let val = operatorOrValue;
150
+ if (value !== undefined) {
151
+ operator = String(operatorOrValue);
152
+ val = value;
153
+ }
154
+ const allowed = ["=", "!=", "<>", "<", ">", "<=", ">=", "like", "LIKE"];
155
+ if (!allowed.includes(operator)) operator = "=";
156
+ this.wheres.push({ sql: `${col} ${operator} ?`, params: [val], op });
157
+ return this;
158
+ }
159
+
160
+ private buildWhere(): { where: string; params: unknown[] } {
161
+ if (this.wheres.length === 0) return { where: "", params: [] };
162
+ const params: unknown[] = [];
163
+ let sql = "";
164
+ for (let i = 0; i < this.wheres.length; i++) {
165
+ const w = this.wheres[i];
166
+ if (i === 0) sql = w.sql;
167
+ else sql += ` ${w.op.toUpperCase()} ${w.sql}`;
168
+ params.push(...w.params);
169
+ }
170
+ return { where: `WHERE ${sql}`, params };
171
+ }
172
+
173
+ private buildSelect(): { sql: string; params: unknown[] } {
174
+ const { where, params } = this.buildWhere();
175
+ let sql = `SELECT * FROM ${this.table} ${where}`;
176
+ if (this.orderSql) sql += ` ORDER BY ${this.orderSql}`;
177
+ else sql += ` ORDER BY id DESC`;
178
+ if (this.limitN != null) {
179
+ sql += ` LIMIT ?`;
180
+ params.push(this.limitN);
181
+ }
182
+ if (this.offsetN != null) {
183
+ sql += ` OFFSET ?`;
184
+ params.push(this.offsetN);
185
+ }
186
+ return { sql, params };
187
+ }
188
+
189
+ private async eager(rows: DbRow[]): Promise<DbRow[]> {
190
+ if (rows.length === 0 || this.withRels.length === 0) return rows;
191
+ const resource = getResources().find((r) => r.table === this.table || r.name === this.table);
192
+ if (!resource) return rows;
193
+
194
+ const out = rows.map((r) => ({ ...r }));
195
+ for (const relName of this.withRels) {
196
+ const field = resource.fields[relName];
197
+ if (!field?.relation) continue;
198
+
199
+ if (field.type === "relation" && field.relationMode === "belongsTo") {
200
+ const col = fieldColumn(relName, field)!;
201
+ const ids = [...new Set(out.map((r) => Number(r[col])).filter((id) => id > 0))];
202
+ if (ids.length === 0) continue;
203
+ const related = await this.db.query(
204
+ `SELECT * FROM ${safeIdent(field.relation)} WHERE id IN (${ids.map(() => "?").join(",")})`,
205
+ ids
206
+ );
207
+ const map = new Map(related.map((r) => [Number(r.id), r]));
208
+ for (const row of out) {
209
+ row[relName] = map.get(Number(row[col])) ?? null;
210
+ }
211
+ continue;
212
+ }
213
+
214
+ if (field.type === "hasMany" && field.relation) {
215
+ const fk = foreignKeyFor(resource.table);
216
+ const ids = out.map((r) => Number(r.id));
217
+ const related = await this.db.query(
218
+ `SELECT * FROM ${safeIdent(field.relation)} WHERE ${fk} IN (${ids.map(() => "?").join(",")})`,
219
+ ids
220
+ );
221
+ const grouped = new Map<number, DbRow[]>();
222
+ for (const r of related) {
223
+ const key = Number(r[fk]);
224
+ if (!grouped.has(key)) grouped.set(key, []);
225
+ grouped.get(key)!.push(r);
226
+ }
227
+ for (const row of out) {
228
+ row[relName] = grouped.get(Number(row.id)) ?? [];
229
+ }
230
+ continue;
231
+ }
232
+
233
+ if (field.type === "belongsToMany" && field.relation) {
234
+ const pivot = field.pivot ?? pivotTableFor(resource.table, field.relation);
235
+ const localKey = foreignKeyFor(resource.table);
236
+ const foreignKey = foreignKeyFor(field.relation);
237
+ const ids = out.map((r) => Number(r.id));
238
+ const pivots = await this.db.query(
239
+ `SELECT * FROM ${safeIdent(pivot)} WHERE ${localKey} IN (${ids.map(() => "?").join(",")})`,
240
+ ids
241
+ );
242
+ const foreignIds = [...new Set(pivots.map((p) => Number(p[foreignKey])))];
243
+ const related =
244
+ foreignIds.length === 0
245
+ ? []
246
+ : await this.db.query(
247
+ `SELECT * FROM ${safeIdent(field.relation)} WHERE id IN (${foreignIds.map(() => "?").join(",")})`,
248
+ foreignIds
249
+ );
250
+ const relMap = new Map(related.map((r) => [Number(r.id), r]));
251
+ const byLocal = new Map<number, DbRow[]>();
252
+ for (const p of pivots) {
253
+ const lid = Number(p[localKey]);
254
+ const rel = relMap.get(Number(p[foreignKey]));
255
+ if (!rel) continue;
256
+ if (!byLocal.has(lid)) byLocal.set(lid, []);
257
+ byLocal.get(lid)!.push(rel);
258
+ }
259
+ for (const row of out) {
260
+ row[relName] = byLocal.get(Number(row.id)) ?? [];
261
+ }
262
+ }
263
+ }
264
+ return out;
265
+ }
266
+ }