@mlagie/sql-connector 3.0.3 → 3.2.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.
@@ -0,0 +1,261 @@
1
+ const { getSafe } = require("../utils/security/safe");
2
+ const { DestructiveMigrationError } = require("./errors");
3
+
4
+ function normalizeType(type) {
5
+ const value = String(type ?? "").toLowerCase();
6
+
7
+ const aliases = {
8
+ string: "varchar",
9
+ varchar: "varchar",
10
+ character_varying: "varchar",
11
+ "character varying": "varchar",
12
+
13
+ integer: "integer",
14
+ int: "integer",
15
+ int4: "integer",
16
+
17
+ bigint: "bigint",
18
+ int8: "bigint",
19
+
20
+ boolean: "boolean",
21
+ bool: "boolean",
22
+
23
+ float: "float",
24
+ double: "float",
25
+ double_precision: "float",
26
+
27
+ date: "date",
28
+ datetime: "datetime",
29
+ timestamp: "timestamp",
30
+
31
+ text: "text"
32
+ };
33
+
34
+ return getSafe(aliases, value) || value;
35
+ }
36
+
37
+ function normalizeField(field) {
38
+ return {
39
+ type: normalizeType(field.type),
40
+ length: field.length ?? null,
41
+ required: Boolean(field.required),
42
+ default: field.default ?? null,
43
+ unique: Boolean(field.unique),
44
+ auto_increment: Boolean(field.auto_increment),
45
+ primary_key: Boolean(field.primary_key)
46
+ };
47
+ }
48
+
49
+ function equivalentField(a, b) {
50
+ return JSON.stringify(normalizeField(a)) === JSON.stringify(normalizeField(b));
51
+ }
52
+
53
+ class SchemaDiffer {
54
+ constructor(options = {}) {
55
+ this.allowDestructiveChanges = Boolean(
56
+ options.allowDestructiveChanges
57
+ );
58
+
59
+ this.allowDropTables = Boolean(
60
+ options.allowDropTables
61
+ );
62
+ }
63
+
64
+ diff(desired, current) {
65
+ const operations = [];
66
+
67
+ const desiredTables = desired.tables || {};
68
+ const currentTables = current.tables || {};
69
+
70
+ for (const [tableName, desiredTable] of Object.entries(desiredTables)) {
71
+ const desiredColumns = desiredTable.columns || {};
72
+
73
+ // Table does not exist yet.
74
+ if (!getSafe(currentTables, tableName)) {
75
+ operations.push({
76
+ type: "createTable",
77
+ table: tableName,
78
+ schema: desiredColumns
79
+ });
80
+
81
+ continue;
82
+ }
83
+
84
+ const currentTable = getSafe(currentTables, tableName);
85
+ const currentColumns = getSafe(currentTable, "columns") || {};
86
+
87
+ /*
88
+ * First pass:
89
+ * detect explicit renames using `oldname`.
90
+ *
91
+ * We deliberately do not guess renames.
92
+ */
93
+ const renameOperations = [];
94
+ const renamedFrom = new Set();
95
+ const renamedTo = new Set();
96
+
97
+ for (const [newName, field] of Object.entries(desiredColumns)) {
98
+ const oldName = field?.oldname;
99
+
100
+ if (!oldName) {
101
+ continue;
102
+ }
103
+
104
+ if (oldName === newName) {
105
+ throw new Error(
106
+ `Column '${newName}' cannot have oldname equal to itself.`
107
+ );
108
+ }
109
+
110
+ if (!getSafe(currentColumns, oldName)) {
111
+ throw new Error(
112
+ `Column '${oldName}' specified by oldname for '${tableName}.${newName}' does not exist.`
113
+ );
114
+ }
115
+
116
+ if (getSafe(currentColumns, newName)) {
117
+ throw new Error(
118
+ `Cannot rename '${tableName}.${oldName}' to '${newName}' because '${newName}' already exists.`
119
+ );
120
+ }
121
+
122
+ if (renamedFrom.has(oldName)) {
123
+ throw new Error(
124
+ `Column '${tableName}.${oldName}' is used by multiple oldname declarations.`
125
+ );
126
+ }
127
+ const operation = {
128
+ type: "renameColumn",
129
+ table: tableName,
130
+ oldName,
131
+ newName
132
+ };
133
+
134
+ renameOperations.push(operation);
135
+ renamedFrom.add(oldName);
136
+ renamedTo.add(newName);
137
+ }
138
+
139
+ /*
140
+ * Add rename operations first.
141
+ */
142
+ operations.push(...renameOperations);
143
+
144
+ /*
145
+ * Build a virtual representation of the current schema
146
+ * after applying the renames.
147
+ *
148
+ * This is important because:
149
+ *
150
+ * email -> email_address
151
+ *
152
+ * followed by:
153
+ *
154
+ * email_address VARCHAR(255) -> VARCHAR(512)
155
+ *
156
+ * must generate:
157
+ *
158
+ * RENAME COLUMN
159
+ * ALTER COLUMN
160
+ */
161
+ const virtualCurrentColumns = {
162
+ ...currentColumns
163
+ };
164
+
165
+ for (const operation of renameOperations) {
166
+ virtualCurrentColumns[operation.newName] =
167
+ virtualCurrentColumns[operation.oldName];
168
+
169
+ delete virtualCurrentColumns[operation.oldName];
170
+ }
171
+
172
+ /*
173
+ * Second pass:
174
+ * detect additions and modifications.
175
+ */
176
+ for (const [columnName, field] of Object.entries(desiredColumns)) {
177
+ const currentField = getSafe(virtualCurrentColumns, columnName);
178
+
179
+ if (!currentField) {
180
+ operations.push({
181
+ type: "addColumn",
182
+ table: tableName,
183
+ column: columnName,
184
+ field
185
+ });
186
+
187
+ continue;
188
+ }
189
+
190
+ if (!equivalentField(field, currentField)) {
191
+ operations.push({
192
+ type: "alterColumn",
193
+ table: tableName,
194
+ column: columnName,
195
+ field,
196
+ previous: currentField
197
+ });
198
+ }
199
+ }
200
+
201
+ /*
202
+ * Third pass:
203
+ * detect removed columns.
204
+ *
205
+ * Renamed columns are excluded because their old name
206
+ * intentionally disappears from the desired schema.
207
+ */
208
+ for (const currentName of Object.keys(currentColumns)) {
209
+ if (renamedFrom.has(currentName)) {
210
+ continue;
211
+ }
212
+
213
+ if (!getSafe(desiredColumns, currentName)) {
214
+ const operation = {
215
+ type: "dropColumn",
216
+ table: tableName,
217
+ column: currentName,
218
+ destructive: true
219
+ };
220
+
221
+ if (!this.allowDestructiveChanges) {
222
+ throw new DestructiveMigrationError(
223
+ `Dropping column '${tableName}.${currentName}' requires allowDestructiveChanges=true.`,
224
+ operation
225
+ );
226
+ }
227
+
228
+ operations.push(operation);
229
+ }
230
+ }
231
+ }
232
+
233
+ /*
234
+ * Detect removed tables.
235
+ */
236
+ for (const tableName of Object.keys(currentTables)) {
237
+ if (!getSafe(desiredTables, tableName)) {
238
+ const operation = {
239
+ type: "dropTable",
240
+ table: tableName,
241
+ destructive: true
242
+ };
243
+
244
+ if (!this.allowDropTables) {
245
+ throw new DestructiveMigrationError(
246
+ `Dropping table '${tableName}' requires allowDropTables=true.`,
247
+ operation
248
+ );
249
+ }
250
+
251
+ operations.push(operation);
252
+ }
253
+ }
254
+
255
+ return operations;
256
+ }
257
+ }
258
+
259
+ module.exports = {
260
+ SchemaDiffer
261
+ };
@@ -0,0 +1,44 @@
1
+ const { setSafe } = require("../utils/security/safe");
2
+
3
+ class SchemaInspector {
4
+ constructor(connection, dialect) {
5
+ this.connection = connection;
6
+ this.dialect = dialect;
7
+ }
8
+
9
+ async query(sql, values = []) {
10
+ return this.dialect.execute(this.connection, sql, values);
11
+ }
12
+
13
+ async inspect() {
14
+ const queries = this.dialect.introspectionQueries();
15
+ const tableRows = await this.query(queries.tables);
16
+
17
+ const tables = {};
18
+
19
+ for (const row of tableRows || []) {
20
+ const tableName = row.table_name ?? row.TABLE_NAME;
21
+ if (!tableName || tableName === "sql_connector_migrations") continue;
22
+
23
+ const columnRows = await this.query(
24
+ queries.columns,
25
+ [tableName]
26
+ );
27
+ setSafe(tables, tableName, {
28
+ columns: Object.fromEntries(
29
+ (columnRows || []).map(row => {
30
+ const name = row.column_name ?? row.COLUMN_NAME;
31
+ return [name, this.dialect.normalizeDatabaseColumn(row)];
32
+ })
33
+ )
34
+ });
35
+ }
36
+
37
+ return {
38
+ version: 1,
39
+ tables
40
+ };
41
+ }
42
+ }
43
+
44
+ module.exports = { SchemaInspector };
@@ -0,0 +1,53 @@
1
+ const { clone, normalizeType, normalizeDefault, stableStringify, sha256 } = require("./utils");
2
+
3
+ function fieldToSnapshot(field) {
4
+ return {
5
+ type: normalizeType(field),
6
+ length: field.length ?? null,
7
+ required: Boolean(field.required),
8
+ default: normalizeDefault(field.default),
9
+ unique: Boolean(field.unique),
10
+ auto_increment: Boolean(field.auto_increment),
11
+ foreignKey: field.foreignKey ?? null,
12
+ enum: Array.isArray(field.enum) ? [...field.enum] : null,
13
+ primary_key: Boolean(field.primary_key),
14
+ customize: field.customize ?? null,
15
+ oldname: field.oldname ?? null
16
+ };
17
+ }
18
+
19
+ function modelsToSnapshot(models) {
20
+ const tables = {};
21
+
22
+ for (const model of models) {
23
+ if (!model || typeof model.name !== "string" || !model.schema?.schemaDict) {
24
+ throw new Error("Migration received an invalid Model.");
25
+ }
26
+
27
+ tables[model.name] = {
28
+ columns: Object.fromEntries(
29
+ Object.entries(model.schema.schemaDict).map(([name, field]) => [name, fieldToSnapshot(field)])
30
+ )
31
+ };
32
+ }
33
+
34
+ return {
35
+ version: 1,
36
+ tables
37
+ };
38
+ }
39
+
40
+ function snapshotChecksum(snapshot) {
41
+ return sha256(stableStringify(snapshot));
42
+ }
43
+
44
+ function cloneSnapshot(snapshot) {
45
+ return clone(snapshot);
46
+ }
47
+
48
+ module.exports = {
49
+ fieldToSnapshot,
50
+ modelsToSnapshot,
51
+ snapshotChecksum,
52
+ cloneSnapshot
53
+ };
@@ -0,0 +1,79 @@
1
+ const crypto = require("crypto");
2
+ const { Buffer } = require("node:buffer");
3
+ const fs = require("fs/promises");
4
+
5
+ const FORMAT = "sql-connector-migration-v1";
6
+ const ALGORITHM = "aes-256-gcm";
7
+ const KEY_LENGTH = 32;
8
+ const IV_LENGTH = 12;
9
+ const SALT_LENGTH = 16;
10
+ const TAG_LENGTH = 16;
11
+
12
+ function deriveKey(secret, salt) {
13
+ if (!secret || typeof secret !== "string") {
14
+ throw new Error(
15
+ "Migration encryption key is missing. Provide encryption.key or SQL_CONNECTOR_MIGRATION_KEY."
16
+ );
17
+ }
18
+
19
+ return crypto.scryptSync(secret, salt, KEY_LENGTH);
20
+ }
21
+
22
+ function encrypt(text, secret) {
23
+ const salt = crypto.randomBytes(SALT_LENGTH);
24
+ const iv = crypto.randomBytes(IV_LENGTH);
25
+ const key = deriveKey(secret, salt);
26
+ const cipher = crypto.createCipheriv(ALGORITHM, key, iv);
27
+ const ciphertext = Buffer.concat([cipher.update(String(text), "utf8"), cipher.final()]);
28
+ const tag = cipher.getAuthTag();
29
+
30
+ return [
31
+ FORMAT,
32
+ `salt=${salt.toString("base64url")}`,
33
+ `iv=${iv.toString("base64url")}`,
34
+ `tag=${tag.toString("base64url")}`,
35
+ `data=${ciphertext.toString("base64url")}`
36
+ ].join("\n") + "\n";
37
+ }
38
+
39
+ function decrypt(payload, secret) {
40
+ const lines = String(payload).trim().split("\n");
41
+ if (lines[0] !== FORMAT) throw new Error("Unsupported encrypted migration format.");
42
+
43
+ const values = Object.fromEntries(lines.slice(1).map(line => {
44
+ const index = line.indexOf("=");
45
+ return [line.slice(0, index), line.slice(index + 1)];
46
+ }));
47
+
48
+ const salt = Buffer.from(values.salt, "base64url");
49
+ const iv = Buffer.from(values.iv, "base64url");
50
+ const tag = Buffer.from(values.tag, "base64url");
51
+ const ciphertext = Buffer.from(values.data, "base64url");
52
+
53
+ if (salt.length !== SALT_LENGTH || iv.length !== IV_LENGTH || tag.length !== TAG_LENGTH) {
54
+ throw new Error("Invalid encrypted migration payload.");
55
+ }
56
+
57
+ const key = deriveKey(secret, salt);
58
+ const decipher = crypto.createDecipheriv(ALGORITHM, key, iv);
59
+ decipher.setAuthTag(tag);
60
+
61
+ return Buffer.concat([decipher.update(ciphertext), decipher.final()]).toString("utf8");
62
+ }
63
+
64
+ async function encryptFile(inputPath, outputPath, secret) {
65
+ const content = await fs.readFile(inputPath, "utf8");
66
+ await fs.writeFile(outputPath, encrypt(content, secret), { mode: 0o600 });
67
+ }
68
+
69
+ async function decryptFile(inputPath, secret) {
70
+ return decrypt(await fs.readFile(inputPath, "utf8"), secret);
71
+ }
72
+
73
+ module.exports = {
74
+ FORMAT,
75
+ encrypt,
76
+ decrypt,
77
+ encryptFile,
78
+ decryptFile
79
+ };
@@ -0,0 +1,28 @@
1
+ class MigrationError extends Error {
2
+ constructor(message, code = "MIGRATION_ERROR", details = undefined) {
3
+ super(message);
4
+ this.name = "MigrationError";
5
+ this.code = code;
6
+ this.details = details;
7
+ }
8
+ }
9
+
10
+ class DestructiveMigrationError extends MigrationError {
11
+ constructor(message, details) {
12
+ super(message, "DESTRUCTIVE_MIGRATION", details);
13
+ this.name = "DestructiveMigrationError";
14
+ }
15
+ }
16
+
17
+ class MigrationChecksumError extends MigrationError {
18
+ constructor(message, details) {
19
+ super(message, "MIGRATION_CHECKSUM_MISMATCH", details);
20
+ this.name = "MigrationChecksumError";
21
+ }
22
+ }
23
+
24
+ module.exports = {
25
+ MigrationError,
26
+ DestructiveMigrationError,
27
+ MigrationChecksumError
28
+ };
@@ -0,0 +1,15 @@
1
+ const { Migration } = require("./Migration");
2
+ const { MigrationPlan } = require("./MigrationPlan");
3
+ const { MigrationStore } = require("./MigrationStore");
4
+ const { MigrationRunner } = require("./MigrationRunner");
5
+ const { SchemaDiffer } = require("./SchemaDiffer");
6
+ const { SchemaInspector } = require("./SchemaInspector");
7
+
8
+ module.exports = {
9
+ Migration,
10
+ MigrationPlan,
11
+ MigrationStore,
12
+ MigrationRunner,
13
+ SchemaDiffer,
14
+ SchemaInspector,
15
+ };
@@ -0,0 +1,89 @@
1
+ const crypto = require("crypto");
2
+ const { getSafe } = require("../utils/security/safe");
3
+
4
+ function assertIdentifier(value, label = "identifier") {
5
+ if (typeof value !== "string" || !/^[A-Za-z_][A-Za-z0-9_$]*$/.test(value)) {
6
+ throw new Error(`Invalid ${label}: ${value}`);
7
+ }
8
+ return value;
9
+ }
10
+
11
+ function quoteIdentifier(name, quote) {
12
+ assertIdentifier(name);
13
+ return `${quote}${name}${quote}`;
14
+ }
15
+
16
+ function normalizeType(field) {
17
+ const type = field && field.type;
18
+ if (typeof type === "function") return type.name;
19
+ if (type && typeof type === "object" && typeof type.name === "string") return type.name;
20
+ if (typeof type === "string") return type;
21
+ return undefined;
22
+ }
23
+
24
+ function normalizeDefault(value) {
25
+ if (value === undefined) return undefined;
26
+ if (value === null) return null;
27
+ if (typeof value === "string") return value;
28
+ if (typeof value === "number" || typeof value === "boolean") return value;
29
+ return String(value);
30
+ }
31
+
32
+ function stableStringify(value) {
33
+ if (value === null || typeof value !== "object") return JSON.stringify(value);
34
+ if (Array.isArray(value)) return `[${value.map(stableStringify).join(",")}]`;
35
+ return `{${Object.keys(value).sort().map(k => `${JSON.stringify(k)}:${getSafe(value, k)}`)}.join(",")}}`;
36
+ }
37
+
38
+ function sha256(value) {
39
+ return crypto.createHash("sha256").update(value).digest("hex");
40
+ }
41
+
42
+ function migrationId(name = "migration") {
43
+ const now = new Date();
44
+ const stamp = now.toISOString()
45
+ .replace(/[-:TZ.]/g, "")
46
+ .slice(0, 14);
47
+ const safeName = String(name)
48
+ .trim()
49
+ .replace(/[^A-Za-z0-9_-]+/g, "_")
50
+ .replace(/^_+|_+$/g, "")
51
+ .slice(0, 80) || "migration";
52
+ return `${stamp}_${safeName}`;
53
+ }
54
+
55
+ function parseDuration(value) {
56
+ if (value === null || value === false || value === undefined) return null;
57
+ if (typeof value === "number" && Number.isFinite(value) && value >= 0) return value;
58
+
59
+ const match = /^(\d+(?:\.\d+)?)(ms|s|m|h|d|w)$/i.exec(String(value).trim());
60
+ if (!match) throw new Error(`Invalid retention duration: ${value}`);
61
+
62
+ const amount = Number(match[1]);
63
+ const unit = match[2].toLowerCase();
64
+ const factors = {
65
+ ms: 1,
66
+ s: 1000,
67
+ m: 60_000,
68
+ h: 3_600_000,
69
+ d: 86_400_000,
70
+ w: 604_800_000
71
+ };
72
+ return amount * getSafe(factors, unit);
73
+ }
74
+
75
+ function clone(value) {
76
+ return JSON.parse(JSON.stringify(value));
77
+ }
78
+
79
+ module.exports = {
80
+ assertIdentifier,
81
+ quoteIdentifier,
82
+ normalizeType,
83
+ normalizeDefault,
84
+ stableStringify,
85
+ sha256,
86
+ migrationId,
87
+ parseDuration,
88
+ clone
89
+ };
@@ -5,6 +5,7 @@ const { ModelInstance } = require("./ModelInstance");
5
5
  const { buildSelect, buildQueryParts } = require("../utils/buildQuery/buildQuery");
