turbine-orm 0.5.0 → 0.7.1

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 (50) hide show
  1. package/README.md +292 -26
  2. package/dist/cjs/cli/config.js +5 -15
  3. package/dist/cjs/cli/index.js +311 -43
  4. package/dist/cjs/cli/loader.js +129 -0
  5. package/dist/cjs/cli/migrate.js +96 -47
  6. package/dist/cjs/cli/ui.js +5 -9
  7. package/dist/cjs/client.js +158 -49
  8. package/dist/cjs/errors.js +424 -0
  9. package/dist/cjs/generate.js +145 -14
  10. package/dist/cjs/index.js +43 -20
  11. package/dist/cjs/introspect.js +3 -5
  12. package/dist/cjs/pipeline.js +9 -2
  13. package/dist/cjs/query.js +544 -115
  14. package/dist/cjs/schema-builder.js +150 -30
  15. package/dist/cjs/schema-sql.js +241 -37
  16. package/dist/cjs/schema.js +5 -2
  17. package/dist/cjs/serverless.js +88 -176
  18. package/dist/cli/config.js +6 -16
  19. package/dist/cli/index.js +316 -48
  20. package/dist/cli/loader.d.ts +45 -0
  21. package/dist/cli/loader.js +91 -0
  22. package/dist/cli/migrate.d.ts +13 -2
  23. package/dist/cli/migrate.js +97 -48
  24. package/dist/cli/ui.d.ts +1 -1
  25. package/dist/cli/ui.js +5 -9
  26. package/dist/client.d.ts +92 -4
  27. package/dist/client.js +158 -49
  28. package/dist/errors.d.ts +225 -0
  29. package/dist/errors.js +405 -0
  30. package/dist/generate.d.ts +7 -1
  31. package/dist/generate.js +148 -18
  32. package/dist/index.d.ts +11 -9
  33. package/dist/index.js +16 -12
  34. package/dist/introspect.d.ts +1 -1
  35. package/dist/introspect.js +4 -6
  36. package/dist/pipeline.d.ts +1 -1
  37. package/dist/pipeline.js +9 -2
  38. package/dist/query.d.ts +374 -38
  39. package/dist/query.js +545 -116
  40. package/dist/schema-builder.d.ts +38 -5
  41. package/dist/schema-builder.js +150 -31
  42. package/dist/schema-sql.d.ts +7 -3
  43. package/dist/schema-sql.js +241 -37
  44. package/dist/schema.d.ts +1 -1
  45. package/dist/schema.js +5 -2
  46. package/dist/serverless.d.ts +92 -139
  47. package/dist/serverless.js +87 -173
  48. package/package.json +33 -16
  49. package/dist/types.d.ts +0 -93
  50. package/dist/types.js +0 -126
