s3db.js 12.0.1 → 12.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.
Files changed (45) hide show
  1. package/README.md +212 -196
  2. package/dist/s3db.cjs.js +1431 -4001
  3. package/dist/s3db.cjs.js.map +1 -1
  4. package/dist/s3db.es.js +1426 -3997
  5. package/dist/s3db.es.js.map +1 -1
  6. package/mcp/entrypoint.js +91 -57
  7. package/package.json +7 -1
  8. package/src/cli/index.js +954 -43
  9. package/src/cli/migration-manager.js +270 -0
  10. package/src/concerns/calculator.js +0 -4
  11. package/src/concerns/metadata-encoding.js +1 -21
  12. package/src/concerns/plugin-storage.js +17 -4
  13. package/src/concerns/typescript-generator.d.ts +171 -0
  14. package/src/concerns/typescript-generator.js +275 -0
  15. package/src/database.class.js +171 -28
  16. package/src/index.js +15 -9
  17. package/src/plugins/api/index.js +0 -1
  18. package/src/plugins/api/routes/resource-routes.js +86 -1
  19. package/src/plugins/api/server.js +79 -3
  20. package/src/plugins/api/utils/openapi-generator.js +195 -5
  21. package/src/plugins/backup/multi-backup-driver.class.js +0 -1
  22. package/src/plugins/backup.plugin.js +7 -14
  23. package/src/plugins/concerns/plugin-dependencies.js +73 -19
  24. package/src/plugins/eventual-consistency/analytics.js +0 -2
  25. package/src/plugins/eventual-consistency/consolidation.js +2 -13
  26. package/src/plugins/eventual-consistency/index.js +0 -1
  27. package/src/plugins/eventual-consistency/install.js +1 -1
  28. package/src/plugins/geo.plugin.js +5 -6
  29. package/src/plugins/importer/index.js +1 -1
  30. package/src/plugins/plugin.class.js +5 -0
  31. package/src/plugins/relation.plugin.js +193 -57
  32. package/src/plugins/replicator.plugin.js +12 -21
  33. package/src/plugins/s3-queue.plugin.js +4 -4
  34. package/src/plugins/scheduler.plugin.js +10 -12
  35. package/src/plugins/state-machine.plugin.js +8 -12
  36. package/src/plugins/tfstate/README.md +1 -1
  37. package/src/plugins/tfstate/errors.js +3 -3
  38. package/src/plugins/tfstate/index.js +41 -67
  39. package/src/plugins/ttl.plugin.js +479 -304
  40. package/src/resource.class.js +263 -61
  41. package/src/schema.class.js +0 -2
  42. package/src/testing/factory.class.js +286 -0
  43. package/src/testing/index.js +15 -0
  44. package/src/testing/seeder.class.js +183 -0
  45. package/dist/s3db-cli.js +0 -55543
package/src/cli/index.js CHANGED
@@ -330,52 +330,159 @@ program
330
330
  }
331
331
  });
332
332
 
333
- // Interactive mode
333
+ // Console command (enhanced REPL)
334
+ program
335
+ .command('console')
336
+ .description('Enhanced interactive console')
337
+ .option('-c, --connection <string>', 'Connection string')
338
+ .action(async (options) => {
339
+ await consoleREPL(options);
340
+ });
341
+
342
+ // Interactive mode (alias for console)
334
343
  program
335
344
  .command('interactive')
336
- .description('Interactive REPL mode')
345
+ .description('Interactive REPL mode (alias for console)')
337
346
  .option('-c, --connection <string>', 'Connection string')