6
6
  const { getSafe, setSafe } = require("../utils/security/safe");
7
7
  const { getDialect } = require("../db/dialects");
8
+ const LENGTH_TYPES = new Set(["VARCHAR", "CHAR", "BINARY", "VARBINARY"]);
8
9
 
9
10
  function getFieldType(field) {
10
11
  if (typeof field === "object") {
@@ -25,16 +26,20 @@ function getColumnDefinition(fieldName, field) {
25
26
  }
26
27
 
27
28
  const fieldType = getFieldType(field);
28
- const type = getSafe(sqlTypeMap, fieldType);
29
+ const type = getDialect().mapType(fieldType);
29
30
  if (!type) throw new Error(`Field ${fieldName} has unsupported type ${fieldType}.`);
30
31
 
31
32
  let colDef;
32
33
  if (Array.isArray(field.enum) && field.enum.length > 0) {
33
34
  const enumValues = field.enum.map(v => `'${v.replace(/'/g, "''")}'`).join(", ");
34
35
  colDef = `ENUM(${enumValues})`;
36
+ } else if (type === "DECIMAL") {
37
+ const precision = field.precision > 0 ? field.precision : 10;
38
+ const scale = field.scale >= 0 ? field.scale : 0;
39
+ colDef = `DECIMAL(${precision}, ${scale})`;
35
40
  } else {
36
- const hasLength = type === "VARCHAR" || (type === "INT" && getDialect().name === "mysql");
37
- colDef = `${type}${hasLength ? `(${field.length > 0 ? field.length : 255})` : ""}`;
41
+ const hasLength = LENGTH_TYPES.has(type) || type === "INT";
42
+ colDef = getDialect().columnType(type, hasLength ? (field.length > 0 ? field.length : 255) : undefined);
38
43
  }
39
44
 
40
45
  if (field.required) colDef += ' NOT NULL';
@@ -43,7 +48,7 @@ function getColumnDefinition(fieldName, field) {
43
48
 
44
49
  if (defaultDefinition !== null) colDef += ` ${defaultDefinition}`;
45
50
  if (field.unique) colDef += ' UNIQUE';
46
- if (field.auto_increment) colDef += ' AUTO_INCREMENT';
51
+ if (field.auto_increment) colDef += ` ${getDialect().autoIncrement(fieldType)}`;
47
52
  if (field.primary_key) colDef += ' PRIMARY KEY'
48
53
  if (typeof field.customize === 'string' && field.customize.length != 0) colDef += ` ${field.customize}`;
49
54
  return `${getDialect().escape(fieldName)} ${colDef}`;
@@ -160,10 +165,11 @@ class Model {
160
165
  return `${getDialect().escape(fieldName)} ENUM(${enumValues})`;
161
166
  }
162
167
 
163
- const type = getSafe(sqlTypeMap, fieldType);
168
+ const type = getDialect().mapType(fieldType);
164
169
 
165
170
  if (!type) throw new Error(`Field ${fieldName} has unsupported type ${field}`);
166
- if (type == "VARCHAR") return `${getDialect().escape(fieldName)} ${type}(${lengthDefault})`;
171
+ if (LENGTH_TYPES.has(type)) return `${getDialect().escape(fieldName)} ${type}(${lengthDefault})`;
172
+ if (type === "DECIMAL") return `${getDialect().escape(fieldName)} ${type}(10, 0)`;
167
173
  return `${getDialect().escape(fieldName)} ${type}`;
168
174
  });
169
175
  return `CREATE TABLE IF NOT EXISTS ${getDialect().escape(this.name)} (${columns.join(', ')}${foreignKey.length > 0 ? ", " + foreignKey.join(', ') : ""}) ${getDialect().tableSuffix};`;
@@ -177,7 +183,7 @@ class Model {
177
183
  */
178
184
  async save(data) {
179
185
  const keys = Object.keys(data);
180
- const sql_request = `INSERT INTO ${getDialect().escape(this.name)} (${getDialect().escapeIdentifierList(keys)}) VALUES (${keys.map(() => "?").join(", ")})`;
186
+ const sql_request = `INSERT INTO ${getDialect().escape(this.name)} (${getDialect().escapeIdentifierList(keys)}) VALUES (${keys.map((_, index) => getDialect().getPlaceholder(index)).join(", ")})`;
181
187
 
182
188
  try {
183
189
  const result = await getDialect().execute(getConnexion(), sql_request, Object.values(data));
@@ -1,22 +1,22 @@
1
- /**
2
- * Represents a database schema.
3
- *
4
- * @example
5
- * const transferSchema = new Schema({
6
- * token: {
7
- * type: String,
8
- * length: 50
9
- * },
10
- * mdp: {
11
- * type: String,
12
- * length: 15
13
- * }
14
- * });
15
- */
16
- class Schema {
17
- constructor(schemaDict) {
18
- this.schemaDict = schemaDict;
1
+ /**
2
+ * Represents a database schema.
3
+ *
4
+ * @example
5
+ * const transferSchema = new Schema({
6
+ * token: {
7
+ * type: String,
8
+ * length: 50
9
+ * },
10
+ * mdp: {
11
+ * type: String,
12
+ * length: 15
13
+ * }
14
+ * });
15
+ */
16
+ class Schema {
17
+ constructor(schemaDict) {
18
+ this.schemaDict = schemaDict;
19
+ }
19
20
  }
20
- }
21
21
 
22
- module.exports = { Schema }
22
+ module.exports = { Schema }