@@ -0,0 +1,129 @@
1
+ "use strict";
2
+ /**
3
+ * turbine-orm CLI — TypeScript loader registration
4
+ *
5
+ * The CLI loads user-supplied config and schema files via dynamic `import()`.
6
+ * Plain Node has no built-in `.ts` loader, so importing `turbine.config.ts`
7
+ * blows up with `ERR_UNKNOWN_FILE_EXTENSION` unless we register a TypeScript
8
+ * loader first.
9
+ *
10
+ * Strategy:
11
+ * 1. If the file we're about to import ends in `.ts` / `.mts` / `.cts`,
12
+ * probe whether `tsx/esm` is resolvable from the user's CWD.
13
+ * 2. If yes, call `module.register('tsx/esm', ...)` ONCE per process.
14
+ * 3. If no, surface an actionable error telling the user to install `tsx`.
15
+ *
16
+ * `tsx` is intentionally NOT a runtime dependency — many projects already
17
+ * have it, and adding a heavy dev tool to a 1-dependency ORM would be silly.
18
+ */
19
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
20
+ if (k2 === undefined) k2 = k;
21
+ var desc = Object.getOwnPropertyDescriptor(m, k);
22
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
23
+ desc = { enumerable: true, get: function() { return m[k]; } };
24
+ }
25
+ Object.defineProperty(o, k2, desc);
26
+ }) : (function(o, m, k, k2) {
27
+ if (k2 === undefined) k2 = k;
28
+ o[k2] = m[k];
29
+ }));
30
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
31
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
32
+ }) : function(o, v) {
33
+ o["default"] = v;
34
+ });
35
+ var __importStar = (this && this.__importStar) || (function () {
36
+ var ownKeys = function(o) {
37
+ ownKeys = Object.getOwnPropertyNames || function (o) {
38
+ var ar = [];
39
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
40
+ return ar;
41
+ };
42
+ return ownKeys(o);
43
+ };
44
+ return function (mod) {
45
+ if (mod && mod.__esModule) return mod;
46
+ var result = {};
47
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
48
+ __setModuleDefault(result, mod);
49
+ return result;
50
+ };
51
+ })();
52
+ Object.defineProperty(exports, "__esModule", { value: true });
53
+ exports.needsTsLoader = needsTsLoader;
54
+ exports.canResolveTsx = canResolveTsx;
55
+ exports.registerTsLoader = registerTsLoader;
56
+ exports._resetTsLoaderStateForTests = _resetTsLoaderStateForTests;
57
+ const node_module_1 = require("node:module");
58
+ const node_url_1 = require("node:url");
59
+ /**
60
+ * Detect whether a config / schema file path needs the tsx ESM loader.
61
+ * Returns true for `.ts`, `.mts`, and `.cts` files; false for `.js`, `.mjs`,
62
+ * `.cjs`, `.json`, missing paths, or anything else.
63
+ */
64
+ function needsTsLoader(filePath) {
65
+ if (!filePath)
66
+ return false;
67
+ return /\.(ts|mts|cts)$/i.test(filePath);
68
+ }
69
+ /**
70
+ * Probe whether `tsx/esm` is resolvable from the user's current working
71
+ * directory. Returns true if `tsx` is installed in the user's project.
72
+ *
73
+ * Accepts an injected `resolver` so unit tests don't need a real filesystem.
74
+ */
75
+ function canResolveTsx(resolver) {
76
+ try {
77
+ if (resolver) {
78
+ resolver('tsx/esm');
79
+ return true;
80
+ }
81
+ // Probe relative to the user's CWD, not Turbine's install location.
82
+ // This way we honour whatever `tsx` version the user has pinned.
83
+ const userRequire = (0, node_module_1.createRequire)(`${process.cwd()}/`);
84
+ userRequire.resolve('tsx/esm');
85
+ return true;
86
+ }
87
+ catch {
88
+ return false;
89
+ }
90
+ }
91
+ let tsLoaderState = null;
92
+ /**
93
+ * Register the tsx ESM loader so subsequent dynamic imports of `.ts` files
94
+ * work. Safe to call multiple times — internal flag prevents double registration.
95
+ *
96
+ * Returns:
97
+ * - 'registered' loader was successfully registered this call
98
+ * - 'already' a loader was previously registered (idempotent)
99
+ * - 'unsupported' Node lacks `module.register()` (Node < 20.6)
100
+ * - 'missing' `tsx` is not installed in the user's project
101
+ */
102
+ async function registerTsLoader() {
103
+ if (tsLoaderState === 'registered' || tsLoaderState === 'already') {
104
+ return 'already';
105
+ }
106
+ if (!canResolveTsx()) {
107
+ tsLoaderState = 'missing';
108
+ return 'missing';
109
+ }
110
+ try {
111
+ const mod = await Promise.resolve().then(() => __importStar(require('node:module')));
112
+ const register = mod.register;
113
+ if (typeof register !== 'function') {
114
+ tsLoaderState = 'unsupported';
115
+ return 'unsupported';
116
+ }
117
+ register('tsx/esm', (0, node_url_1.pathToFileURL)(`${process.cwd()}/`));
118
+ tsLoaderState = 'registered';
119
+ return 'registered';
120
+ }
121
+ catch {
122
+ tsLoaderState = 'missing';
123
+ return 'missing';
124
+ }
125
+ }
126
+ /** Reset the loader state — used by unit tests only. */
127
+ function _resetTsLoaderStateForTests() {
128
+ tsLoaderState = null;
129
+ }
@@ -27,15 +27,19 @@ exports.createMigration = createMigration;
27
27
  exports.migrateUp = migrateUp;
