create-nevela 0.1.5 → 0.2.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 (31) hide show
  1. package/package.json +1 -1
  2. package/project.mjs +11 -0
  3. package/template/laravel/composer.json +61 -59
  4. package/template/laravel/config/nevela.php +70 -0
  5. package/template/laravel/database/migrations/2026_10_04_000000_create_nevela_uploads_table.php +38 -0
  6. package/template/laravel/src/Console/GenerateCommand.php +25 -2
  7. package/template/laravel/src/Console/MakeResourceCommand.php +8 -0
  8. package/template/laravel/src/Console/SeedCommand.php +186 -124
  9. package/template/laravel/src/Console/UpdateCommand.php +64 -3
  10. package/template/laravel/src/Generator/ResourceGenerator.php +151 -19
  11. package/template/laravel/src/Http/ResourceRequest.php +4 -1
  12. package/template/laravel/src/Http/UploadController.php +147 -0
  13. package/template/laravel/src/Media/FileTypes.php +97 -0
  14. package/template/laravel/src/Media/ImageOptimizer.php +252 -0
  15. package/template/laravel/src/Media/Profile.php +94 -0
  16. package/template/laravel/src/Media/UploadRejected.php +8 -0
  17. package/template/laravel/src/Media/Uploads.php +325 -0
  18. package/template/laravel/src/Models/Upload.php +46 -0
  19. package/template/laravel/src/Nevela.php +20 -1
  20. package/template/laravel/src/NevelaServiceProvider.php +24 -0
  21. package/template/laravel/src/Rules/UploadKey.php +27 -0
  22. package/template/laravel/src/Support/Fake.php +194 -171
  23. package/template/laravel/src/Support/Field.php +79 -10
  24. package/template/laravel/src/Support/TemplateSource.php +1 -1
  25. package/template/web/app/api/[...path]/route.ts +87 -0
  26. package/template/web/app/dashboard/actions.ts +26 -9
  27. package/template/web/components/dashboard/fields/file-cell.tsx +10 -47
  28. package/template/web/components/dashboard/fields/file-field.tsx +25 -31
  29. package/template/web/lib/files.ts +21 -0
  30. package/template/web/package.json +1 -1
  31. package/template.mjs +1 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-nevela",
3
- "version": "0.1.5",
3
+ "version": "0.2.1",
4
4
  "description": "Create a new Nevela app: a Laravel API and a Next.js dashboard from one resource description.",
5
5
  "type": "module",