338
347
  .action(async (options) => {
339
- console.log(chalk.cyan('S3DB Interactive Mode'));
340
- console.log(chalk.gray('Type "help" for commands, "exit" to quit\n'));
341
-
342
- const db = await getDatabase(options);
343
- await db.init();
344
-
345
- const repl = await import('repl');
346
- const server = repl.start({
347
- prompt: chalk.green('s3db> '),
348
- eval: async (cmd, context, filename, callback) => {
349
- try {
350
- // Make db available in REPL
351
- context.db = db;
352
-
353
- // Parse commands
354
- const trimmed = cmd.trim();
355
- if (trimmed === 'help') {
356
- console.log(`
357
- Available commands:
358
- db - Database instance
359
- db.listResources() - List all resources
360
- db.resource('name') - Get a resource
361
- await ... - Use await for async operations
362
- .exit - Exit REPL
363
- `);
348
+ await consoleREPL(options);
349
+ });
350
+
351
+ async function consoleREPL(options) {
352
+ console.log(chalk.cyan.bold('\n┌─────────────────────────────────────┐'));
353
+ console.log(chalk.cyan.bold('│ S3DB Interactive Console v12.0 │'));
354
+ console.log(chalk.cyan.bold('└─────────────────────────────────────┘\n'));
355
+
356
+ const db = await getDatabase(options);
357
+ await db.init();
358
+
359
+ const resources = await db.listResources();
360
+ console.log(chalk.gray(`Connected to: ${db.client.config.bucket}`));
361
+ console.log(chalk.gray(`Resources: ${resources.length}\n`));
362
+
363
+ console.log(chalk.yellow('Quick commands:'));
364
+ console.log(chalk.gray(' .help - Show all commands'));
365
+ console.log(chalk.gray(' .resources - List all resources'));
366
+ console.log(chalk.gray(' .use <name> - Select a resource'));
367
+ console.log(chalk.gray(' .exit - Exit console\n'));
368
+
369
+ const repl = await import('repl');
370
+ const { Factory, Seeder } = await import('../testing/index.js');
371
+
372
+ // Set up global context
373
+ let currentResource = null;
374
+
375
+ const server = repl.start({
376
+ prompt: chalk.green('s3db> '),
377
+ useColors: true,
378
+ ignoreUndefined: true,
379
+ eval: async (cmd, context, filename, callback) => {
380
+ try {
381
+ const trimmed = cmd.trim().replace(/\n$/, '');
382
+
383
+ // Special commands
384
+ if (trimmed === '.help' || trimmed === 'help') {
385
+ console.log(chalk.cyan('\n📖 S3DB Console Commands:\n'));
386
+ console.log(chalk.bold('Database:'));
387
+ console.log(' db - Database instance');
388
+ console.log(' db.listResources() - List all resources');
389
+ console.log(' db.resource(name) - Get a resource');
390
+ console.log(chalk.bold('\nResource Selection:'));
391
+ console.log(' .use <name> - Select active resource');
392
+ console.log(' resource - Current resource (if selected)');
393
+ console.log(chalk.bold('\nData Operations:'));
394
+ console.log(' await resource.list() - List records');
395
+ console.log(' await resource.get(id) - Get record by ID');
396
+ console.log(' await resource.insert({})- Insert record');
397
+ console.log(' await resource.count() - Count records');
398
+ console.log(chalk.bold('\nTesting:'));
399
+ console.log(' Factory - Factory class');
400
+ console.log(' Seeder - Seeder class');
401
+ console.log(chalk.bold('\nUtilities:'));
402
+ console.log(' .resources - List resources');
403
+ console.log(' .clear - Clear console');
404
+ console.log(' .exit - Exit\n');
405
+ callback(null);
406
+ return;
407
+ }
408
+
409
+ if (trimmed === '.resources') {
410
+ const table = new Table({
411
+ head: ['Resource', 'Behavior', 'Partitions'],
412
+ style: { head: ['cyan'] }
413
+ });
414
+
415
+ resources.forEach(r => {
416
+ table.push([
417
+ r.name,
418
+ r.config.behavior || 'user-managed',
419
+ Object.keys(r.config.partitions || {}).length
420
+ ]);
421
+ });
422
+
423
+ console.log(table.toString());
424
+ callback(null);
425
+ return;
426
+ }
427
+
428
+ if (trimmed.startsWith('.use ')) {
429
+ const resourceName = trimmed.replace('.use ', '').trim();
430
+ try {
431
+ currentResource = await db.resource(resourceName);
432
+ console.log(chalk.green(`✓ Now using resource: ${resourceName}`));
433
+ context.resource = currentResource;
434
+ callback(null);
435
+ } catch (err) {
436
+ console.log(chalk.red(`✗ Resource not found: ${resourceName}`));
364
437
  callback(null);
365
- } else {
366
- // Default eval
367
- const result = await eval(cmd);
368
- callback(null, result);
369
438
  }
370
- } catch (error) {
371
- callback(error);
439
+ return;
440
+ }
441
+
442
+ if (trimmed === '.clear') {
443
+ console.clear();
444
+ callback(null);
445
+ return;
372
446
  }
447
+
448
+ // Set up context
449
+ context.db = db;
450
+ context.resource = currentResource;
451
+ context.Factory = Factory;
452
+ context.Seeder = Seeder;
453
+ context.chalk = chalk;
454
+ context.Table = Table;
455
+
456
+ // Evaluate JavaScript
457
+ const AsyncFunction = Object.getPrototypeOf(async function(){}).constructor;
458
+ const fn = new AsyncFunction('context', `
459
+ with (context) {
460
+ return (async () => {
461
+ ${trimmed}
462
+ })();
463
+ }
464
+ `);
465
+
466
+ const result = await fn(context);
467
+ callback(null, result);
468
+
469
+ } catch (error) {
470
+ console.log(chalk.red(`Error: ${error.message}`));
471
+ callback(null);
373
472
  }
374
- });
375
-
376
- server.setupHistory(path.join(os.homedir(), '.s3db', 'history'), () => {});
473
+ }
377
474
  });
378
475
 
476
+ // Set up history
477
+ server.setupHistory(path.join(os.homedir(), '.s3db', 'history'), () => {});
478
+
479
+ // Set up autocomplete
480
+ server.on('exit', () => {
481
+ console.log(chalk.cyan('\n👋 Bye!\n'));
482
+ process.exit(0);
483
+ });
484
+ }
485
+
379
486
  // Stats command
380
487
  program
381
488
  .command('stats [resource]')
@@ -383,37 +490,37 @@ program
383
490
  .option('-c, --connection <string>', 'Connection string')
384
491
  .action(async (resourceName, options) => {
385
492
  const spinner = ora('Gathering stats...').start();
386
-
493
+
387
494
  try {
388
495
  const db = await getDatabase(options);
389
496
  await db.init();
390
-
497
+
391
498
  if (resourceName) {
392
499
  const resource = await db.resource(resourceName);
393
500
  const count = await resource.count();
394
501
  spinner.stop();
395
-
502
+
396
503
  console.log(chalk.cyan(`\nResource: ${resourceName}`));
397
504
  console.log(`Total records: ${count}`);
398
505
  } else {
399
506
  const resources = await db.listResources();
400
507
  spinner.stop();
401
-
508
+
402
509
  console.log(chalk.cyan('\nDatabase Statistics'));
403
510
  console.log(`Total resources: ${resources.length}`);
404
-
511
+
405
512
  if (resources.length > 0) {
406
513
  const table = new Table({
407
514
  head: ['Resource', 'Count'],
408
515
  style: { head: ['cyan'] }
409
516
  });
410
-
517
+
411
518
  for (const r of resources) {
412
519
  const resource = await db.resource(r.name);
413
520
  const count = await resource.count();
414
521
  table.push([r.name, count]);
415
522
  }
416
-
523
+
417
524
  console.log(table.toString());
418
525
  }
419
526
  }
@@ -423,4 +530,808 @@ program
423
530
  }
424
531
  });
425
532
 
533
+ // Schema command
534
+ program
535
+ .command('schema <resource>')
536
+ .description('Show resource schema')
537
+ .option('-c, --connection <string>', 'Connection string')
538
+ .option('-f, --format <type>', 'Output format: json, typescript, bigquery', 'json')
539
+ .action(async (resourceName, options) => {
540
+ const spinner = ora('Loading schema...').start();
541
+
542
+ try {
543
+ const db = await getDatabase(options);
544
+ await db.init();
545
+
546
+ const resource = await db.resource(resourceName);
547
+ const schema = resource.export();
548
+ spinner.stop();
549
+
550
+ if (options.format === 'typescript') {
551
+ const { generateTypes } = await import('../concerns/typescript-generator.js');
552
+ const types = generateTypes(db);
553
+ console.log(types);
554
+ } else if (options.format === 'bigquery') {
555
+ console.log(chalk.cyan(`\nBigQuery DDL for ${resourceName}:\n`));
556
+ console.log(`CREATE TABLE \`project.dataset.${resourceName}\` (`);
557
+
558
+ const fields = [];
559
+ fields.push(' id STRING NOT NULL');
560
+
561
+ for (const [field, type] of Object.entries(schema.attributes)) {
562
+ const typeStr = type.toString();
563
+ let bqType = 'STRING';
564
+
565
+ if (typeStr.includes('number')) bqType = 'FLOAT64';
566
+ else if (typeStr.includes('boolean')) bqType = 'BOOL';
567
+ else if (typeStr.includes('array')) bqType = 'ARRAY<STRING>';
568
+ else if (typeStr.includes('object')) bqType = 'JSON';
569
+ else if (typeStr.includes('embedding')) bqType = 'ARRAY<FLOAT64>';
570
+
571
+ const required = typeStr.includes('required') ? 'NOT NULL' : '';
572
+ fields.push(` ${field} ${bqType} ${required}`.trim());
573
+ }
574
+
575
+ if (schema.timestamps) {
576
+ fields.push(' createdAt TIMESTAMP');
577
+ fields.push(' updatedAt TIMESTAMP');
578
+ }
579
+
580
+ console.log(fields.join(',\n'));
581
+ console.log(');');
582
+ } else {
583
+ // JSON format (default)
584
+ console.log(JSON.stringify(schema, null, 2));
585
+ }
586
+ } catch (error) {
587
+ spinner.fail(chalk.red(error.message));
588
+ process.exit(1);
589
+ }
590
+ });
591
+
592
+ // Schema diff command
593
+ program
594
+ .command('schema-diff')
595
+ .description('Compare local schema files with deployed schemas')
596
+ .option('-c, --connection <string>', 'Connection string')
597
+ .option('-d, --dir <path>', 'Local schema directory', './schemas')
598
+ .action(async (options) => {
599
+ const spinner = ora('Comparing schemas...').start();
600
+
601
+ try {
602
+ const db = await getDatabase(options);
603
+ await db.init();
604
+
605
+ const remoteResources = await db.listResources();
606
+ spinner.stop();
607
+
608
+ // Try to load local schemas
609
+ let localSchemas = {};
610
+ try {
611
+ const schemaFiles = await fs.readdir(options.dir);
612
+ for (const file of schemaFiles) {
613
+ if (file.endsWith('.json')) {
614
+ const content = await fs.readFile(path.join(options.dir, file), 'utf-8');
615
+ const schema = JSON.parse(content);
616
+ localSchemas[schema.name] = schema;
617
+ }
618
+ }
619
+ } catch (err) {
620
+ console.log(chalk.yellow(`No local schemas found in ${options.dir}`));
621
+ }
622
+
623
+ const table = new Table({
624
+ head: ['Resource', 'Status', 'Changes'],
625
+ style: { head: ['cyan'] }
626
+ });
627
+
628
+ // Check remote resources
629
+ for (const remote of remoteResources) {
630
+ const local = localSchemas[remote.name];
631
+
632
+ if (!local) {
633
+ table.push([remote.name, chalk.yellow('Remote Only'), 'Not in local schemas']);
634
+ } else {
635
+ // Compare attributes
636
+ const remoteAttrs = Object.keys(remote.attributes || {}).sort();
637
+ const localAttrs = Object.keys(local.attributes || {}).sort();
638
+
639
+ if (JSON.stringify(remoteAttrs) !== JSON.stringify(localAttrs)) {
640
+ const diff = {
641
+ added: localAttrs.filter(a => !remoteAttrs.includes(a)),
642
+ removed: remoteAttrs.filter(a => !localAttrs.includes(a))
643
+ };
644
+ table.push([
645
+ remote.name,
646
+ chalk.yellow('Modified'),
647
+ `+${diff.added.length} -${diff.removed.length} fields`
648
+ ]);
649
+ } else {
650
+ table.push([remote.name, chalk.green('Synced'), 'No changes']);
651
+ }
652
+
653
+ delete localSchemas[remote.name];
654
+ }
655
+ }
656
+
657
+ // Check local-only schemas
658
+ for (const [name, schema] of Object.entries(localSchemas)) {
659
+ table.push([name, chalk.blue('Local Only'), 'Not deployed']);
660
+ }
661
+
662
+ console.log(table.toString());
663
+ } catch (error) {
664
+ spinner.fail(chalk.red(error.message));
665
+ process.exit(1);
666
+ }
667
+ });
668
+
669
+ // Count with aggregation
670
+ program
671
+ .command('count <resource>')
672
+ .description('Count records with optional grouping')
673
+ .option('-c, --connection <string>', 'Connection string')
674
+ .option('-b, --by <field>', 'Group by field')
675
+ .option('-p, --partition <name>', 'Partition name')
676
+ .action(async (resourceName, options) => {
677
+ const spinner = ora('Counting...').start();
678
+
679
+ try {
680
+ const db = await getDatabase(options);
681
+ await db.init();
682
+
683
+ const resource = await db.resource(resourceName);
684
+
685
+ if (options.by) {
686
+ // Group by aggregation
687
+ const listOptions = options.partition ? { partition: options.partition } : {};
688
+ const records = await resource.list(listOptions);
689
+ spinner.stop();
690
+
691
+ const grouped = {};
692
+ for (const record of records) {
693
+ const value = record[options.by] || '(null)';
694
+ grouped[value] = (grouped[value] || 0) + 1;
695
+ }
696
+
697
+ const table = new Table({
698
+ head: [options.by, 'Count'],
699
+ style: { head: ['cyan'] }
700
+ });
701
+
702
+ Object.entries(grouped)
703
+ .sort((a, b) => b[1] - a[1])
704
+ .forEach(([key, count]) => {
705
+ table.push([key, count]);
706
+ });
707
+
708
+ console.log(table.toString());
709
+ console.log(chalk.gray(`\nTotal: ${records.length} records`));
710
+ } else {
711
+ const count = await resource.count();
712
+ spinner.stop();
713
+ console.log(chalk.cyan(`${resourceName}: ${count} records`));
714
+ }
715
+ } catch (error) {
716
+ spinner.fail(chalk.red(error.message));
717
+ process.exit(1);
718
+ }
719
+ });
720
+
721
+ // Explain command
722
+ program
723
+ .command('explain <resource>')
724
+ .description('Show partition structure and query plans')
725
+ .option('-c, --connection <string>', 'Connection string')
726
+ .option('-p, --partition <name>', 'Specific partition to explain')
727
+ .action(async (resourceName, options) => {
728
+ const spinner = ora('Analyzing...').start();
729
+
730
+ try {
731
+ const db = await getDatabase(options);
732
+ await db.init();
733
+
734
+ const resource = await db.resource(resourceName);
735
+ const schema = resource.export();
736
+ spinner.stop();
737
+
738
+ console.log(chalk.cyan(`\n📊 Resource: ${resourceName}\n`));
739
+
740
+ // Basic info
741
+ console.log(chalk.bold('Configuration:'));
742
+ console.log(` Behavior: ${schema.behavior || 'user-managed'}`);
743
+ console.log(` Timestamps: ${schema.timestamps ? '✓' : '✗'}`);
744
+ console.log(` Paranoid: ${schema.paranoid ? '✓' : '✗'}`);
745
+ console.log(` Async Partitions: ${schema.asyncPartitions ? '✓' : '✗'}`);
746
+
747
+ // Partitions
748
+ if (schema.partitions && Object.keys(schema.partitions).length > 0) {
749
+ console.log(chalk.bold('\nPartitions:'));
750
+
751
+ for (const [name, config] of Object.entries(schema.partitions)) {
752
+ if (options.partition && name !== options.partition) continue;
753
+
754
+ console.log(chalk.green(`\n ${name}:`));
755
+ console.log(` Fields: ${Object.keys(config.fields).join(', ')}`);
756
+
757
+ // Try to count partition keys
758
+ try {
759
+ const prefix = `resource=${resourceName}/partition=${name}/`;
760
+ const keys = await resource.client.listObjects({ prefix, maxKeys: 1000 });
761
+
762
+ // Extract unique partition values
763
+ const values = new Set();
764
+ keys.forEach(key => {
765
+ const parts = key.split('/');
766
+ const valueParts = parts.filter(p => !p.startsWith('resource=') && !p.startsWith('partition=') && !p.startsWith('id='));
767
+ valueParts.forEach(v => values.add(v));
768
+ });
769
+
770
+ console.log(` Unique values: ${values.size}`);
771
+ console.log(` Total keys: ${keys.length}`);
772
+ console.log(` Key pattern: ${prefix}<values>/id=<id>`);
773
+ } catch (err) {
774
+ console.log(chalk.gray(' (Unable to analyze partition keys)'));
775
+ }
776
+ }
777
+ } else {
778
+ console.log(chalk.yellow('\nNo partitions configured'));
779
+ }
780
+
781
+ // Query plan
782
+ console.log(chalk.bold('\n🔍 Query Optimization:'));
783
+ if (schema.partitions && Object.keys(schema.partitions).length > 0) {
784
+ console.log(chalk.green(' ✓ O(1) partition lookups available'));
785
+ console.log(' 💡 Use getFromPartition() for best performance');
786
+ } else {
787
+ console.log(chalk.yellow(' ⚠️ O(n) full scans only (no partitions)'));
788
+ console.log(' 💡 Consider adding partitions for common queries');
789
+ }
790
+
791
+ } catch (error) {
792
+ spinner.fail(chalk.red(error.message));
793
+ process.exit(1);
794
+ }
795
+ });
796
+
797
+ // Analyze command
798
+ program
799
+ .command('analyze <resource>')
800
+ .description('Analyze resource performance and storage')
801
+ .option('-c, --connection <string>', 'Connection string')
802
+ .action(async (resourceName, options) => {
803
+ const spinner = ora('Analyzing resource...').start();
804
+
805
+ try {
806
+ const db = await getDatabase(options);
807
+ await db.init();
808
+
809
+ const resource = await db.resource(resourceName);
810
+
811
+ // Get sample records
812
+ const sample = await resource.list({ limit: 100 });
813
+ const count = await resource.count();
814
+ spinner.stop();
815
+
816
+ console.log(chalk.cyan(`\n📈 Analysis: ${resourceName}\n`));
817
+
818
+ // Record count
819
+ console.log(chalk.bold('Records:'));
820
+ console.log(` Total: ${count}`);
821
+ console.log(` Sampled: ${sample.length}`);
822
+
823
+ // Size analysis
824
+ if (sample.length > 0) {
825
+ console.log(chalk.bold('\nSize Analysis (from sample):'));
826
+
827
+ const sizes = sample.map(r => {
828
+ const str = JSON.stringify(r);
829
+ return Buffer.byteLength(str, 'utf8');
830
+ });
831
+
832
+ const avg = sizes.reduce((a, b) => a + b, 0) / sizes.length;
833
+ const min = Math.min(...sizes);
834
+ const max = Math.max(...sizes);
835
+
836
+ console.log(` Average: ${(avg / 1024).toFixed(2)} KB`);
837
+ console.log(` Min: ${(min / 1024).toFixed(2)} KB`);
838
+ console.log(` Max: ${(max / 1024).toFixed(2)} KB`);
839
+ console.log(` Estimated total: ${((avg * count) / 1024 / 1024).toFixed(2)} MB`);
840
+
841
+ // Field analysis
842
+ console.log(chalk.bold('\nField Usage:'));
843
+ const fieldCounts = {};
844
+ const fieldSizes = {};
845
+
846
+ sample.forEach(record => {
847
+ Object.keys(record).forEach(field => {
848
+ fieldCounts[field] = (fieldCounts[field] || 0) + 1;
849
+ const value = record[field];
850
+ if (value !== null && value !== undefined) {
851
+ const size = Buffer.byteLength(JSON.stringify(value), 'utf8');
852
+ fieldSizes[field] = (fieldSizes[field] || 0) + size;
853
+ }
854
+ });
855
+ });
856
+
857
+ const table = new Table({
858
+ head: ['Field', 'Fill Rate', 'Avg Size'],
859
+ style: { head: ['cyan'] }
860
+ });
861
+
862
+ Object.keys(fieldCounts).forEach(field => {
863
+ const fillRate = ((fieldCounts[field] / sample.length) * 100).toFixed(1);
864
+ const avgSize = fieldSizes[field] ? (fieldSizes[field] / fieldCounts[field]) : 0;
865
+ table.push([
866
+ field,
867
+ `${fillRate}%`,
868
+ `${avgSize.toFixed(0)} bytes`
869
+ ]);
870
+ });
871
+
872
+ console.log(table.toString());
873
+ }
874
+
875
+ // Performance recommendations
876
+ console.log(chalk.bold('\n💡 Recommendations:'));
877
+
878
+ const schema = resource.export();
879
+ if (!schema.partitions || Object.keys(schema.partitions).length === 0) {
880
+ console.log(chalk.yellow(' • Add partitions for frequently queried fields'));
881
+ }
882
+
883
+ if (count > 1000 && !schema.asyncPartitions) {
884
+ console.log(chalk.yellow(' • Enable asyncPartitions for faster writes'));
885
+ }
886
+
887
+ if (sample.length > 0) {
888
+ const avgSize = sample.reduce((sum, r) => {
889
+ return sum + Buffer.byteLength(JSON.stringify(r), 'utf8');
890
+ }, 0) / sample.length;
891
+
892
+ if (avgSize > 2000) {
893
+ console.log(chalk.yellow(' • Consider body-only behavior for large records'));
894
+ }
895
+ }
896
+
897
+ } catch (error) {
898
+ spinner.fail(chalk.red(error.message));
899
+ process.exit(1);
900
+ }
901
+ });
902
+
903
+ // Test commands
904
+ const test = program.command('test').description('Testing utilities');
905
+
906
+ test
907
+ .command('seed [resource]')
908
+ .description('Seed database with test data')
909
+ .option('-c, --connection <string>', 'Connection string')
910
+ .option('-n, --count <number>', 'Number of records to create', '10')
911
+ .option('-f, --file <path>', 'Seed from factory definition file')
912
+ .action(async (resourceName, options) => {
913
+ const spinner = ora('Seeding database...').start();
914
+
915
+ try {
916
+ const db = await getDatabase(options);
917
+ await db.init();
918
+
919
+ const { Factory, Seeder } = await import('../testing/index.js');
920
+ Factory.setDatabase(db);
921
+
922
+ const seeder = new Seeder(db, { verbose: false });
923
+
924
+ if (options.file) {
925
+ // Load factory definitions from file
926
+ const factoryModule = await import(path.resolve(options.file));
927
+ spinner.text = 'Running custom seed...';
928
+
929
+ const result = await seeder.call(factoryModule.default || factoryModule.seed);
930
+ spinner.succeed(chalk.green('✓ Custom seed completed'));
931
+
932
+ console.log(JSON.stringify(result, null, 2));
933
+ } else if (resourceName) {
934
+ // Seed specific resource
935
+ const count = parseInt(options.count);
936
+ spinner.text = `Seeding ${count} ${resourceName}...`;
937
+
938
+ const factory = Factory.get(resourceName);
939
+ if (!factory) {
940
+ spinner.fail(chalk.red(`No factory found for '${resourceName}'`));
941
+ console.log(chalk.yellow('\n💡 Define a factory first or use --file option'));
942
+ process.exit(1);
943
+ }
944
+
945
+ const records = await factory.createMany(count);
946
+ spinner.succeed(chalk.green(`✓ Created ${records.length} ${resourceName}`));
947
+
948
+ console.log(chalk.gray(`IDs: ${records.map(r => r.id).slice(0, 5).join(', ')}${records.length > 5 ? '...' : ''}`));
949
+ } else {
950
+ // Seed all resources using factories
951
+ const resources = await db.listResources();
952
+ spinner.stop();
953
+
954
+ const specs = {};
955
+ for (const r of resources) {
956
+ const factory = Factory.get(r.name);
957
+ if (factory) {
958
+ specs[r.name] = parseInt(options.count);
959
+ }
960
+ }
961
+
962
+ if (Object.keys(specs).length === 0) {
963
+ console.log(chalk.yellow('No factories defined. Use --file to load factory definitions.'));
964
+ return;
965
+ }
966
+
967
+ console.log(chalk.cyan('Seeding with factories:'));
968
+ console.log(specs);
969
+
970
+ const created = await seeder.seed(specs);
971
+
972
+ console.log(chalk.green('\n✓ Seed completed:'));
973
+ Object.entries(created).forEach(([name, records]) => {
974
+ console.log(` ${name}: ${records.length} records`);
975
+ });
976
+ }
977
+ } catch (error) {
978
+ spinner.fail(chalk.red(error.message));
979
+ console.error(error.stack);
980
+ process.exit(1);
981
+ }
982
+ });
983
+
984
+ test
985
+ .command('setup')
986
+ .description('Setup isolated test database')
987
+ .option('-c, --connection <string>', 'Connection string')
988
+ .option('-n, --name <name>', 'Test database name', `test-${Date.now()}`)
989
+ .option('-f, --fixtures <path>', 'Load fixtures from file')
990
+ .action(async (options) => {
991
+ const spinner = ora('Setting up test database...').start();
992
+
993
+ try {
994
+ // Create test database connection
995
+ const config = await loadConfig();
996
+ let baseConnection = options.connection || config.connection || process.env.S3DB_CONNECTION;
997
+
998
+ if (!baseConnection) {
999
+ spinner.fail(chalk.red('No connection string provided'));
1000
+ process.exit(1);
1001
+ }
1002
+
1003
+ // Modify connection to use test database path
1004
+ const url = new URL(baseConnection);
1005
+ const originalPath = url.pathname;
1006
+ url.pathname = `${originalPath}/${options.name}`;
1007
+ const testConnection = url.toString();
1008
+
1009
+ const db = new S3db({ connectionString: testConnection });
1010
+ await db.init();
1011
+
1012
+ spinner.text = 'Loading fixtures...';
1013
+
1014
+ if (options.fixtures) {
1015
+ const fixturesModule = await import(path.resolve(options.fixtures));
1016
+ const fixtures = fixturesModule.default || fixturesModule.fixtures;
1017
+
1018
+ if (typeof fixtures === 'function') {
1019
+ await fixtures(db);
1020
+ } else {
1021
+ // Load fixtures as data
1022
+ for (const [resourceName, records] of Object.entries(fixtures)) {
1023
+ const resource = await db.resource(resourceName);
1024
+ for (const record of records) {
1025
+ await resource.insert(record);
1026
+ }
1027
+ }
1028
+ }
1029
+ }
1030
+
1031
+ spinner.succeed(chalk.green('✓ Test database ready'));
1032
+
1033
+ console.log(chalk.cyan('\nTest Database:'));
1034
+ console.log(` Name: ${options.name}`);
1035
+ console.log(` Connection: ${testConnection}`);
1036
+ console.log(chalk.gray('\n💡 Use this connection string for your tests'));
1037
+ console.log(chalk.gray(`💡 Teardown with: s3db test teardown --name ${options.name}`));
1038
+
1039
+ // Save test connection for teardown
1040
+ const testConfig = { ...config, testConnection, testName: options.name };
1041
+ await saveConfig(testConfig);
1042
+
1043
+ } catch (error) {
1044
+ spinner.fail(chalk.red(error.message));
1045
+ console.error(error.stack);
1046
+ process.exit(1);
1047
+ }
1048
+ });
1049
+
1050
+ test
1051
+ .command('teardown')
1052
+ .description('Clean up test database')
1053
+ .option('-c, --connection <string>', 'Connection string')
1054
+ .option('-n, --name <name>', 'Test database name')
1055
+ .option('--all', 'Teardown all test databases')
1056
+ .action(async (options) => {
1057
+ const spinner = ora('Cleaning up...').start();
1058
+
1059
+ try {
1060
+ const config = await loadConfig();
1061
+ let connection = options.connection;
1062
+
1063
+ if (!connection && options.name) {
1064
+ const baseConnection = config.connection || process.env.S3DB_CONNECTION;
1065
+ const url = new URL(baseConnection);
1066
+ const originalPath = url.pathname.replace(/\/[^\/]+$/, '');
1067
+ url.pathname = `${originalPath}/${options.name}`;
1068
+ connection = url.toString();
1069
+ } else if (!connection && config.testConnection) {
1070
+ connection = config.testConnection;
1071
+ }
1072
+
1073
+ if (!connection) {
1074
+ spinner.fail(chalk.red('No test database to teardown'));
1075
+ process.exit(1);
1076
+ }
1077
+
1078
+ const db = new S3db({ connectionString: connection });
1079
+ await db.init();
1080
+
1081
+ const { Seeder } = await import('../testing/index.js');
1082
+ const seeder = new Seeder(db, { verbose: false });
1083
+
1084
+ spinner.text = 'Resetting database...';
1085
+ await seeder.reset();
1086
+
1087
+ spinner.succeed(chalk.green('✓ Test database cleaned up'));
1088
+
1089
+ // Clean up config
1090
+ if (config.testConnection) {
1091
+ delete config.testConnection;
1092
+ delete config.testName;
1093
+ await saveConfig(config);
1094
+ }
1095
+
1096
+ } catch (error) {
1097
+ spinner.fail(chalk.red(error.message));
1098
+ console.error(error.stack);
1099
+ process.exit(1);
1100
+ }
1101
+ });
1102
+
1103
+ test
1104
+ .command('truncate <resource>')
1105
+ .description('Delete all data from a resource')
1106
+ .option('-c, --connection <string>', 'Connection string')
1107
+ .option('--force', 'Skip confirmation')
1108
+ .action(async (resourceName, options) => {
1109
+ if (!options.force) {
1110
+ const { confirm } = await inquirer.prompt([
1111
+ {
1112
+ type: 'confirm',
1113
+ name: 'confirm',
1114
+ message: `This will delete ALL data from ${resourceName}. Continue?`,
1115
+ default: false
1116
+ }
1117
+ ]);
1118
+
1119
+ if (!confirm) {
1120
+ console.log(chalk.yellow('Cancelled'));
1121
+ return;
1122
+ }
1123
+ }
1124
+
1125
+ const spinner = ora(`Truncating ${resourceName}...`).start();
1126
+
1127
+ try {
1128
+ const db = await getDatabase(options);
1129
+ await db.init();
1130
+
1131
+ const { Seeder } = await import('../testing/index.js');
1132
+ const seeder = new Seeder(db, { verbose: false });
1133
+
1134
+ await seeder.truncate([resourceName]);
1135
+ spinner.succeed(chalk.green(`✓ ${resourceName} truncated`));
1136
+
1137
+ } catch (error) {
1138
+ spinner.fail(chalk.red(error.message));
1139
+ process.exit(1);
1140
+ }
1141
+ });
1142
+
1143
+ // Migration commands
1144
+ const migrate = program.command('migrate').description('Database migrations');
1145
+
1146
+ migrate
1147
+ .command('generate <name>')
1148
+ .description('Generate a new migration file')
1149
+ .option('-d, --dir <path>', 'Migrations directory', './migrations')
1150
+ .action(async (name, options) => {
1151
+ const spinner = ora('Generating migration...').start();
1152
+
1153
+ try {
1154
+ const { MigrationManager } = await import('./migration-manager.js');
1155
+ const manager = new MigrationManager(null, options.dir);
1156
+
1157
+ const { filename, filepath } = await manager.generate(name);
1158
+ spinner.succeed(chalk.green('✓ Migration generated'));
1159
+
1160
+ console.log(chalk.cyan('\nMigration file:'));
1161
+ console.log(` ${filepath}`);
1162
+ console.log(chalk.gray('\n💡 Edit the file to add your migration logic'));
1163
+ console.log(chalk.gray('💡 Run with: s3db migrate up'));
1164
+
1165
+ } catch (error) {
1166
+ spinner.fail(chalk.red(error.message));
1167
+ console.error(error.stack);
1168
+ process.exit(1);
1169
+ }
1170
+ });
1171
+
1172
+ migrate
1173
+ .command('up')
1174
+ .description('Run pending migrations')
1175
+ .option('-c, --connection <string>', 'Connection string')
1176
+ .option('-d, --dir <path>', 'Migrations directory', './migrations')
1177
+ .option('-s, --step <number>', 'Number of migrations to run')
1178
+ .action(async (options) => {
1179
+ const spinner = ora('Running migrations...').start();
1180
+
1181
+ try {
1182
+ const db = await getDatabase(options);
1183
+ await db.init();
1184
+
1185
+ const { MigrationManager } = await import('./migration-manager.js');
1186
+ const manager = new MigrationManager(db, options.dir);
1187
+ await manager.init();
1188
+
1189
+ const step = options.step ? parseInt(options.step) : null;
1190
+ const result = await manager.up({ step });
1191
+
1192
+ spinner.succeed(chalk.green(`✓ ${result.message}`));
1193
+
1194
+ if (result.migrations.length > 0) {
1195
+ console.log(chalk.cyan('\nMigrations executed:'));
1196
+ result.migrations.forEach(m => console.log(` • ${m}`));
1197
+ console.log(chalk.gray(`\nBatch: ${result.batch}`));
1198
+ }
1199
+
1200
+ } catch (error) {
1201
+ spinner.fail(chalk.red(error.message));
1202
+ console.error(error.stack);
1203
+ process.exit(1);
1204
+ }
1205
+ });
1206
+
1207
+ migrate
1208
+ .command('down')
1209
+ .description('Rollback migrations')
1210
+ .option('-c, --connection <string>', 'Connection string')
1211
+ .option('-d, --dir <path>', 'Migrations directory', './migrations')
1212
+ .option('-s, --step <number>', 'Number of migrations to rollback', '1')
1213
+ .action(async (options) => {
1214
+ const spinner = ora('Rolling back migrations...').start();
1215
+
1216
+ try {
1217
+ const db = await getDatabase(options);
1218
+ await db.init();
1219
+
1220
+ const { MigrationManager } = await import('./migration-manager.js');
1221
+ const manager = new MigrationManager(db, options.dir);
1222
+ await manager.init();
1223
+
1224
+ const step = parseInt(options.step);
1225
+ const result = await manager.down({ step });
1226
+
1227
+ spinner.succeed(chalk.green(`✓ ${result.message}`));
1228
+
1229
+ if (result.migrations.length > 0) {
1230
+ console.log(chalk.cyan('\nMigrations rolled back:'));
1231
+ result.migrations.forEach(m => console.log(` • ${m}`));
1232
+ }
1233
+
1234
+ } catch (error) {
1235
+ spinner.fail(chalk.red(error.message));
1236
+ console.error(error.stack);
1237
+ process.exit(1);
1238
+ }
1239
+ });
1240
+
1241
+ migrate
1242
+ .command('reset')
1243
+ .description('Reset all migrations')
1244
+ .option('-c, --connection <string>', 'Connection string')
1245
+ .option('-d, --dir <path>', 'Migrations directory', './migrations')
1246
+ .option('--force', 'Skip confirmation')
1247
+ .action(async (options) => {
1248
+ if (!options.force) {
1249
+ const { confirm } = await inquirer.prompt([
1250
+ {
1251
+ type: 'confirm',
1252
+ name: 'confirm',
1253
+ message: 'This will rollback ALL migrations. Continue?',
1254
+ default: false
1255
+ }
1256
+ ]);
1257
+
1258
+ if (!confirm) {
1259
+ console.log(chalk.yellow('Cancelled'));
1260
+ return;
1261
+ }
1262
+ }
1263
+
1264
+ const spinner = ora('Resetting migrations...').start();
1265
+
1266
+ try {
1267
+ const db = await getDatabase(options);
1268
+ await db.init();
1269
+
1270
+ const { MigrationManager } = await import('./migration-manager.js');
1271
+ const manager = new MigrationManager(db, options.dir);
1272
+ await manager.init();
1273
+
1274
+ const result = await manager.reset();
1275
+ spinner.succeed(chalk.green(`✓ ${result.message}`));
1276
+
1277
+ } catch (error) {
1278
+ spinner.fail(chalk.red(error.message));
1279
+ console.error(error.stack);
1280
+ process.exit(1);
1281
+ }
1282
+ });
1283
+
1284
+ migrate
1285
+ .command('status')
1286
+ .description('Show migration status')
1287
+ .option('-c, --connection <string>', 'Connection string')
1288
+ .option('-d, --dir <path>', 'Migrations directory', './migrations')
1289
+ .action(async (options) => {
1290
+ const spinner = ora('Checking migration status...').start();
1291
+
1292
+ try {
1293
+ const db = await getDatabase(options);
1294
+ await db.init();
1295
+
1296
+ const { MigrationManager } = await import('./migration-manager.js');
1297
+ const manager = new MigrationManager(db, options.dir);
1298
+ await manager.init();
1299
+
1300
+ const status = await manager.status();
1301
+ spinner.stop();
1302
+
1303
+ if (status.length === 0) {
1304
+ console.log(chalk.yellow('No migrations found'));
1305
+ return;
1306
+ }
1307
+
1308
+ const table = new Table({
1309
+ head: ['Migration', 'Status', 'Batch', 'Executed At'],
1310
+ style: { head: ['cyan'] }
1311
+ });
1312
+
1313
+ status.forEach(m => {
1314
+ const statusColor = m.status === 'executed' ? chalk.green : chalk.yellow;
1315
+ table.push([
1316
+ m.name,
1317
+ statusColor(m.status),
1318
+ m.batch || '-',
1319
+ m.executedAt ? new Date(m.executedAt).toLocaleString() : '-'
1320
+ ]);
1321
+ });
1322
+
1323
+ console.log(table.toString());
1324
+
1325
+ const pending = status.filter(m => m.status === 'pending').length;
1326
+ const executed = status.filter(m => m.status === 'executed').length;
1327
+
1328
+ console.log(chalk.gray(`\nTotal: ${status.length} | Executed: ${executed} | Pending: ${pending}`));
1329
+
1330
+ } catch (error) {
1331
+ spinner.fail(chalk.red(error.message));
1332
+ console.error(error.stack);
1333
+ process.exit(1);
1334
+ }
1335
+ });
1336
+
426
1337
  program.parse(process.argv);