28
28
  exports.migrateDown = migrateDown;
29
29
  exports.migrateStatus = migrateStatus;
30
+ const node_crypto_1 = require("node:crypto");
30
31
  const node_fs_1 = require("node:fs");
31
32
  const node_path_1 = require("node:path");
32
33
  const pg_1 = __importDefault(require("pg"));
34
+ const errors_js_1 = require("../errors.js");
35
+ const query_js_1 = require("../query.js");
33
36
  // ---------------------------------------------------------------------------
34
37
  // Tracking table management
35
38
  // ---------------------------------------------------------------------------
36
39
  const TRACKING_TABLE = '_turbine_migrations';
40
+ const QUOTED_TRACKING_TABLE = (0, query_js_1.quoteIdent)(TRACKING_TABLE);
37
41
  const CREATE_TRACKING_TABLE = `
38
- CREATE TABLE IF NOT EXISTS ${TRACKING_TABLE} (
42
+ CREATE TABLE IF NOT EXISTS ${QUOTED_TRACKING_TABLE} (
39
43
  id SERIAL PRIMARY KEY,
40
44
  name TEXT NOT NULL UNIQUE,
41
45
  checksum TEXT NOT NULL,
@@ -47,7 +51,7 @@ async function ensureTrackingTable(client) {
47
51
  }
48
52
  async function getAppliedMigrations(client) {
49
53
  await ensureTrackingTable(client);
50
- const result = await client.query(`SELECT id, name, applied_at, checksum FROM ${TRACKING_TABLE} ORDER BY id ASC`);
54
+ const result = await client.query(`SELECT id, name, applied_at, checksum FROM ${QUOTED_TRACKING_TABLE} ORDER BY id ASC`);
51
55
  return result.rows;
52
56
  }
53
57
  // ---------------------------------------------------------------------------
@@ -155,30 +159,45 @@ function parseMigrationSQL(filePath) {
155
159
  return parseMigrationContent(content);
156
160
  }
157
161
  /**
158
- * Simple checksum for a migration file (for drift detection).
162
+ * SHA-256 checksum for migration drift detection.
163
+ * Returns a hex-encoded hash of the file content.
159
164
  */
160
165
  function checksum(content) {
161
- let hash = 0;
162
- for (let i = 0; i < content.length; i++) {
163
- const chr = content.charCodeAt(i);
164
- hash = ((hash << 5) - hash + chr) | 0;
165
- }
166
- return Math.abs(hash).toString(36);
166
+ return (0, node_crypto_1.createHash)('sha256').update(content, 'utf-8').digest('hex');
167
+ }
168
+ /** Detect legacy djb2 checksums (short alphanumeric strings, pre-v0.6) */
169
+ function isLegacyChecksum(hash) {
170
+ return hash.length < 64;
167
171
  }
168
172
  // ---------------------------------------------------------------------------
169
173
  // Commands
170
174
  // ---------------------------------------------------------------------------
171
175
  /**
172
176
  * Create a new migration file.
177
+ * If `autoContent` is provided, the UP/DOWN sections are pre-populated with the given SQL.
173
178
  */
174
- function createMigration(migrationsDir, name) {
179
+ function createMigration(migrationsDir, name, autoContent) {
175
180
  (0, node_fs_1.mkdirSync)(migrationsDir, { recursive: true });
176
181
  const now = new Date();
177
182
  const ts = formatTimestamp(now);
178
183
  const safeName = sanitizeName(name);
179
184
  const filename = `${ts}_${safeName}.sql`;
180
185
  const filePath = (0, node_path_1.join)(migrationsDir, filename);
181
- const template = `-- Migration: ${name}
186
+ let template;
187
+ if (autoContent) {
188
+ template = `-- Migration: ${name} (auto-generated from schema diff)
189
+ -- Created: ${now.toISOString()}
190
+ -- Review this file before running: npx turbine migrate up
191
+
192
+ -- UP
193
+ ${autoContent.up}
194
+
195
+ -- DOWN
196
+ ${autoContent.down}
197
+ `;
198
+ }
199
+ else {
200
+ template = `-- Migration: ${name}
182
201
  -- Created: ${now.toISOString()}
183
202
 
184
203
  -- UP
@@ -187,6 +206,7 @@ function createMigration(migrationsDir, name) {
187
206
  -- DOWN
188
207
  -- Write your rollback SQL here
189
208
  `;
209
+ }
190
210
  (0, node_fs_1.writeFileSync)(filePath, template, 'utf-8');