6
6
  "bin": {
package/project.mjs CHANGED
@@ -7,6 +7,7 @@
7
7
 
8
8
  import fs from 'node:fs';
9
9
  import path from 'node:path';
10
+ import { isNewer, satisfiesCaret } from './packagist.mjs';
10
11
  import { spawnCommand } from './shell.mjs';
11
12
 
12
13
  /** What `nevela <name>` runs: these go to `php artisan nevela:<name>`. */
@@ -64,6 +65,16 @@ export function repairManifest(manifest, latest) {
64
65
  if (typeof current === 'string' && /^\d+\.\d+(\.\d+)?$/.test(current)) {
65
66
  manifest.require['nevela/laravel'] = caret ?? `^${current.split('.').slice(0, 2).join('.')}`;
66
67
  repairs.push(`"nevela/laravel": "${current}" pinned one exact version; it is now "${manifest.require['nevela/laravel']}"`);
68
+ return repairs;
69
+ }
70
+
71
+ // Below 1.0 a caret holds the minor version: "^0.1" never reaches 0.2.0, so an update
72
+ // would finish having changed nothing. Updating is asking for the newest, so the range
73
+ // is moved to the one that has it.
74
+ const range = typeof current === 'string' ? /^\^(\d+)\.(\d+)(?:\.\d+)?$/.exec(current) : null;
75
+ if (range && caret && caret !== current && !satisfiesCaret(latest, [Number(range[1]), Number(range[2])]) && isNewer(latest, `${range[1]}.${range[2]}.0`)) {
76
+ manifest.require['nevela/laravel'] = caret;
77
+ repairs.push(`"nevela/laravel": "${current}" doesn't reach ${latest}; it is now "${caret}"`);
67
78
  }
68
79
  return repairs;
69
80
  }
@@ -1,59 +1,61 @@
1
- {
2
- "name": "nevela/laravel",
3
- "description": "Nevela for Laravel: describe a resource once and generate its model, migration, validation, policy and REST API, plus the Next.js dashboard screens for it.",
4
- "type": "library",
5
- "license": "MIT",
6
- "keywords": [
7
- "laravel",
8
- "nevela",
9
- "generator",
10
- "crud",
11
- "rest-api",
12
- "nextjs",
13
- "dashboard",
14
- "scaffold"
15
- ],
16
- "homepage": "https://nevela-docs.vercel.app",
17
- "authors": [
18
- {
19
- "name": "Mark Cole MUKISA",
20
- "homepage": "https://github.com/MarkColeMukisa"
21
- }
22
- ],
23
- "support": {
24
- "issues": "https://github.com/MarkColeMukisa/nevela/issues",
25
- "source": "https://github.com/MarkColeMukisa/nevela",
26
- "docs": "https://nevela-docs.vercel.app"
27
- },
28
- "require": {
29
- "php": "^8.2",
30
- "illuminate/support": "^11.0|^12.0|^13.0",
31
- "illuminate/database": "^11.17|^12.0|^13.0",
32
- "illuminate/http": "^11.0|^12.0|^13.0",
33
- "illuminate/console": "^11.0|^12.0|^13.0"
34
- },
35
- "require-dev": {
36
- "phpunit/phpunit": "^11.0"
37
- },
38
- "suggest": {
39
- "laravel/sanctum": "Required for the token sign-in endpoints and the default auth:sanctum middleware (php artisan install:api)."
40
- },
41
- "autoload": {
42
- "psr-4": {
43
- "Nevela\\Laravel\\": "src/"
44
- }
45
- },
46
- "autoload-dev": {
47
- "psr-4": {
48
- "Nevela\\Laravel\\Tests\\": "tests/"
49
- }
50
- },
51
- "extra": {
52
- "laravel": {
53
- "providers": [
54
- "Nevela\\Laravel\\NevelaServiceProvider"
55
- ]
56
- }
57
- },
58
- "minimum-stability": "stable"
59
- }
1
+ {
2
+ "name": "nevela/laravel",
3
+ "description": "Nevela for Laravel: describe a resource once and generate its model, migration, validation, policy and REST API, plus the Next.js dashboard screens for it.",
4
+ "type": "library",
5
+ "license": "MIT",
6
+ "keywords": [
7
+ "laravel",
8
+ "nevela",
9
+ "generator",
10
+ "crud",
11
+ "rest-api",
12
+ "nextjs",
13
+ "dashboard",
14
+ "scaffold"
15
+ ],
16
+ "homepage": "https://nevela-docs.vercel.app",
17
+ "authors": [
18
+ {
19
+ "name": "Mark Cole MUKISA",
20
+ "homepage": "https://github.com/MarkColeMukisa"
21
+ }
22
+ ],
23
+ "support": {
24
+ "issues": "https://github.com/MarkColeMukisa/nevela/issues",
25
+ "source": "https://github.com/MarkColeMukisa/nevela",
26
+ "docs": "https://nevela-docs.vercel.app"
27
+ },
28
+ "require": {
29
+ "php": "^8.2",
30
+ "illuminate/support": "^11.0|^12.0|^13.0",
31
+ "illuminate/database": "^11.17|^12.0|^13.0",
32
+ "illuminate/http": "^11.0|^12.0|^13.0",
33
+ "illuminate/console": "^11.0|^12.0|^13.0"
34
+ },
35
+ "require-dev": {
36
+ "phpunit/phpunit": "^11.0"
37
+ },
38
+ "suggest": {
39
+ "laravel/sanctum": "Required for the token sign-in endpoints and the default auth:sanctum middleware (php artisan install:api).",
40
+ "ext-gd": "Optimises uploaded images: resizing, WebP and renditions. Without it images are stored as uploaded.",
41
+ "ext-exif": "Turns phone photos the right way up before their metadata is removed."
42
+ },
43
+ "autoload": {
44
+ "psr-4": {
45
+ "Nevela\\Laravel\\": "src/"
46
+ }
47
+ },
48
+ "autoload-dev": {
49
+ "psr-4": {
50
+ "Nevela\\Laravel\\Tests\\": "tests/"
51
+ }
52
+ },
53
+ "extra": {
54
+ "laravel": {
55
+ "providers": [
56
+ "Nevela\\Laravel\\NevelaServiceProvider"
57
+ ]
58
+ }
59
+ },
60
+ "minimum-stability": "stable"
61
+ }
@@ -33,4 +33,74 @@ return [
33
33
  /* List defaults, matching Flare's client. */
34
34
  'per_page' => 25,
35
35
  'max_per_page' => 100,
36
+
37
+ /*
38
+ | File and image fields (image:image, manual:file(pdf)).
39
+ |
40
+ | Files go on a disk from config/filesystems.php and are handed out by Nevela's own
41
+ | route, GET {prefix}/_nevela/files/{key}, so nothing needs linking or publishing. Keys
42
+ | are unguessable, but anyone who has one can fetch the file: don't use these fields
43
+ | for documents that must stay private. If a CDN or your web server serves the disk,
44
+ | put its address in NEVELA_UPLOADS_URL and the API's links point there.
45
+ */
46
+ 'uploads' => [
47
+ 'disk' => env('NEVELA_UPLOADS_DISK', 'public'),
48
+ 'url' => env('NEVELA_UPLOADS_URL'),
49
+
50
+ /* Where untouched originals are kept, so an image can be optimised again later. Never served. */
51
+ 'originals_disk' => env('NEVELA_ORIGINALS_DISK', 'local'),
52
+
53
+ /* The largest file a field takes. */
54
+ 'max_bytes' => 10 * 1024 * 1024,
55
+
56
+ /* Memory allowed while an image is being optimised: a phone photo needs more than PHP's usual 128M. */
57
+ 'memory' => '512M',
58
+
59
+ /*
60
+ | How images are optimised. Every image field uses "default" unless it names another:
61
+ | image:image(product). A profile only says what differs from the default.
62
+ |
63
+ | max [width, height] the stored image fits inside. Add 'crop' to fill it exactly.
64
+ | quality 1 to 100.
65
+ | format auto, webp, jpeg, png or avif. Auto keeps transparency and otherwise
66
+ | picks the smallest.
67
+ | renditions extra sizes made alongside, by name. Ask for one by putting its name
68
+ | before the extension: kettle.webp → kettle.thumb.webp
69
+ | keep_original keep the untouched upload on the originals disk.
70
+ | on_error store_original (keep the upload as it came) or reject.
71
+ | max_pixels refuse anything that would decode to more than this.
72
+ */
73
+ 'profiles' => [
74
+ 'default' => [
75
+ 'max' => [1600, 1600],
76
+ 'quality' => 82,
77
+ 'format' => 'auto',
78
+ 'renditions' => ['thumb' => [400, 400, 'crop']],
79
+ 'keep_original' => true,
80
+ 'on_error' => 'store_original',
81
+ 'max_pixels' => 50_000_000,
82
+ ],
83
+
84
+ // A product photograph: smaller than the default, because a catalogue page shows a dozen at once.
85
+ 'product' => [
86
+ 'max' => [1000, 1000],
87
+ 'quality' => 80,
88
+ 'renditions' => ['thumb' => [300, 300, 'crop'], 'card' => [600, 600]],
89
+ ],
90
+
91
+ // Always square and always small, so it crops: a portrait in a round frame shouldn't letterbox.
92
+ 'avatar' => [
93
+ 'max' => [400, 400, 'crop'],
94
+ 'quality' => 85,
95
+ 'renditions' => ['thumb' => [80, 80, 'crop']],
96
+ 'keep_original' => false,
97
+ ],
98
+
99
+ // A hero or cover image, where width matters more than total pixels.
100
+ 'cover' => [
101
+ 'max' => [2400, 1200],
102
+ 'renditions' => ['thumb' => [600, 300, 'crop']],
103
+ ],
104
+ ],
105
+ ],
36
106
  ];
@@ -0,0 +1,38 @@
1
+ <?php
2
+
3
+ use Illuminate\Database\Migrations\Migration;
4
+ use Illuminate\Database\Schema\Blueprint;
5
+ use Illuminate\Support\Facades\Schema;
6
+
7
+ // What Nevela knows about each uploaded file: where it is, how big it is, and for an
8
+ // image, its dimensions and the smaller renditions made from it.
9
+ return new class extends Migration
10
+ {
11
+ public function up(): void
12
+ {
13
+ Schema::create('nevela_uploads', function (Blueprint $table) {
14
+ $table->uuid('id')->primary();
15
+ $table->string('key', 512)->unique();
16
+ $table->string('disk');
17
+ $table->string('resource')->index();
18
+ $table->string('field');
19
+ $table->string('name');
20
+ $table->string('mime');
21
+ $table->unsignedBigInteger('size');
22
+ $table->unsignedInteger('width')->nullable();
23
+ $table->unsignedInteger('height')->nullable();
24
+ $table->json('renditions')->nullable();
25
+ $table->string('profile')->nullable();
26
+ $table->boolean('optimised')->default(false);
27
+ $table->string('original_key', 512)->nullable();
28
+ $table->unsignedBigInteger('original_size')->nullable();
29
+ $table->string('uploaded_by')->nullable();
30
+ $table->timestamps();
31
+ });
32
+ }
33
+
34
+ public function down(): void
35
+ {
36
+ Schema::dropIfExists('nevela_uploads');
37
+ }
38
+ };
@@ -61,9 +61,32 @@ final class GenerateCommand extends Command
61
61
  public static function generate(Command $command, array $targets, array $all, bool $force): int
62
62
  {
63
63
  $generator = new ResourceGenerator;
64
- $files = [];
64
+ $byName = [];
65
+ foreach ($all as $descriptor) {
66
+ $byName[$descriptor->name] = $descriptor;
67
+ }
68
+
69
+ // A relation is written on both ends, so the resource a new one points at is
70
+ // regenerated with it: Category gains its products when Product arrives.
71
+ $queue = [];
65
72
  foreach ($targets as $descriptor) {
66
- array_push($files, ...$generator->forResource($descriptor));
73
+ $queue[$descriptor->name] = $descriptor;
74
+ foreach ($descriptor->fields as $field) {
75
+ if ($field->kind !== 'belongsTo') {
76
+ continue;
77
+ }
78
+ if (! isset($byName[$field->target])) {
79
+ $command->components->error("{$descriptor->name}.{$field->name} belongs to {$field->target}, which doesn't exist yet. Create it first: php artisan nevela:resource {$field->target} --fields=\"name:string\"");
80
+
81
+ return self::FAILURE;
82
+ }
83
+ $queue[$field->target] ??= $byName[$field->target];
84
+ }
85
+ }
86
+
87
+ $files = [];
88
+ foreach ($queue as $descriptor) {
89
+ array_push($files, ...$generator->forResource($descriptor, null, $all));
67
90
  }
68
91
  $files[] = $generator->routes($all);
69
92
  $files[] = $generator->registry($all);
@@ -43,6 +43,14 @@ final class MakeResourceCommand extends Command
43
43
 
44
44
  return self::FAILURE;
45
45
  }
46
+ // Checked before anything is written: a relation needs the resource it points at.
47
+ foreach ($descriptor->fields as $field) {
48
+ if ($field->kind === 'belongsTo' && $field->target !== $descriptor->name && ! is_file($dir.DIRECTORY_SEPARATOR.Naming::kebab((string) $field->target).'.json')) {
49
+ $this->components->error("{$field->name} belongs to {$field->target}, which doesn't exist yet. Create it first: php artisan nevela:resource {$field->target} --fields=\"name:string\"");
50
+
51
+ return self::FAILURE;
52
+ }
53
+ }
46
54
  if (! is_dir($dir)) {
47
55
  mkdir($dir, 0775, true);
48
56
  }
@@ -1,124 +1,186 @@
1
- <?php
2
-
3
- namespace Nevela\Laravel\Console;
4
-
5
- use Illuminate\Console\Command;
6
- use Illuminate\Database\UniqueConstraintViolationException;
7
- use Illuminate\Support\Facades\DB;
8
- use Illuminate\Support\Str;
9
- use InvalidArgumentException;
10
- use Nevela\Laravel\Nevela;
11
- use Nevela\Laravel\Support\Descriptor;
12
- use Nevela\Laravel\Support\Fake;
13
- use Symfony\Component\Console\Helper\ProgressBar;
14
- use Throwable;
15
-
16
- final class SeedCommand extends Command
17
- {
18
- protected $signature = 'nevela:seed
19
- {name : The resource to fill, e.g. Product}
20
- {--count=1000 : How many records to create}
21
- {--chunk=500 : Rows per insert statement}
22
- {--fresh : Delete the resource\'s existing records first}';
23
-
24
- protected $description = 'Fill a resource with plausible records, and say how long it took';
25
-
26
- private const MAX_COUNT = 5_000_000;
27
-
28
- /** 400 blocks of MAX_COUNT stay under 2^31, so a unique integer column can hold the numbers. */
29
- private const BLOCKS = 400;
30
-
31
- private const ATTEMPTS = 5;
32
-
33
- public function handle(): int
34
- {
35
- try {
36
- $resource = Nevela::resource(Str::studly($this->argument('name')));
37
- } catch (InvalidArgumentException $e) {
38
- $this->components->error($e->getMessage());
39
-
40
- return self::FAILURE;
41
- }
42
-
43
- $count = filter_var($this->option('count'), FILTER_VALIDATE_INT, ['options' => ['min_range' => 1, 'max_range' => self::MAX_COUNT]]);
44
- $chunk = filter_var($this->option('chunk'), FILTER_VALIDATE_INT, ['options' => ['min_range' => 1, 'max_range' => 5000]]);
45
- if ($count === false || $chunk === false) {
46
- $this->components->error('--count must be between 1 and 5,000,000, and --chunk between 1 and 5,000.');
47
-
48
- return self::FAILURE;
49
- }
50
-
51
- // SQLite allows 32,766 bound values per statement; stay well inside it on wide tables.
52
- $chunk = min($chunk, max(1, intdiv(30_000, count($resource->fields) + 3)));
53
- $fresh = (bool) $this->option('fresh');
54
-
55
- $existing = $fresh ? 0 : (int) DB::table($resource->table)->count();
56
- foreach ($resource->fields as $field) {
57
- $capacity = Fake::capacity($field);
58
- if ($capacity !== null && $existing + $count > $capacity) {
59
- $room = number_format(max(0, $capacity - $existing));
60
- $this->components->error("\"{$field->name}\" is unique and can only hold ".number_format($capacity)." different values, so there is room for {$room} more {$resource->pluralLabel}, not ".number_format($count).'.');
61
-
62
- return self::FAILURE;
63
- }
64
- }
65
-
66
- $started = microtime(true);
67
- $removed = 0;
68
- $bar = $this->output->createProgressBar($count);
69
- try {
70
- for ($attempt = 1; ; $attempt++) {
71
- // Unique fields are numbered from a block of MAX_COUNT picked per run (a run is at
72
- // most that long). Two runs only meet if they pick the same block; then the insert
73
- // fails, the transaction rolls back and another block is picked.
74
- $offset = $fresh && $attempt === 1 ? 0 : mt_rand(0, self::BLOCKS - 1) * self::MAX_COUNT;
75
- $bar->start();
76
- try {
77
- $removed = DB::transaction(fn () => $this->fill($resource, $count, $chunk, $offset, $fresh, $bar));
78
- break;
79
- } catch (UniqueConstraintViolationException $e) {
80
- if ($attempt === self::ATTEMPTS) {
81
- throw $e;
82
- }
83
- }
84
- }
85
- $bar->finish();
86
- $this->newLine(2);
87
- } catch (Throwable $e) {
88
- $this->newLine(2);
89
- $this->components->error("Seeding {$resource->pluralLabel} failed, and the table is as it was: ".Str::limit($e->getMessage(), 300));
90
-
91
- return self::FAILURE;
92
- }
93
- if ($fresh) {
94
- $this->components->info('Removed '.number_format($removed)." existing {$resource->pluralLabel}.");
95
- }
96
-
97
- $seconds = microtime(true) - $started;
98
- $time = $seconds < 1 ? round($seconds * 1000).' ms' : number_format($seconds, 2).' s';
99
- $rate = number_format($count / max($seconds, 0.001));
100
- $this->components->info('Seeded '.number_format($count)." {$resource->pluralLabel} in {$time} ({$rate} rows/s).");
101
- $this->components->twoColumnDetail("{$resource->pluralLabel} in the database", number_format(DB::table($resource->table)->count()));
102
-
103
- return self::SUCCESS;
104
- }
105
-
106
- /** Runs inside the transaction: the optional delete and every insert succeed or fail together. */
107
- private function fill(Descriptor $resource, int $count, int $chunk, int $offset, bool $fresh, ProgressBar $bar): int
108
- {
109
- $removed = $fresh ? DB::table($resource->table)->delete() : 0;
110
- $now = time();
111
- for ($done = 0; $done < $count; $done += $chunk) {
112
- $rows = [];
113
- for ($i = $done; $i < min($count, $done + $chunk); $i++) {
114
- // Spread over the last 60 days, so the dashboard's "this week" and trend have something to show.
115
- $created = date('Y-m-d H:i:s', $now - mt_rand(0, 60 * 86400));
116
- $rows[] = ['id' => (string) Str::uuid7()] + Fake::row($resource, $offset + $i + 1) + ['created_at' => $created, 'updated_at' => $created];
117
- }
118
- DB::table($resource->table)->insert($rows);
119
- $bar->advance(count($rows));
120
- }
121
-
122
- return $removed;
123
- }
124
- }
1
+ <?php
2
+
3
+ namespace Nevela\Laravel\Console;
4
+
5
+ use Illuminate\Console\Command;
6
+ use Illuminate\Database\UniqueConstraintViolationException;
7
+ use Illuminate\Support\Facades\DB;
8
+ use Illuminate\Support\Str;
9
+ use InvalidArgumentException;
10
+ use Nevela\Laravel\Media\Uploads;
11
+ use Nevela\Laravel\Nevela;
12
+ use Nevela\Laravel\Support\Descriptor;
13
+ use Nevela\Laravel\Support\Fake;
14
+ use Symfony\Component\Console\Helper\ProgressBar;
15
+ use Throwable;
16
+
17
+ final class SeedCommand extends Command
18
+ {
19
+ protected $signature = 'nevela:seed
20
+ {name : The resource to fill, e.g. Product}
21
+ {--count=1000 : How many records to create}
22
+ {--chunk=500 : Rows per insert statement}
23
+ {--fresh : Delete the resource\'s existing records first}';
24
+
25
+ protected $description = 'Fill a resource with plausible records, and say how long it took';
26
+
27
+ private const MAX_COUNT = 5_000_000;
28
+
29
+ /** 400 blocks of MAX_COUNT stay under 2^31, so a unique integer column can hold the numbers. */
30
+ private const BLOCKS = 400;
31
+
32
+ private const ATTEMPTS = 5;
33
+
34
+ public function handle(): int
35
+ {
36
+ try {
37
+ $resource = Nevela::resource(Str::studly($this->argument('name')));
38
+ } catch (InvalidArgumentException $e) {
39
+ $this->components->error($e->getMessage());
40
+
41
+ return self::FAILURE;
42
+ }
43
+
44
+ $count = filter_var($this->option('count'), FILTER_VALIDATE_INT, ['options' => ['min_range' => 1, 'max_range' => self::MAX_COUNT]]);
45
+ $chunk = filter_var($this->option('chunk'), FILTER_VALIDATE_INT, ['options' => ['min_range' => 1, 'max_range' => 5000]]);
46
+ if ($count === false || $chunk === false) {
47
+ $this->components->error('--count must be between 1 and 5,000,000, and --chunk between 1 and 5,000.');
48
+
49
+ return self::FAILURE;
50
+ }
51
+
52
+ // SQLite allows 32,766 bound values per statement; stay well inside it on wide tables.
53
+ $chunk = min($chunk, max(1, intdiv(30_000, count($resource->fields) + 3)));
54
+ $fresh = (bool) $this->option('fresh');
55
+
56
+ $existing = $fresh ? 0 : (int) DB::table($resource->table)->count();
57
+ foreach ($resource->fields as $field) {
58
+ $capacity = Fake::capacity($field);
59
+ if ($capacity !== null && $existing + $count > $capacity) {
60
+ $room = number_format(max(0, $capacity - $existing));
61
+ $this->components->error("\"{$field->name}\" is unique and can only hold ".number_format($capacity)." different values, so there is room for {$room} more {$resource->pluralLabel}, not ".number_format($count).'.');
62
+
63
+ return self::FAILURE;
64
+ }
65
+ }
66
+
67
+ // --fresh deletes first, and a record others are required to belong to can't be deleted.
68
+ // Said here, before any placeholder pictures are made for a run that can't happen.
69
+ if ($fresh) {
70
+ foreach (Nevela::all() as $other) {
71
+ foreach ($other->fields as $field) {
72
+ if ($field->kind === 'belongsTo' && $field->target === $resource->name && $field->required && $other->name !== $resource->name
73
+ && ($children = (int) DB::table($other->table)->count()) > 0) {
74
+ $this->components->error(number_format($children)." {$other->pluralLabel} belong to the {$resource->pluralLabel} that --fresh would delete. Remove them first, or seed without --fresh.");
75
+
76
+ return self::FAILURE;
77
+ }
78
+ }
79
+ }
80
+ }
81
+
82
+ $started = microtime(true);
83
+ try {
84
+ $pools = $this->pools($resource);
85
+ } catch (InvalidArgumentException $e) {
86
+ $this->components->error($e->getMessage());
87
+
88
+ return self::FAILURE;
89
+ }
90
+ $removed = 0;
91
+ $bar = $this->output->createProgressBar($count);
92
+ try {
93
+ for ($attempt = 1; ; $attempt++) {
94
+ // Unique fields are numbered from a block of MAX_COUNT picked per run (a run is at
95
+ // most that long). Two runs only meet if they pick the same block; then the insert
96
+ // fails, the transaction rolls back and another block is picked.
97
+ $offset = $fresh && $attempt === 1 ? 0 : mt_rand(0, self::BLOCKS - 1) * self::MAX_COUNT;
98
+ $bar->start();
99
+ try {
100
+ $removed = DB::transaction(fn () => $this->fill($resource, $count, $chunk, $offset, $fresh, $bar, $pools));
101
+ break;
102
+ } catch (UniqueConstraintViolationException $e) {
103
+ if ($attempt === self::ATTEMPTS) {
104
+ throw $e;
105
+ }
106
+ }
107
+ }
108
+ $bar->finish();
109
+ $this->newLine(2);
110
+ } catch (Throwable $e) {
111
+ $this->newLine(2);
112
+ $this->components->error("Seeding {$resource->pluralLabel} failed, and the table is as it was: ".Str::limit($e->getMessage(), 300));
113
+
114
+ return self::FAILURE;
115
+ }
116
+ if ($fresh) {
117
+ $this->components->info('Removed '.number_format($removed)." existing {$resource->pluralLabel}.");
118
+ }
119
+
120
+ $seconds = microtime(true) - $started;
121
+ $time = $seconds < 1 ? round($seconds * 1000).' ms' : number_format($seconds, 2).' s';
122
+ $rate = number_format($count / max($seconds, 0.001));
123
+ $this->components->info('Seeded '.number_format($count)." {$resource->pluralLabel} in {$time} ({$rate} rows/s).");
124
+ $this->components->twoColumnDetail("{$resource->pluralLabel} in the database", number_format(DB::table($resource->table)->count()));
125
+
126
+ return self::SUCCESS;
127
+ }
128
+
129
+ /**
130
+ * What a relation or an image field can be filled with: the records that exist to point
131
+ * at, and a handful of pictures made for the purpose.
132
+ *
133
+ * @return array<string, list<string>>
134
+ *
135
+ * @throws InvalidArgumentException when a required field has nothing to draw from
136
+ */
137
+ private function pools(Descriptor $resource): array
138
+ {
139
+ $pools = [];
140
+ foreach ($resource->fields as $field) {
141
+ if ($field->kind === 'belongsTo') {
142
+ $target = $field->target === $resource->name ? $resource : Nevela::resource((string) $field->target);
143
+ $ids = DB::table($target->table)->inRandomOrder()->limit(1000)->pluck('id')->map(fn ($id) => (string) $id)->all();
144
+ if ($field->unique) {
145
+ throw new InvalidArgumentException("\"{$field->name}\" is unique, so each {$target->label} can be used once. The seeder doesn't fill one-to-one relations; add those records yourself.");
146
+ }
147
+ if ($ids === [] && $field->required && $target->name !== $resource->name) {
148
+ throw new InvalidArgumentException("Every {$resource->label} needs a {$target->label}, and there are none yet. Seed them first: php artisan nevela:seed {$target->name}");
149
+ }
150
+ $pools[$field->name] = $ids;
151
+ } elseif ($field->kind === 'file') {
152
+ $pools[$field->name] = $field->isImage() ? Uploads::placeholders($resource, $field) : [];
153
+ if ($pools[$field->name] === [] && $field->required) {
154
+ throw new InvalidArgumentException($field->isImage()
155
+ ? "\"{$field->name}\" is a required image, and PHP's GD extension isn't installed to make placeholder pictures with."
156
+ : "\"{$field->name}\" is a required file, which the seeder can't make up. Make it optional ({$field->name}:file(…)?) or add records yourself.");
157
+ }
158
+ }
159
+ }
160
+
161
+ return $pools;
162
+ }
163
+
164
+ /**
165
+ * Runs inside the transaction: the optional delete and every insert succeed or fail together.
166
+ *
167
+ * @param array<string, list<string>> $pools
168
+ */
169
+ private function fill(Descriptor $resource, int $count, int $chunk, int $offset, bool $fresh, ProgressBar $bar, array $pools = []): int
170
+ {
171
+ $removed = $fresh ? DB::table($resource->table)->delete() : 0;
172
+ $now = time();
173
+ for ($done = 0; $done < $count; $done += $chunk) {
174
+ $rows = [];
175
+ for ($i = $done; $i < min($count, $done + $chunk); $i++) {
176
+ // Spread over the last 60 days, so the dashboard's "this week" and trend have something to show.
177
+ $created = date('Y-m-d H:i:s', $now - mt_rand(0, 60 * 86400));
178
+ $rows[] = ['id' => (string) Str::uuid7()] + Fake::row($resource, $offset + $i + 1, $pools) + ['created_at' => $created, 'updated_at' => $created];
179
+ }
180
+ DB::table($resource->table)->insert($rows);
181
+ $bar->advance(count($rows));
182
+ }
183
+
184
+ return $removed;
185
+ }
186
+ }