191
211
  return {
192
212
  filename,
@@ -201,17 +221,16 @@ function createMigration(migrationsDir, name) {
201
221
  /** Fixed lock ID for Turbine migrations — prevents concurrent migrate runs */
202
222
  const MIGRATION_LOCK_ID = 8_347_291; // arbitrary but stable
203
223
  async function acquireLock(client) {
204
- const result = await client.query(`SELECT pg_try_advisory_lock($1)`, [MIGRATION_LOCK_ID]);
224
+ const result = await client.query(`SELECT pg_try_advisory_lock($1)`, [
225
+ MIGRATION_LOCK_ID,
226
+ ]);
205
227
  return result.rows[0]?.pg_try_advisory_lock ?? false;
206
228
  }
207
229
  async function releaseLock(client) {
208
230
  await client.query(`SELECT pg_advisory_unlock($1)`, [MIGRATION_LOCK_ID]);
209
231
  }
210
- // ---------------------------------------------------------------------------
211
- // Checksum validation
212
- // ---------------------------------------------------------------------------
213
232
  /**
214
- * Validate that applied migration files have not been modified since they were run.
233
+ * Validate that applied migration files have not been modified or deleted since they were run.
215
234
  * Returns an array of mismatched migrations (empty if all are clean).
216
235
  */
217
236
  async function validateChecksums(client, migrationsDir) {
@@ -221,15 +240,31 @@ async function validateChecksums(client, migrationsDir) {
221
240
  const mismatches = [];
222
241
  for (const migration of applied) {
223
242
  const file = fileMap.get(migration.name);
224
- if (!file)
225
- continue; // file deleted — not a checksum issue
243
+ if (!file) {
244
+ mismatches.push({
245
+ name: migration.name,
246
+ expected: migration.checksum,
247
+ actual: '',
248
+ type: 'missing',
249
+ });
250
+ continue;
251
+ }
226
252
  const content = (0, node_fs_1.readFileSync)(file.path, 'utf-8');
227
253
  const currentHash = checksum(content);
228
254
  if (currentHash !== migration.checksum) {
255
+ // Auto-upgrade legacy djb2 checksums to SHA-256 without flagging as modified
256
+ if (isLegacyChecksum(migration.checksum)) {
257
+ await client.query(`UPDATE ${QUOTED_TRACKING_TABLE} SET checksum = $1 WHERE name = $2`, [
258
+ currentHash,
259
+ migration.name,
260
+ ]);
261
+ continue;
262
+ }
229
263
  mismatches.push({
230
264
  name: migration.name,
231
265
  expected: migration.checksum,
232
266
  actual: currentHash,
267
+ type: 'modified',
233
268
  });
234
269
  }
235
270
  }
@@ -241,37 +276,57 @@ async function validateChecksums(client, migrationsDir) {
241
276
  * Features:
242
277
  * - Idempotent: running twice is safe (already-applied migrations are skipped)
243
278
  * - Advisory lock: prevents concurrent migration runs
244
- * - Checksum validation: detects modified migration files
279
+ * - Checksum validation: detects modified migration files (BLOCKING — use
280
+ * `allowDrift: true` to bypass when intentionally rewriting history)
245
281
  * - Each migration runs in its own transaction
282
+ *
283
+ * Throws `MigrationError` if any applied migration has been modified or deleted
284
+ * on disk, listing the offending files. Pass `{ allowDrift: true }` to bypass
285
+ * this check (the CLI exposes this as `--allow-drift`).
246
286
  */
247
287
  async function migrateUp(connectionString, migrationsDir, options) {
248
288
  const client = new pg_1.default.Client({ connectionString });
249
289
  await client.connect();
290
+ // Treat `force` as an alias for `allowDrift` for backwards compatibility.
291
+ const allowDrift = options?.allowDrift === true || options?.force === true;
250
292
  try {
251
293
  // Acquire advisory lock to prevent concurrent migrations
252
294
  const gotLock = await acquireLock(client);
253
295
  if (!gotLock) {
254
- return {
255
- applied: [],
256
- errors: [{
257
- file: { filename: '', path: '', name: '', timestamp: '' },
258
- error: 'Could not acquire migration lock — another migration is already running',
259
- }],
260
- };
296
+ throw new errors_js_1.MigrationError('[turbine] Could not acquire migration lock — another migration is already running');
261
297
  }
262
298
  try {
263
299
  await ensureTrackingTable(client);
264
- // Validate checksums of already-applied migrations
265
- const mismatches = await validateChecksums(client, migrationsDir);
266
- if (mismatches.length > 0) {
267
- const names = mismatches.map((m) => m.name).join(', ');
268
- return {
269
- applied: [],
270
- errors: [{
271
- file: { filename: '', path: '', name: '', timestamp: '' },
272
- error: `Checksum mismatch: migration file(s) modified after application: ${names}. This is dangerous — applied migrations should be immutable. Use --force to skip this check.`,
273
- }],
274
- };
300
+ // Validate checksums of already-applied migrations.
301
+ // Drift = an APPLIED migration's on-disk file has changed (or been deleted)
302
+ // since it was run. Either situation means the database state and the
303
+ // migration history no longer agree, so we BLOCK the run by default.
304
+ // Users can pass `allowDrift: true` (CLI: `--allow-drift`) to force past
305
+ // the block when they are intentionally rewriting history.
306
+ if (!allowDrift) {
307
+ const mismatches = await validateChecksums(client, migrationsDir);
308
+ if (mismatches.length > 0) {
309
+ const modified = mismatches.filter((m) => m.type === 'modified');
310
+ const missing = mismatches.filter((m) => m.type === 'missing');
311
+ const lines = [
312
+ '[turbine] Migration drift detected — refusing to apply pending migrations.',
313
+ '',
314
+ 'Applied migrations should be immutable. The following files no longer match their applied state:',
315
+ '',
316
+ ];
317
+ for (const m of modified) {
318
+ lines.push(` - ${m.name}.sql (modified on disk)`);
319
+ }
320
+ for (const m of missing) {
321
+ lines.push(` - ${m.name}.sql (deleted from disk)`);
322
+ }
323
+ lines.push('');
324
+ lines.push('Fix one of these:');
325
+ lines.push(' 1. Restore the file(s) to their original content, OR');
326
+ lines.push(' 2. Roll back the affected migrations with `npx turbine migrate down`, OR');
327
+ lines.push(' 3. Pass `--allow-drift` to bypass this check (advanced — make sure you know what you are doing).');
328
+ throw new errors_js_1.MigrationError(lines.join('\n'));
329
+ }
275
330
  }
276
331
  const applied = await getAppliedMigrations(client);
277
332
  const appliedNames = new Set(applied.map((m) => m.name));
@@ -293,7 +348,7 @@ async function migrateUp(connectionString, migrationsDir, options) {
293
348
  try {
294
349
  await client.query('BEGIN');
295
350
  await client.query(up);
296
- await client.query(`INSERT INTO ${TRACKING_TABLE} (name, checksum) VALUES ($1, $2) ON CONFLICT (name) DO NOTHING`, [file.name, hash]);
351
+ await client.query(`INSERT INTO ${QUOTED_TRACKING_TABLE} (name, checksum) VALUES ($1, $2) ON CONFLICT (name) DO NOTHING`, [file.name, hash]);
297
352
  await client.query('COMMIT');
298
353
  results.push(file);
299
354
  }
@@ -329,13 +384,7 @@ async function migrateDown(connectionString, migrationsDir, options) {
329
384
  try {
330
385
  const gotLock = await acquireLock(client);
331
386
  if (!gotLock) {
332
- return {
333
- rolledBack: [],
334
- errors: [{
335
- file: { filename: '', path: '', name: '', timestamp: '' },
336
- error: 'Could not acquire migration lock — another migration is already running',
337
- }],
338
- };
387
+ throw new errors_js_1.MigrationError('[turbine] Could not acquire migration lock — another migration is already running');
339
388
  }
340
389
  try {
341
390
  await ensureTrackingTable(client);
@@ -353,7 +402,7 @@ async function migrateDown(connectionString, migrationsDir, options) {
353
402
  const file = fileMap.get(migration.name);
354
403
  if (!file) {
355
404
  errors.push({
356
- file: { filename: migration.name + '.sql', path: '', name: migration.name, timestamp: '' },
405
+ file: { filename: `${migration.name}.sql`, path: '', name: migration.name, timestamp: '' },
357
406
  error: `Migration file not found for "${migration.name}"`,
358
407
  });
359
408
  continue;
@@ -366,7 +415,7 @@ async function migrateDown(connectionString, migrationsDir, options) {
366
415
  try {
367
416
  await client.query('BEGIN');
368
417
  await client.query(down);
369
- await client.query(`DELETE FROM ${TRACKING_TABLE} WHERE name = $1`, [migration.name]);
418
+ await client.query(`DELETE FROM ${QUOTED_TRACKING_TABLE} WHERE name = $1`, [migration.name]);
370
419
  await client.query('COMMIT');
371
420
  results.push(file);
372
421
  }
@@ -24,9 +24,7 @@ exports.redactUrl = redactUrl;
24
24
  // ---------------------------------------------------------------------------
25
25
  // ANSI escape codes
26
26
  // ---------------------------------------------------------------------------
27
- const isColorSupported = process.env['NO_COLOR'] == null &&
28
- process.env['TERM'] !== 'dumb' &&
29
- (process.stdout.isTTY ?? false);
27
+ const isColorSupported = process.env.NO_COLOR == null && process.env.TERM !== 'dumb' && (process.stdout.isTTY ?? false);
30
28
  function code(open, close) {
31
29
  if (!isColorSupported)
32
30
  return (s) => s;
@@ -113,9 +111,7 @@ function table(headers, rows) {
113
111
  return ` ${(0, exports.bold)(h)}${' '.repeat(Math.max(0, w - stripAnsi(h).length))} `;
114
112
  })
115
113
  .join((0, exports.dim)(exports.symbols.vertLine));
116
- const separator = colWidths
117
- .map((w) => exports.symbols.line.repeat(w + 2))
118
- .join((0, exports.dim)(exports.symbols.line));
114
+ const separator = colWidths.map((w) => exports.symbols.line.repeat(w + 2)).join((0, exports.dim)(exports.symbols.line));
119
115
  const bodyLines = rows.map((row) => row
120
116
  .map((cell, i) => {
121
117
  const w = colWidths[i];
@@ -195,7 +191,7 @@ function info(msg) {
195
191
  console.log(` ${(0, exports.blue)(exports.symbols.info)} ${msg}`);
196
192
  }
197
193
  function label(key, value) {
198
- console.log(` ${(0, exports.dim)(key + ':')} ${value}`);
194
+ console.log(` ${(0, exports.dim)(`${key}:`)} ${value}`);
199
195
  }
200
196
  function newline() {
201
197
  console.log('');
@@ -209,7 +205,7 @@ function divider() {
209
205
  // ---------------------------------------------------------------------------
210
206
  function banner() {
211
207
  console.log('');
212
- console.log(` ${(0, exports.bold)((0, exports.cyan)('turbine'))} ${(0, exports.dim)('by')} ${(0, exports.bold)('BataData')}`);
208
+ console.log(` ${(0, exports.bold)((0, exports.cyan)('turbine-orm'))}`);
213
209
  console.log(` ${(0, exports.dim)('TypeScript ORM with json_agg nested queries')}`);
214
210
  console.log('');
215
211
  }
@@ -226,7 +222,7 @@ function elapsed(startMs) {
226
222
  // Strip ANSI codes (for width calculation)
227
223
  // ---------------------------------------------------------------------------
228
224
  function stripAnsi(s) {
229
- // eslint-disable-next-line no-control-regex
225
+ // biome-ignore lint/suspicious/noControlCharactersInRegex: ANSI escape codes require control characters
230
226
  return s.replace(/\x1b\[[0-9;]*m/g, '');
231
227
  }
232
228
  // ---------------------------------------------------------------------------