tina4ruby 3.13.64 → 3.13.67
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.
- checksums.yaml +4 -4
- data/lib/tina4/cli.rb +1083 -155
- data/lib/tina4/orm.rb +3 -2
- data/lib/tina4/public/js/frond.js +19 -8
- data/lib/tina4/public/js/frond.min.js +1 -1
- data/lib/tina4/queue.rb +6 -0
- data/lib/tina4/version.rb +1 -1
- metadata +2 -2
data/lib/tina4/cli.rb
CHANGED
|
@@ -5,7 +5,75 @@ require "fileutils"
|
|
|
5
5
|
|
|
6
6
|
module Tina4
|
|
7
7
|
class CLI
|
|
8
|
-
|
|
8
|
+
# ── Command registries — the single source of truth ─────────────────
|
|
9
|
+
#
|
|
10
|
+
# ONE entry per command/generator drives dispatch (#run / #cmd_generate),
|
|
11
|
+
# the human help (#cmd_help), AND the machine-readable manifest
|
|
12
|
+
# (`commands --json`). Add a command in ONE place and it appears in
|
|
13
|
+
# dispatch, help, and discovery — there is no second list to sync. Ruby
|
|
14
|
+
# mirror of the Python master's COMMANDS / GENERATORS registries
|
|
15
|
+
# (tina4_python/cli/__init__.py).
|
|
16
|
+
#
|
|
17
|
+
# GENERATORS[name] = { handler: :method_symbol, usage: str, summary: str }
|
|
18
|
+
# COMMANDS[name] = { handler: :method_symbol, summary: str,
|
|
19
|
+
# usage?: str, # arg/flag hint for #cmd_help (human only)
|
|
20
|
+
# args?: [str], # positional args for the manifest ("x?" = optional)
|
|
21
|
+
# subcommands?: [str] } # sub-names for the manifest (generate)
|
|
22
|
+
#
|
|
23
|
+
# Handlers are instance-method symbols dispatched via #send: GENERATORS
|
|
24
|
+
# handlers take (name, flags); COMMANDS handlers take (argv).
|
|
25
|
+
|
|
26
|
+
GENERATORS = {
|
|
27
|
+
"model" => { handler: :generate_model, usage: '<Name> [--fields "name:string,price:float"]', summary: "ORM model + matching migration" },
|
|
28
|
+
"route" => { handler: :generate_route, usage: "<name> [--model Name] [--public]", summary: "CRUD route file, secure by default (--public opens writes)" },
|
|
29
|
+
"crud" => { handler: :generate_crud, usage: '<Name> [--fields "..."] [--public]', summary: "Model + migration + routes + form + view + test" },
|
|
30
|
+
"migration" => { handler: :generate_migration, usage: "<description>", summary: "Timestamped migration file (UP/DOWN)" },
|
|
31
|
+
"middleware" => { handler: :generate_middleware, usage: "<Name>", summary: "Middleware with before/after hooks" },
|
|
32
|
+
"test" => { handler: :generate_test, usage: "<name> [--model Name]", summary: "RSpec test file" },
|
|
33
|
+
"form" => { handler: :generate_form, usage: '<Name> [--fields "..."]', summary: "Form template with inputs matching model fields" },
|
|
34
|
+
"view" => { handler: :generate_view, usage: '<Name> [--fields "..."]', summary: "List + detail view templates" },
|
|
35
|
+
"auth" => { handler: :generate_auth, usage: "", summary: "Login/register routes + User model + templates" },
|
|
36
|
+
"service" => { handler: :generate_service, usage: '<Name> [--every 5m | --cron "..."]', summary: "Scheduled ServiceRunner task (src/services/)" },
|
|
37
|
+
"queue" => { handler: :generate_queue, usage: "<topic>", summary: "Producer + consumer worker (src/services/)" },
|
|
38
|
+
"validator" => { handler: :generate_validator, usage: "<Name>", summary: "Request-body Validator (src/validators/)" },
|
|
39
|
+
"seeder" => { handler: :generate_seeder, usage: "<Model>", summary: "FakeData + seed_orm seeder (seeds/)" },
|
|
40
|
+
"websocket" => { handler: :generate_websocket, usage: "<path>", summary: "Tina4.websocket handler (src/routes/)" },
|
|
41
|
+
"listener" => { handler: :generate_listener, usage: "<event>", summary: "Tina4::Events.on listener (src/listeners/)" },
|
|
42
|
+
}.freeze
|
|
43
|
+
|
|
44
|
+
# Sub-dispatch table for the top-level `queue` command — the SINGLE source
|
|
45
|
+
# for its subcommands (drives #cmd_queue dispatch AND the manifest's
|
|
46
|
+
# queue.subcommands). Ruby mirror of the Python master's _QUEUE_SUBCOMMANDS.
|
|
47
|
+
# Distinct from the `queue` GENERATOR, which SCAFFOLDS a consumer file.
|
|
48
|
+
QUEUE_SUBCOMMANDS = {
|
|
49
|
+
"work" => :queue_work,
|
|
50
|
+
"stats" => :queue_stats,
|
|
51
|
+
"retry" => :queue_retry,
|
|
52
|
+
"clear" => :queue_clear,
|
|
53
|
+
}.freeze
|
|
54
|
+
|
|
55
|
+
COMMANDS = {
|
|
56
|
+
"init" => { handler: :cmd_init, usage: "[NAME]", args: ["name?"], summary: "Initialize a new Tina4 project" },
|
|
57
|
+
"start" => { handler: :cmd_start, usage: "[options]", summary: "Start the Tina4 web server" },
|
|
58
|
+
"serve" => { handler: :cmd_start, summary: "Alias for start" },
|
|
59
|
+
"migrate" => { handler: :cmd_migrate, usage: "[--create NAME] [--rollback N]", summary: "Run database migrations" },
|
|
60
|
+
"migrate:create" => { handler: :cmd_migrate_create, usage: "<desc>", args: ["description"], summary: "Create a new migration file" },
|
|
61
|
+
"migrate:status" => { handler: :cmd_migrate_status, summary: "Show migration status (completed and pending)" },
|
|
62
|
+
"migrate:rollback" => { handler: :cmd_migrate_rollback, usage: "[-n N]", summary: "Rollback the last batch of migrations" },
|
|
63
|
+
"seed" => { handler: :cmd_seed, usage: "[--clear]", summary: "Run all seed files in seeds/" },
|
|
64
|
+
"seed:create" => { handler: :cmd_seed_create, usage: "NAME", args: ["name"], summary: "Create a new seed file" },
|
|
65
|
+
"test" => { handler: :cmd_test, summary: "Run inline tests" },
|
|
66
|
+
"queue" => { handler: :cmd_queue, usage: "<work|stats|retry|clear> [topic]", subcommands: QUEUE_SUBCOMMANDS.keys, summary: "Run queue workers and manage jobs" },
|
|
67
|
+
"build" => { handler: :cmd_build, usage: "[--tag NAME] [--file PATH]", summary: "Build the deployable Docker image" },
|
|
68
|
+
"version" => { handler: :cmd_version, summary: "Show Tina4 version" },
|
|
69
|
+
"routes" => { handler: :cmd_routes, summary: "List all registered routes" },
|
|
70
|
+
"console" => { handler: :cmd_console, summary: "Start an interactive console" },
|
|
71
|
+
"generate" => { handler: :cmd_generate, usage: "<what> <name> [options]", subcommands: GENERATORS.keys, summary: "Generate scaffolding (see Generators below)" },
|
|
72
|
+
"ai" => { handler: :cmd_ai, usage: "[--all]", summary: "Detect AI tools and install context files" },
|
|
73
|
+
"metrics" => { handler: :cmd_metrics, usage: "[--top N] [--json] [--fail-on warn|error] [--path DIR]", summary: "Rank top code-quality offenders" },
|
|
74
|
+
"commands" => { handler: :cmd_commands, usage: "[--json]", summary: "List available commands (add --json for machine form)" },
|
|
75
|
+
"help" => { handler: :cmd_help, summary: "Show this help message" },
|
|
76
|
+
}.freeze
|
|
9
77
|
|
|
10
78
|
# ── Field type mapping ──────────────────────────────────────────────
|
|
11
79
|
FIELD_TYPE_MAP = {
|
|
@@ -29,22 +97,14 @@ module Tina4
|
|
|
29
97
|
|
|
30
98
|
def run(argv)
|
|
31
99
|
command = argv.shift || "help"
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
when "test" then cmd_test(argv)
|
|
41
|
-
when "version" then cmd_version
|
|
42
|
-
when "routes" then cmd_routes
|
|
43
|
-
when "console" then cmd_console
|
|
44
|
-
when "generate" then cmd_generate(argv)
|
|
45
|
-
when "ai" then cmd_ai(argv)
|
|
46
|
-
when "metrics" then cmd_metrics(argv)
|
|
47
|
-
when "help", "-h", "--help" then cmd_help
|
|
100
|
+
command = "help" if %w[-h --help].include?(command)
|
|
101
|
+
|
|
102
|
+
# Dispatch from the single-source-of-truth COMMANDS registry. The same
|
|
103
|
+
# registry drives #cmd_help and the `commands --json` manifest, so
|
|
104
|
+
# dispatch, help, and discovery never drift.
|
|
105
|
+
spec = COMMANDS[command]
|
|
106
|
+
if spec
|
|
107
|
+
send(spec[:handler], argv)
|
|
48
108
|
else
|
|
49
109
|
puts "Unknown command: #{command}"
|
|
50
110
|
cmd_help
|
|
@@ -148,7 +208,7 @@ module Tina4
|
|
|
148
208
|
# Parse --key value and --flag from args. Returns [flags_hash, positional_array]
|
|
149
209
|
def parse_flags(args)
|
|
150
210
|
# Boolean-only flags that never take a value argument
|
|
151
|
-
boolean_flags = %w[no-browser no-reload production managed all clear dev json public no-migration]
|
|
211
|
+
boolean_flags = %w[no-browser no-reload production managed all clear dev json public no-migration once]
|
|
152
212
|
|
|
153
213
|
flags = {}
|
|
154
214
|
positional = []
|
|
@@ -361,6 +421,26 @@ module Tina4
|
|
|
361
421
|
end
|
|
362
422
|
end
|
|
363
423
|
|
|
424
|
+
# ── migrate:create ───────────────────────────────────────────────────
|
|
425
|
+
|
|
426
|
+
# Create a new timestamped migration file (UP .sql + .down.sql), matching
|
|
427
|
+
# the Python master `migrate:create`. CHEAP + database-free: it only writes
|
|
428
|
+
# files via the static Tina4::Migration.create_migration helper — no app
|
|
429
|
+
# boot, no DB connection, no tracking table. `description` is every arg
|
|
430
|
+
# joined with a space (parity with Python's `" ".join(args)`).
|
|
431
|
+
def cmd_migrate_create(argv)
|
|
432
|
+
description = (argv || []).join(" ").strip
|
|
433
|
+
if description.empty?
|
|
434
|
+
puts "Usage: tina4ruby migrate:create <description>"
|
|
435
|
+
exit 1
|
|
436
|
+
end
|
|
437
|
+
|
|
438
|
+
require_relative "log"
|
|
439
|
+
require_relative "migration"
|
|
440
|
+
path = Tina4::Migration.create_migration(description, migrations_dir: "migrations")
|
|
441
|
+
puts "Created: #{path}"
|
|
442
|
+
end
|
|
443
|
+
|
|
364
444
|
# ── migrate:status ─────────────────────────────────────────────────────
|
|
365
445
|
|
|
366
446
|
def cmd_migrate_status(_argv)
|
|
@@ -502,16 +582,304 @@ module Tina4
|
|
|
502
582
|
exit(1) if results[:failed] > 0 || results[:errors] > 0
|
|
503
583
|
end
|
|
504
584
|
|
|
585
|
+
# ── queue ─────────────────────────────────────────────────────────────
|
|
586
|
+
#
|
|
587
|
+
# Top-level `queue` command — wires straight to the real Tina4::Queue (lite
|
|
588
|
+
# /file backend by default; RabbitMQ/Kafka/MongoDB via TINA4_QUEUE_BACKEND).
|
|
589
|
+
# `stats`, `retry` and `clear` operate on the queue without starting a
|
|
590
|
+
# server; `work` runs the app's consumer for a topic. Distinct from
|
|
591
|
+
# `generate queue`, which SCAFFOLDS a consumer file. Mirrors the Python
|
|
592
|
+
# master's `queue` command.
|
|
593
|
+
#
|
|
594
|
+
# tina4ruby queue work [topic] [--once] [--poll N] [--services DIR]
|
|
595
|
+
# tina4ruby queue stats [topic] [--json]
|
|
596
|
+
# tina4ruby queue retry [topic]
|
|
597
|
+
# tina4ruby queue clear [status] [topic]
|
|
598
|
+
def cmd_queue(argv)
|
|
599
|
+
argv ||= []
|
|
600
|
+
if argv.empty?
|
|
601
|
+
puts "Usage: tina4ruby queue <work|stats|retry|clear> [options]"
|
|
602
|
+
puts " Subcommands: #{QUEUE_SUBCOMMANDS.keys.join(', ')}"
|
|
603
|
+
exit 1
|
|
604
|
+
end
|
|
605
|
+
|
|
606
|
+
sub = argv[0].downcase
|
|
607
|
+
handler = QUEUE_SUBCOMMANDS[sub]
|
|
608
|
+
if handler.nil?
|
|
609
|
+
puts "Unknown queue subcommand: #{sub}"
|
|
610
|
+
puts " Available: #{QUEUE_SUBCOMMANDS.keys.join(', ')}"
|
|
611
|
+
exit 1
|
|
612
|
+
end
|
|
613
|
+
send(handler, argv[1..])
|
|
614
|
+
end
|
|
615
|
+
|
|
616
|
+
# Load the framework classes (Queue, backends, ServiceRunner) WITHOUT
|
|
617
|
+
# booting the app (no route discovery, no server, no auto-migrate), then
|
|
618
|
+
# load .env — mirrors the Python master's _load_env() before queue ops.
|
|
619
|
+
def load_queue_runtime
|
|
620
|
+
require_relative "../tina4"
|
|
621
|
+
Tina4::Env.load_env(Dir.pwd)
|
|
622
|
+
end
|
|
623
|
+
|
|
624
|
+
# Resolve the per-job handler a consumer declares for `topic`.
|
|
625
|
+
#
|
|
626
|
+
# A consumer scaffolded by `generate queue <topic>` registers a daemon
|
|
627
|
+
# service via Tina4.service(..., topic:, handle:). When its `topic` matches,
|
|
628
|
+
# `queue work` drives that per-job `handle` callable so THIS command owns the
|
|
629
|
+
# poll loop (honouring --poll / the bounded --once drain) instead of the
|
|
630
|
+
# consumer's own endless loop. Returns the callable, or nil when no consumer
|
|
631
|
+
# in `services_dir` targets this topic. Ruby parity for the Python master's
|
|
632
|
+
# _resolve_queue_handler (which reads a module-level `service` dict).
|
|
633
|
+
def resolve_queue_handler(services_dir, topic)
|
|
634
|
+
dir = File.expand_path(services_dir, Dir.pwd)
|
|
635
|
+
return nil unless Dir.exist?(dir)
|
|
636
|
+
|
|
637
|
+
Dir.glob(File.join(dir, "*.rb")).sort.each do |file|
|
|
638
|
+
next if File.basename(file).start_with?("_")
|
|
639
|
+
begin
|
|
640
|
+
load file
|
|
641
|
+
rescue StandardError, ScriptError
|
|
642
|
+
next # a broken sibling must not sink the worker
|
|
643
|
+
end
|
|
644
|
+
end
|
|
645
|
+
|
|
646
|
+
Tina4::ServiceRunner.list.each do |svc|
|
|
647
|
+
options = svc[:options] || {}
|
|
648
|
+
handle = options[:handle]
|
|
649
|
+
return handle if options[:topic].to_s == topic.to_s && handle.respond_to?(:call)
|
|
650
|
+
end
|
|
651
|
+
nil
|
|
652
|
+
end
|
|
653
|
+
|
|
654
|
+
# Run a consumer loop that pops and processes jobs on a topic.
|
|
655
|
+
#
|
|
656
|
+
# tina4ruby queue work [topic] [--once] [--poll N] [--services DIR]
|
|
657
|
+
#
|
|
658
|
+
# Long-running by default (polls every --poll seconds, 1.0 default; Ctrl-C to
|
|
659
|
+
# stop). --once does a single-pass drain — it processes every currently
|
|
660
|
+
# available job then exits (poll interval 0). The per-job handler is resolved
|
|
661
|
+
# from the app's consumer for this topic (see #resolve_queue_handler); with no
|
|
662
|
+
# handler it drains and acks with a warning rather than inventing behaviour.
|
|
663
|
+
def queue_work(argv)
|
|
664
|
+
load_queue_runtime
|
|
665
|
+
flags, positional = parse_flags(argv)
|
|
666
|
+
topic = positional[0] || "default"
|
|
667
|
+
once = flags["once"] ? true : false
|
|
668
|
+
|
|
669
|
+
poll =
|
|
670
|
+
if once
|
|
671
|
+
0.0 # single-pass: consume() returns as soon as the topic is empty
|
|
672
|
+
else
|
|
673
|
+
poll_raw = flags["poll"].to_s.strip
|
|
674
|
+
if poll_raw.empty? || poll_raw == "true"
|
|
675
|
+
1.0
|
|
676
|
+
else
|
|
677
|
+
begin
|
|
678
|
+
Float(poll_raw)
|
|
679
|
+
rescue ArgumentError
|
|
680
|
+
1.0
|
|
681
|
+
end
|
|
682
|
+
end
|
|
683
|
+
end
|
|
684
|
+
|
|
685
|
+
services_dir = flags["services"]
|
|
686
|
+
services_dir = ENV["TINA4_SERVICE_DIR"] || "src/services" unless services_dir.is_a?(String)
|
|
687
|
+
|
|
688
|
+
handler = resolve_queue_handler(services_dir, topic)
|
|
689
|
+
queue = Tina4::Queue.new(topic: topic)
|
|
690
|
+
|
|
691
|
+
if handler.nil?
|
|
692
|
+
puts " ⚠ No consumer handler found for topic '#{topic}' in #{services_dir}."
|
|
693
|
+
puts " Scaffold one with: tina4ruby generate queue #{topic}"
|
|
694
|
+
puts " Draining (consume + ack) without processing."
|
|
695
|
+
end
|
|
696
|
+
|
|
697
|
+
mode = once ? "single-pass drain" : format("polling every %gs (Ctrl-C to stop)", poll)
|
|
698
|
+
puts " Queue worker on '#{topic}' — #{mode}..."
|
|
699
|
+
|
|
700
|
+
processed = 0
|
|
701
|
+
failed = 0
|
|
702
|
+
begin
|
|
703
|
+
queue.consume(topic, poll_interval: poll) do |job|
|
|
704
|
+
begin
|
|
705
|
+
handler.call(job.payload) unless handler.nil?
|
|
706
|
+
job.complete
|
|
707
|
+
processed += 1
|
|
708
|
+
rescue StandardError, NotImplementedError => e
|
|
709
|
+
# A bad job nacks (retry / dead-letter), the worker lives on —
|
|
710
|
+
# parity with the Python master's `except Exception`. NotImplemented-
|
|
711
|
+
# Error is a ScriptError (not a StandardError) in Ruby, so name it
|
|
712
|
+
# explicitly: an UNFILLED `generate queue` scaffold stub raises it and
|
|
713
|
+
# must nack the job, not crash the worker. Interrupt (Ctrl-C) is
|
|
714
|
+
# neither, so it still propagates to the outer stop handler.
|
|
715
|
+
job.fail(e.message)
|
|
716
|
+
failed += 1
|
|
717
|
+
end
|
|
718
|
+
end
|
|
719
|
+
rescue Interrupt
|
|
720
|
+
puts "\n Interrupted — stopping worker."
|
|
721
|
+
end
|
|
722
|
+
|
|
723
|
+
puts " Processed #{processed} job(s), #{failed} failed on '#{topic}'."
|
|
724
|
+
end
|
|
725
|
+
|
|
726
|
+
# Print pending / in-flight / failed / dead-letter / completed counts.
|
|
727
|
+
#
|
|
728
|
+
# tina4ruby queue stats [topic] [--json]
|
|
729
|
+
def queue_stats(argv)
|
|
730
|
+
require "json"
|
|
731
|
+
load_queue_runtime
|
|
732
|
+
flags, positional = parse_flags(argv)
|
|
733
|
+
topic = positional[0] || "default"
|
|
734
|
+
|
|
735
|
+
queue = Tina4::Queue.new(topic: topic)
|
|
736
|
+
stats = {
|
|
737
|
+
"topic" => topic,
|
|
738
|
+
"pending" => queue.size(status: "pending"), # waiting to run
|
|
739
|
+
"reserved" => queue.size(status: "reserved"), # popped, not yet acked
|
|
740
|
+
"failed" => queue.failed.length, # failed once, still retrying
|
|
741
|
+
"dead" => queue.size(status: "dead"), # exhausted retries (dead-letter)
|
|
742
|
+
"completed" => queue.size(status: "completed"), # terminal-completed (0 on file backend)
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
if flags.key?("json")
|
|
746
|
+
puts JSON.pretty_generate(stats)
|
|
747
|
+
return
|
|
748
|
+
end
|
|
749
|
+
|
|
750
|
+
puts
|
|
751
|
+
puts " Queue '#{topic}'"
|
|
752
|
+
puts " pending #{stats['pending']}"
|
|
753
|
+
puts " reserved #{stats['reserved']} (in-flight)"
|
|
754
|
+
puts " failed #{stats['failed']} (retrying)"
|
|
755
|
+
puts " dead #{stats['dead']} (dead-letter)"
|
|
756
|
+
puts " completed #{stats['completed']}"
|
|
757
|
+
puts
|
|
758
|
+
end
|
|
759
|
+
|
|
760
|
+
# Re-queue failed and dead-letter jobs so they run again.
|
|
761
|
+
#
|
|
762
|
+
# tina4ruby queue retry [topic]
|
|
763
|
+
#
|
|
764
|
+
# Revives every dead-letter job (manual override, regardless of attempt
|
|
765
|
+
# count) and re-queues any failed-but-still-eligible jobs.
|
|
766
|
+
def queue_retry(argv)
|
|
767
|
+
load_queue_runtime
|
|
768
|
+
_flags, positional = parse_flags(argv)
|
|
769
|
+
topic = positional[0] || "default"
|
|
770
|
+
|
|
771
|
+
queue = Tina4::Queue.new(topic: topic)
|
|
772
|
+
|
|
773
|
+
# max_retries: 0 => every job in the dead-letter store, whatever its
|
|
774
|
+
# attempt count (matches what stats/size("dead") reports).
|
|
775
|
+
dead = queue.dead_letters(max_retries: 0)
|
|
776
|
+
revived = dead.count { |job| queue.retry(queue_job_id(job)) }
|
|
777
|
+
# Any failed-but-retryable jobs still under the limit.
|
|
778
|
+
requeued = queue.retry_failed
|
|
779
|
+
|
|
780
|
+
total = revived + requeued
|
|
781
|
+
puts " Re-queued #{total} job(s) on '#{topic}' (#{revived} dead-letter, #{requeued} failed)."
|
|
782
|
+
end
|
|
783
|
+
|
|
784
|
+
# Purge jobs of a given status (default: completed).
|
|
785
|
+
#
|
|
786
|
+
# tina4ruby queue clear [status] [topic]
|
|
787
|
+
#
|
|
788
|
+
# status is one of pending / reserved / completed / failed / dead. The
|
|
789
|
+
# default 'completed' clears finished jobs; pass e.g. `queue clear pending`
|
|
790
|
+
# or `queue clear dead orders` to purge another status / topic.
|
|
791
|
+
def queue_clear(argv)
|
|
792
|
+
load_queue_runtime
|
|
793
|
+
_flags, positional = parse_flags(argv)
|
|
794
|
+
status = positional[0] || "completed"
|
|
795
|
+
topic = positional[1] || "default"
|
|
796
|
+
|
|
797
|
+
queue = Tina4::Queue.new(topic: topic)
|
|
798
|
+
removed = queue.purge(status)
|
|
799
|
+
puts " Cleared #{removed} '#{status}' job(s) from '#{topic}'."
|
|
800
|
+
end
|
|
801
|
+
|
|
802
|
+
# Extract a job id from either a raw backend Hash or a Job object, so
|
|
803
|
+
# `queue retry` works regardless of what a backend's dead_letters returns.
|
|
804
|
+
def queue_job_id(job)
|
|
805
|
+
return job.id if job.respond_to?(:id)
|
|
806
|
+
job["id"] || job[:id]
|
|
807
|
+
end
|
|
808
|
+
|
|
809
|
+
# ── build ─────────────────────────────────────────────────────────────
|
|
810
|
+
#
|
|
811
|
+
# Build the deployable Docker image for this Tina4 app. A Tina4 app deploys
|
|
812
|
+
# as a container (`tina4ruby init` scaffolds a Dockerfile), so `build`
|
|
813
|
+
# produces THAT artifact — it shells out to the `docker` CLI (no new gem)
|
|
814
|
+
# and fails loud with guidance when there is no Dockerfile or docker is not
|
|
815
|
+
# on PATH. Mirrors the Python master's `build`.
|
|
816
|
+
#
|
|
817
|
+
# tina4ruby build # docker build -t <dir>:latest .
|
|
818
|
+
# tina4ruby build --tag myapp:1.2 # explicit image tag
|
|
819
|
+
# tina4ruby build --file docker/Dockerfile
|
|
820
|
+
def cmd_build(argv)
|
|
821
|
+
flags, _positional = parse_flags(argv)
|
|
822
|
+
|
|
823
|
+
tag = flags["tag"]
|
|
824
|
+
unless tag.is_a?(String) && !tag.empty?
|
|
825
|
+
# Default tag: <project-folder>:latest, lower-cased (docker repo names
|
|
826
|
+
# must be lowercase). Fall back to a sane name for an unnamed cwd.
|
|
827
|
+
base = File.basename(Dir.pwd).downcase
|
|
828
|
+
base = "tina4app" if base.empty?
|
|
829
|
+
tag = "#{base}:latest"
|
|
830
|
+
end
|
|
831
|
+
|
|
832
|
+
dockerfile = flags["file"].is_a?(String) ? flags["file"] : "Dockerfile"
|
|
833
|
+
unless File.file?(dockerfile)
|
|
834
|
+
puts " ✗ No #{dockerfile} found."
|
|
835
|
+
puts " A Tina4 app deploys as a container. Scaffold a Dockerfile first:"
|
|
836
|
+
puts " tina4 deploy docker (or: tina4ruby init)"
|
|
837
|
+
exit 1
|
|
838
|
+
end
|
|
839
|
+
|
|
840
|
+
docker = which_executable("docker")
|
|
841
|
+
if docker.nil?
|
|
842
|
+
puts " ✗ docker was not found on PATH."
|
|
843
|
+
puts " Install Docker to build the deployable image, or build manually:"
|
|
844
|
+
puts " docker build -t #{tag} -f #{dockerfile} ."
|
|
845
|
+
exit 1
|
|
846
|
+
end
|
|
847
|
+
|
|
848
|
+
puts " Building image #{tag} from #{dockerfile} ..."
|
|
849
|
+
ok = system(docker, "build", "-t", tag, "-f", dockerfile, ".")
|
|
850
|
+
unless ok
|
|
851
|
+
code = ($?&.exitstatus) || 1
|
|
852
|
+
puts " ✗ docker build failed (exit #{code})"
|
|
853
|
+
exit code
|
|
854
|
+
end
|
|
855
|
+
puts " ✓ Built image #{tag}"
|
|
856
|
+
puts " Run: docker run -p 7147:7147 #{tag}"
|
|
857
|
+
end
|
|
858
|
+
|
|
859
|
+
# Locate an executable on PATH — the stdlib-only equivalent of Python's
|
|
860
|
+
# shutil.which (no new gem). Honours PATHEXT on Windows.
|
|
861
|
+
def which_executable(cmd)
|
|
862
|
+
exts = ENV["PATHEXT"] ? ENV["PATHEXT"].split(File::PATH_SEPARATOR) : [""]
|
|
863
|
+
ENV.fetch("PATH", "").split(File::PATH_SEPARATOR).each do |dir|
|
|
864
|
+
next if dir.empty?
|
|
865
|
+
exts.each do |ext|
|
|
866
|
+
candidate = File.join(dir, "#{cmd}#{ext}")
|
|
867
|
+
return candidate if File.executable?(candidate) && !File.directory?(candidate)
|
|
868
|
+
end
|
|
869
|
+
end
|
|
870
|
+
nil
|
|
871
|
+
end
|
|
872
|
+
|
|
505
873
|
# ── version ───────────────────────────────────────────────────────────
|
|
506
874
|
|
|
507
|
-
def cmd_version
|
|
875
|
+
def cmd_version(_argv = nil)
|
|
508
876
|
require_relative "version"
|
|
509
877
|
puts "Tina4 Ruby v#{Tina4::VERSION}"
|
|
510
878
|
end
|
|
511
879
|
|
|
512
880
|
# ── routes ────────────────────────────────────────────────────────────
|
|
513
881
|
|
|
514
|
-
def cmd_routes
|
|
882
|
+
def cmd_routes(_argv = nil)
|
|
515
883
|
require_relative "../tina4"
|
|
516
884
|
Tina4.initialize!(Dir.pwd)
|
|
517
885
|
load_routes(Dir.pwd)
|
|
@@ -528,7 +896,7 @@ module Tina4
|
|
|
528
896
|
|
|
529
897
|
# ── console ───────────────────────────────────────────────────────────
|
|
530
898
|
|
|
531
|
-
def cmd_console
|
|
899
|
+
def cmd_console(_argv = nil)
|
|
532
900
|
require_relative "../tina4"
|
|
533
901
|
Tina4.initialize!(Dir.pwd)
|
|
534
902
|
load_routes(Dir.pwd)
|
|
@@ -655,15 +1023,13 @@ module Tina4
|
|
|
655
1023
|
|
|
656
1024
|
# ── generate ────────────────────────────────────────────────────────
|
|
657
1025
|
|
|
658
|
-
ALL_GENERATORS = "model, route, crud, migration, middleware, test, form, view, auth, " \
|
|
659
|
-
"service, queue, validator, seeder, websocket, listener"
|
|
660
|
-
|
|
661
1026
|
def cmd_generate(argv)
|
|
662
1027
|
what = argv.shift
|
|
1028
|
+
all = GENERATORS.keys.join(", ") # single source: the GENERATORS registry
|
|
663
1029
|
|
|
664
1030
|
unless what
|
|
665
1031
|
puts "Usage: tina4ruby generate <what> <name> [options]"
|
|
666
|
-
puts " Generators: #{
|
|
1032
|
+
puts " Generators: #{all}"
|
|
667
1033
|
puts ' Options: --fields "name:string,price:float" --model ModelName'
|
|
668
1034
|
puts ' --public open a route'"'"'s writes (default: secure)'
|
|
669
1035
|
puts ' --every 5m | --cron "..." service schedule'
|
|
@@ -682,32 +1048,21 @@ module Tina4
|
|
|
682
1048
|
name = no_name_generators.include?(what) ? "" : argv.shift
|
|
683
1049
|
flags, _positional = parse_flags(argv)
|
|
684
1050
|
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
when "middleware" then generate_middleware(name, flags)
|
|
691
|
-
when "test" then generate_test(name, flags)
|
|
692
|
-
when "form" then generate_form(name, flags)
|
|
693
|
-
when "view" then generate_view(name, flags)
|
|
694
|
-
when "auth" then generate_auth(name, flags)
|
|
695
|
-
when "service" then generate_service(name, flags)
|
|
696
|
-
when "queue" then generate_queue(name, flags)
|
|
697
|
-
when "validator" then generate_validator(name, flags)
|
|
698
|
-
when "seeder" then generate_seeder(name, flags)
|
|
699
|
-
when "websocket" then generate_websocket(name, flags)
|
|
700
|
-
when "listener" then generate_listener(name, flags)
|
|
1051
|
+
# Dispatch from the GENERATORS registry (single source of truth for the
|
|
1052
|
+
# generate subcommands; also feeds #cmd_help and the manifest).
|
|
1053
|
+
gen_spec = GENERATORS[what]
|
|
1054
|
+
if gen_spec
|
|
1055
|
+
send(gen_spec[:handler], name, flags)
|
|
701
1056
|
else
|
|
702
1057
|
puts "Unknown generator: #{what}"
|
|
703
|
-
puts " Available: #{
|
|
1058
|
+
puts " Available: #{all}"
|
|
704
1059
|
exit 1
|
|
705
1060
|
end
|
|
706
1061
|
end
|
|
707
1062
|
|
|
708
1063
|
# ── Generator: model ─────────────────────────────────────────────────
|
|
709
1064
|
|
|
710
|
-
def generate_model(name, flags)
|
|
1065
|
+
def generate_model(name, flags, emit_test: true)
|
|
711
1066
|
fields = parse_fields(flags["fields"])
|
|
712
1067
|
table = to_table_name(name)
|
|
713
1068
|
snake = to_snake_case(name)
|
|
@@ -744,10 +1099,16 @@ module Tina4
|
|
|
744
1099
|
File.write(path, content)
|
|
745
1100
|
puts " Created #{path}"
|
|
746
1101
|
|
|
747
|
-
# Generate matching migration (unless --no-migration)
|
|
1102
|
+
# Generate matching migration (unless --no-migration). The model's own
|
|
1103
|
+
# co-emitted spec proves the schema through the real ORM, so the migration
|
|
1104
|
+
# sub-call does NOT also co-emit a migration spec (emit_test: false).
|
|
748
1105
|
unless flags["no-migration"]
|
|
749
|
-
generate_migration("create_#{table}", flags, fields_override: fields,
|
|
1106
|
+
generate_migration("create_#{table}", flags, fields_override: fields,
|
|
1107
|
+
table_override: table, emit_test: false)
|
|
750
1108
|
end
|
|
1109
|
+
|
|
1110
|
+
# Co-emit a real SQLite roundtrip spec next to the model.
|
|
1111
|
+
emit_model_test(name, table, fields) if emit_test
|
|
751
1112
|
end
|
|
752
1113
|
|
|
753
1114
|
# ── Generator: route ─────────────────────────────────────────────────
|
|
@@ -768,7 +1129,7 @@ module Tina4
|
|
|
768
1129
|
# `Tina4::Router.get/post/...` (no bearer auth_handler attached), so `.no_auth`
|
|
769
1130
|
# genuinely opens the write on BOTH the live server and the TestClient — the
|
|
770
1131
|
# same registration path AutoCrud uses.
|
|
771
|
-
def generate_route(name, flags)
|
|
1132
|
+
def generate_route(name, flags, emit_test: true)
|
|
772
1133
|
route_path = name.sub(%r{^/}, "")
|
|
773
1134
|
singular = route_path.end_with?("s") ? route_path[0..-2] : route_path
|
|
774
1135
|
model = flags["model"]
|
|
@@ -929,6 +1290,18 @@ module Tina4
|
|
|
929
1290
|
|
|
930
1291
|
File.write(path, content)
|
|
931
1292
|
puts " Created #{path}"
|
|
1293
|
+
|
|
1294
|
+
# Co-emit a real spec. A --model route is working code → reuse the
|
|
1295
|
+
# secure-gate behavioural spec (reads public, writes gated) via
|
|
1296
|
+
# generate_test; a no-model route's handlers are loud stubs → the
|
|
1297
|
+
# Router-registration + live-stub spec.
|
|
1298
|
+
if emit_test
|
|
1299
|
+
if model
|
|
1300
|
+
generate_test(route_path, { "model" => model, "secure_writes" => true, "public" => public_writes })
|
|
1301
|
+
else
|
|
1302
|
+
emit_route_stub_test(route_path)
|
|
1303
|
+
end
|
|
1304
|
+
end
|
|
932
1305
|
end
|
|
933
1306
|
|
|
934
1307
|
# ── Generator: crud ──────────────────────────────────────────────────
|
|
@@ -940,12 +1313,14 @@ module Tina4
|
|
|
940
1313
|
|
|
941
1314
|
puts "\n Generating CRUD for #{name}...\n"
|
|
942
1315
|
|
|
943
|
-
# 1. Model + migration
|
|
944
|
-
|
|
1316
|
+
# 1. Model + migration (emit_test: false — crud emits its own broader gate
|
|
1317
|
+
# spec at step 5, so the sub-generators stay quiet to avoid double-emit).
|
|
1318
|
+
generate_model(name, flags, emit_test: false)
|
|
945
1319
|
|
|
946
1320
|
# 2. Routes with model — secure-by-default; thread --public through so
|
|
947
1321
|
# `generate crud X --public` opens the writes (mirrors AutoCrud public:).
|
|
948
|
-
|
|
1322
|
+
# emit_test: false — the crud gate spec at step 5 targets the same file.
|
|
1323
|
+
generate_route(route_name, { "model" => name, "public" => is_public }, emit_test: false)
|
|
949
1324
|
|
|
950
1325
|
# 3. Form
|
|
951
1326
|
generate_form(name, flags)
|
|
@@ -963,7 +1338,7 @@ module Tina4
|
|
|
963
1338
|
|
|
964
1339
|
# ── Generator: migration ─────────────────────────────────────────────
|
|
965
1340
|
|
|
966
|
-
def generate_migration(name, flags = {}, fields_override: nil, table_override: nil)
|
|
1341
|
+
def generate_migration(name, flags = {}, fields_override: nil, table_override: nil, emit_test: true)
|
|
967
1342
|
now = Time.now
|
|
968
1343
|
timestamp = now.strftime("%Y%m%d%H%M%S")
|
|
969
1344
|
dir = "migrations"
|
|
@@ -1000,22 +1375,24 @@ module Tina4
|
|
|
1000
1375
|
down_sql = "-- Write your DOWN rollback SQL here\n-- Example: ALTER TABLE #{table} DROP COLUMN new_col;"
|
|
1001
1376
|
end
|
|
1002
1377
|
|
|
1378
|
+
# The main .sql holds ONLY the UP migration. The runner executes the WHOLE
|
|
1379
|
+
# file (comments stripped), so embedding the DOWN here would CREATE then
|
|
1380
|
+
# immediately DROP the table — the migration would silently no-op. The
|
|
1381
|
+
# rollback SQL lives solely in the sibling .down.sql. Matches the Python
|
|
1382
|
+
# master (tina4_python/cli/__init__.py).
|
|
1003
1383
|
content = <<~SQL
|
|
1004
1384
|
-- Migration: #{name}
|
|
1005
1385
|
-- Created: #{now.strftime("%Y-%m-%d %H:%M:%S")}
|
|
1006
1386
|
|
|
1007
|
-
-- UP
|
|
1008
1387
|
#{up_sql}
|
|
1009
|
-
|
|
1010
|
-
-- DOWN
|
|
1011
|
-
#{down_sql}
|
|
1012
1388
|
SQL
|
|
1013
1389
|
|
|
1014
1390
|
File.write(path, content)
|
|
1015
1391
|
puts " Created #{path}"
|
|
1016
1392
|
|
|
1017
1393
|
# Also create .down.sql for the migration runner
|
|
1018
|
-
|
|
1394
|
+
down_filename = "#{timestamp}_#{name}.down.sql"
|
|
1395
|
+
down_path = File.join(dir, down_filename)
|
|
1019
1396
|
down_content = <<~SQL
|
|
1020
1397
|
-- Rollback: #{name}
|
|
1021
1398
|
-- Created: #{now.strftime("%Y-%m-%d %H:%M:%S")}
|
|
@@ -1025,6 +1402,10 @@ module Tina4
|
|
|
1025
1402
|
|
|
1026
1403
|
File.write(down_path, down_content)
|
|
1027
1404
|
puts " Created #{down_path}"
|
|
1405
|
+
|
|
1406
|
+
# Co-emit a real apply-UP/DOWN spec — only for CREATE migrations, whose UP
|
|
1407
|
+
# SQL is real DDL to assert against (a placeholder ALTER has nothing yet).
|
|
1408
|
+
emit_migration_test(name, table, filename, down_filename) if emit_test && is_create
|
|
1028
1409
|
end
|
|
1029
1410
|
|
|
1030
1411
|
# ── Generator: middleware ────────────────────────────────────────────
|
|
@@ -1053,7 +1434,7 @@ module Tina4
|
|
|
1053
1434
|
# Runs before the route handler.
|
|
1054
1435
|
# Return [request, response] to continue, or
|
|
1055
1436
|
# return [request, response.json({ error: "Unauthorized" }, 401)] to block.
|
|
1056
|
-
Tina4::Log.info("#{name}: \#{request.
|
|
1437
|
+
Tina4::Log.info("#{name}: \#{request.method} \#{request.path}")
|
|
1057
1438
|
[request, response]
|
|
1058
1439
|
end
|
|
1059
1440
|
|
|
@@ -1066,6 +1447,10 @@ module Tina4
|
|
|
1066
1447
|
|
|
1067
1448
|
File.write(path, content)
|
|
1068
1449
|
puts " Created #{path}"
|
|
1450
|
+
|
|
1451
|
+
# Co-emit a real dispatch spec (drives the scaffold through the real
|
|
1452
|
+
# server middleware dispatch — Tina4::Middleware.run_before/run_after).
|
|
1453
|
+
emit_middleware_test(name, snake)
|
|
1069
1454
|
end
|
|
1070
1455
|
|
|
1071
1456
|
# ── Generator: test ──────────────────────────────────────────────────
|
|
@@ -1075,14 +1460,6 @@ module Tina4
|
|
|
1075
1460
|
snake = to_snake_case(name)
|
|
1076
1461
|
singular = snake.end_with?("s") ? snake[0..-2] : snake
|
|
1077
1462
|
|
|
1078
|
-
dir = "spec"
|
|
1079
|
-
FileUtils.mkdir_p(dir)
|
|
1080
|
-
path = File.join(dir, "#{snake}_spec.rb")
|
|
1081
|
-
if File.exist?(path)
|
|
1082
|
-
puts " File already exists: #{path}"
|
|
1083
|
-
return
|
|
1084
|
-
end
|
|
1085
|
-
|
|
1086
1463
|
# Secure-by-default CRUD spec (emitted by `generate crud`): proves the gate
|
|
1087
1464
|
# by BEHAVIOR through the real Tina4::TestClient — reads public, writes
|
|
1088
1465
|
# gated — instead of assuming an anonymous create returns 201. Grounded on
|
|
@@ -1157,8 +1534,7 @@ module Tina4
|
|
|
1157
1534
|
#{write_examples}
|
|
1158
1535
|
end
|
|
1159
1536
|
RUBY
|
|
1160
|
-
|
|
1161
|
-
puts " Created #{path}"
|
|
1537
|
+
write_test(snake, content)
|
|
1162
1538
|
return
|
|
1163
1539
|
end
|
|
1164
1540
|
|
|
@@ -1221,8 +1597,7 @@ module Tina4
|
|
|
1221
1597
|
RUBY
|
|
1222
1598
|
end
|
|
1223
1599
|
|
|
1224
|
-
|
|
1225
|
-
puts " Created #{path}"
|
|
1600
|
+
write_test(snake, content)
|
|
1226
1601
|
end
|
|
1227
1602
|
|
|
1228
1603
|
# ── Generator: form ──────────────────────────────────────────────────
|
|
@@ -1394,8 +1769,9 @@ module Tina4
|
|
|
1394
1769
|
def generate_auth(_name = nil, flags = {})
|
|
1395
1770
|
puts "\n Generating authentication scaffolding...\n"
|
|
1396
1771
|
|
|
1397
|
-
# 1. User model + migration
|
|
1398
|
-
|
|
1772
|
+
# 1. User model + migration (emit_test: false — auth emits its own broader
|
|
1773
|
+
# register/login/me spec at step 5).
|
|
1774
|
+
generate_model("User", { "fields" => "email:string,password:string,role:string" }, emit_test: false)
|
|
1399
1775
|
|
|
1400
1776
|
# 2. Auth routes
|
|
1401
1777
|
dir = "src/routes"
|
|
@@ -1530,8 +1906,9 @@ module Tina4
|
|
|
1530
1906
|
puts " Created #{register_path}"
|
|
1531
1907
|
end
|
|
1532
1908
|
|
|
1533
|
-
# 5. Auth test
|
|
1534
|
-
|
|
1909
|
+
# 5. Auth test — real register/login/me end-to-end (real Router, real Auth
|
|
1910
|
+
# JWT + PBKDF2, real SQLite). Replaces the old placeholder stub spec.
|
|
1911
|
+
emit_auth_test
|
|
1535
1912
|
|
|
1536
1913
|
puts "\n Authentication scaffolding complete."
|
|
1537
1914
|
puts " Run: tina4ruby migrate"
|
|
@@ -1609,6 +1986,9 @@ module Tina4
|
|
|
1609
1986
|
RUBY
|
|
1610
1987
|
File.write(path, content)
|
|
1611
1988
|
puts " Created #{path}"
|
|
1989
|
+
|
|
1990
|
+
# Co-emit a real ServiceRunner registration/discovery spec.
|
|
1991
|
+
emit_service_test(name, snake)
|
|
1612
1992
|
end
|
|
1613
1993
|
|
|
1614
1994
|
# ── Generator: queue ──────────────────────────────────────────────────
|
|
@@ -1665,11 +2045,17 @@ module Tina4
|
|
|
1665
2045
|
end
|
|
1666
2046
|
|
|
1667
2047
|
# Wiring: consumer runs as a daemon service (manages its own poll loop).
|
|
1668
|
-
# Discovered by Tina4::ServiceRunner.discover("src/services").
|
|
1669
|
-
|
|
2048
|
+
# Discovered by Tina4::ServiceRunner.discover("src/services"). The `topic`
|
|
2049
|
+
# + per-job `handle` options let `tina4ruby queue work #{topic}` drive this
|
|
2050
|
+
# consumer directly (own the poll loop / bounded --once drain) without
|
|
2051
|
+
# wiring a ServiceRunner.
|
|
2052
|
+
Tina4.service("#{topic}-consumer", daemon: true, topic: "#{topic}", handle: method(:handle_#{slug})) { |context| consume_#{slug}(context) }
|
|
1670
2053
|
RUBY
|
|
1671
2054
|
File.write(path, content)
|
|
1672
2055
|
puts " Created #{path}"
|
|
2056
|
+
|
|
2057
|
+
# Co-emit a real file-backed Queue spec (push a real job + daemon wiring).
|
|
2058
|
+
emit_queue_test(topic, slug)
|
|
1673
2059
|
end
|
|
1674
2060
|
|
|
1675
2061
|
# ── Generator: validator ──────────────────────────────────────────────
|
|
@@ -1685,15 +2071,15 @@ module Tina4
|
|
|
1685
2071
|
return
|
|
1686
2072
|
end
|
|
1687
2073
|
|
|
1688
|
-
|
|
1689
|
-
|
|
1690
|
-
|
|
1691
|
-
|
|
1692
|
-
|
|
1693
|
-
|
|
1694
|
-
|
|
1695
|
-
ground:
|
|
1696
|
-
)
|
|
2074
|
+
# Ships a working starter rule (not a loud stub): a rules-less validator
|
|
2075
|
+
# validates nothing, so there would be no negative case for the co-emitted
|
|
2076
|
+
# valid/invalid spec to assert against. `required("name")` mirrors the model
|
|
2077
|
+
# generator's default `name` field — edit it for your real payload.
|
|
2078
|
+
body = extend_marker(
|
|
2079
|
+
"add / adjust the rules for your #{name} payload",
|
|
2080
|
+
'e.g. validator.email("email").min_length("name", 2).integer("age") · ' \
|
|
2081
|
+
'ground: tina4_context("validate request body with Validator", "ruby")'
|
|
2082
|
+
) + %( validator.required("name")\n)
|
|
1697
2083
|
content = <<~RUBY
|
|
1698
2084
|
# #{name} request validator.
|
|
1699
2085
|
#
|
|
@@ -1713,6 +2099,9 @@ module Tina4
|
|
|
1713
2099
|
RUBY
|
|
1714
2100
|
File.write(path, content)
|
|
1715
2101
|
puts " Created #{path}"
|
|
2102
|
+
|
|
2103
|
+
# Co-emit a real valid + invalid spec against the starter rule.
|
|
2104
|
+
emit_validator_test(name, snake)
|
|
1716
2105
|
end
|
|
1717
2106
|
|
|
1718
2107
|
# ── Generator: seeder ─────────────────────────────────────────────────
|
|
@@ -1729,15 +2118,14 @@ module Tina4
|
|
|
1729
2118
|
return
|
|
1730
2119
|
end
|
|
1731
2120
|
|
|
1732
|
-
|
|
1733
|
-
|
|
1734
|
-
|
|
1735
|
-
|
|
1736
|
-
"
|
|
1737
|
-
|
|
1738
|
-
|
|
1739
|
-
|
|
1740
|
-
)
|
|
2121
|
+
# Ships working out of the box (not a loud stub): seed_orm auto-fills every
|
|
2122
|
+
# field by type/name, so a zero-override seeder already seeds real rows.
|
|
2123
|
+
# Return overrides only for fields that need a specific shape.
|
|
2124
|
+
body = extend_marker(
|
|
2125
|
+
"override #{name} fields that need a specific shape (optional)",
|
|
2126
|
+
'e.g. { "email" => ->(fake) { fake.email }, "status" => "active" } · ' \
|
|
2127
|
+
'ground: tina4_context("seed ORM model with FakeData", "ruby")'
|
|
2128
|
+
) + %( {}\n)
|
|
1741
2129
|
content = <<~RUBY
|
|
1742
2130
|
# Seeder for #{name} — run with: tina4ruby seed
|
|
1743
2131
|
#
|
|
@@ -1755,9 +2143,9 @@ module Tina4
|
|
|
1755
2143
|
end
|
|
1756
2144
|
|
|
1757
2145
|
# Wiring: seed rows via Tina4.seed_orm. Guarded on a bound database so
|
|
1758
|
-
# merely LOADING this file (e.g.
|
|
1759
|
-
#
|
|
1760
|
-
#
|
|
2146
|
+
# merely LOADING this file (e.g. from another script) is a no-op — only
|
|
2147
|
+
# `tina4ruby seed` (which binds the DB first) actually seeds. seed_orm
|
|
2148
|
+
# auto-fills every field, so this works out of the box.
|
|
1761
2149
|
if Tina4.database
|
|
1762
2150
|
fake = Tina4::FakeData.new
|
|
1763
2151
|
summary = Tina4.seed_orm(#{name}, count: 20, overrides: #{table}_field_overrides(fake))
|
|
@@ -1766,6 +2154,9 @@ module Tina4
|
|
|
1766
2154
|
RUBY
|
|
1767
2155
|
File.write(path, content)
|
|
1768
2156
|
puts " Created #{path}"
|
|
2157
|
+
|
|
2158
|
+
# Co-emit a real seeding spec (runs the seeder against real SQLite).
|
|
2159
|
+
emit_seeder_test(name, table)
|
|
1769
2160
|
end
|
|
1770
2161
|
|
|
1771
2162
|
# ── Generator: websocket ──────────────────────────────────────────────
|
|
@@ -1820,6 +2211,9 @@ module Tina4
|
|
|
1820
2211
|
RUBY
|
|
1821
2212
|
File.write(path, content)
|
|
1822
2213
|
puts " Created #{path}"
|
|
2214
|
+
|
|
2215
|
+
# Co-emit a real handler spec (Router registration + drives the handler).
|
|
2216
|
+
emit_websocket_test(ws_path, base, handler)
|
|
1823
2217
|
end
|
|
1824
2218
|
|
|
1825
2219
|
# ── Generator: listener ───────────────────────────────────────────────
|
|
@@ -1865,70 +2259,604 @@ module Tina4
|
|
|
1865
2259
|
RUBY
|
|
1866
2260
|
File.write(path, content)
|
|
1867
2261
|
puts " Created #{path}"
|
|
2262
|
+
|
|
2263
|
+
# Co-emit a real event-bus spec (emit the real event, assert it ran).
|
|
2264
|
+
emit_listener_test(event, slug)
|
|
2265
|
+
end
|
|
2266
|
+
|
|
2267
|
+
# ── co-emitted tests: every code-producing generator ships a real spec ──
|
|
2268
|
+
#
|
|
2269
|
+
# One shared writer (#write_test) + one focused builder per generator. The
|
|
2270
|
+
# builders are NOT copy-paste boilerplate — each drives a different real
|
|
2271
|
+
# subsystem (real SQLite / TestClient / Router / ServiceRunner / Queue /
|
|
2272
|
+
# event bus), grounded on the same real-collaborator patterns the
|
|
2273
|
+
# scaffolding-first acceptance matrix already proves. CRUD-shaped scaffolds
|
|
2274
|
+
# (working code) get behavioural specs; logic-shaped scaffolds (loud
|
|
2275
|
+
# NotImplementedError stubs) get wiring specs + a lock-in that the
|
|
2276
|
+
# placeholder fails loud. `test` itself (it IS the test generator) and
|
|
2277
|
+
# form/view (template-only, no Ruby logic to run) are the only exemptions.
|
|
2278
|
+
# Ruby mirror of the Python master's _write_test + _emit_* builders
|
|
2279
|
+
# (tina4_python/cli/__init__.py, owner req 2026-07-10, Phase 4).
|
|
2280
|
+
|
|
2281
|
+
# Write a co-emitted spec to spec/<name>_spec.rb — the SINGLE place a
|
|
2282
|
+
# generated spec is written (path + overwrite refusal + the "Created" line
|
|
2283
|
+
# every generator prints). Generalized from #generate_test so the generators
|
|
2284
|
+
# share one rule instead of a dozen copy-pasted write blocks.
|
|
2285
|
+
def write_test(test_name, content)
|
|
2286
|
+
dir = "spec"
|
|
2287
|
+
FileUtils.mkdir_p(dir)
|
|
2288
|
+
path = File.join(dir, "#{test_name}_spec.rb")
|
|
2289
|
+
if File.exist?(path)
|
|
2290
|
+
puts " File already exists: #{path}"
|
|
2291
|
+
return
|
|
2292
|
+
end
|
|
2293
|
+
File.write(path, content)
|
|
2294
|
+
puts " Created #{path}"
|
|
2295
|
+
end
|
|
2296
|
+
|
|
2297
|
+
# snake/kebab/dotted -> PascalCase: user_created -> UserCreated.
|
|
2298
|
+
def pascalize(name)
|
|
2299
|
+
name.to_s.split(/[^0-9a-zA-Z]+/).reject(&:empty?).map { |part| part[0].upcase + part[1..].to_s }.join
|
|
2300
|
+
end
|
|
2301
|
+
|
|
2302
|
+
# A source-text Ruby literal that is a valid value for a scaffolded field
|
|
2303
|
+
# type (used to build a real create() payload in the model spec).
|
|
2304
|
+
def sample_literal(field_type)
|
|
2305
|
+
{
|
|
2306
|
+
"int" => "1", "integer" => "1",
|
|
2307
|
+
"float" => "1.5", "numeric" => "1.5", "decimal" => "1.5",
|
|
2308
|
+
"bool" => "true", "boolean" => "true",
|
|
2309
|
+
"datetime" => '"2020-01-01 00:00:00"',
|
|
2310
|
+
"blob" => '"x"',
|
|
2311
|
+
}.fetch((field_type || "string").to_s.downcase, '"sample"')
|
|
2312
|
+
end
|
|
2313
|
+
|
|
2314
|
+
# model -> real SQLite roundtrip (create / read back / missing -> nil).
|
|
2315
|
+
def emit_model_test(model, table, fields)
|
|
2316
|
+
fields = fields.empty? ? [["name", "string"]] : fields
|
|
2317
|
+
payload = fields.map { |fname, ftype| %("#{fname}" => #{sample_literal(ftype)}) }.join(", ")
|
|
2318
|
+
# Assert a STRING field round-trips (type-safe); else just the id round-trips
|
|
2319
|
+
# (avoids datetime/bool/float equality pitfalls on the read-back).
|
|
2320
|
+
string_field = fields.find { |_f, t| %w[string str text].include?((t || "string").to_s.downcase) }
|
|
2321
|
+
value_assert = string_field ? %(\n expect(fetched.#{string_field[0]}).to eq("sample")) : ""
|
|
2322
|
+
content = <<~RUBY
|
|
2323
|
+
# frozen_string_literal: true
|
|
2324
|
+
#
|
|
2325
|
+
# Real ORM roundtrip spec for #{model} — no mocks, real SQLite.
|
|
2326
|
+
#
|
|
2327
|
+
# Generated with src/orm/#{table}.rb by `tina4ruby generate model #{model}`.
|
|
2328
|
+
# The model scaffold is working code, so this passes on generation: it
|
|
2329
|
+
# binds a real on-disk SQLite database, creates the table, saves a row and
|
|
2330
|
+
# reads it back.
|
|
2331
|
+
require "spec_helper"
|
|
2332
|
+
require "tmpdir"
|
|
2333
|
+
require_relative "../src/orm/#{table}"
|
|
2334
|
+
|
|
2335
|
+
RSpec.describe "#{model} model (real SQLite)" do
|
|
2336
|
+
before(:each) do
|
|
2337
|
+
@dir = Dir.mktmpdir("#{table}_model")
|
|
2338
|
+
Tina4.bind_database(Tina4::Database.new("sqlite:///" + File.join(@dir, "test.db")))
|
|
2339
|
+
#{model}.create_table
|
|
2340
|
+
end
|
|
2341
|
+
|
|
2342
|
+
after(:each) { FileUtils.rm_rf(@dir) }
|
|
2343
|
+
|
|
2344
|
+
it "creates a row and reads it back" do
|
|
2345
|
+
row = #{model}.create({ #{payload} })
|
|
2346
|
+
expect(row).not_to be_nil
|
|
2347
|
+
expect(row.id).not_to be_nil
|
|
2348
|
+
fetched = #{model}.find(row.id)
|
|
2349
|
+
expect(fetched).not_to be_nil
|
|
2350
|
+
expect(fetched.id).to eq(row.id)#{value_assert}
|
|
2351
|
+
end
|
|
2352
|
+
|
|
2353
|
+
it "returns nil for a missing id" do
|
|
2354
|
+
expect(#{model}.find(999999)).to be_nil
|
|
2355
|
+
end
|
|
2356
|
+
end
|
|
2357
|
+
RUBY
|
|
2358
|
+
write_test("#{table}_model", content)
|
|
2359
|
+
end
|
|
2360
|
+
|
|
2361
|
+
# route (no --model) -> real Router registration + the handler is a live loud
|
|
2362
|
+
# stub. (route --model reuses the secure-gate #generate_test spec instead.)
|
|
2363
|
+
def emit_route_stub_test(route)
|
|
2364
|
+
klass = pascalize(route)
|
|
2365
|
+
content = <<~RUBY
|
|
2366
|
+
# frozen_string_literal: true
|
|
2367
|
+
#
|
|
2368
|
+
# Routing spec for #{route} — no mocks, real Router.
|
|
2369
|
+
#
|
|
2370
|
+
# Generated with src/routes/#{route}.rb by `tina4ruby generate route
|
|
2371
|
+
# #{route}` (no --model). The handlers are AI-FILL stubs that raise until
|
|
2372
|
+
# you implement them, so this tests what IS live on generation: all five
|
|
2373
|
+
# routes register on the REAL Router, and the list handler fails loud until
|
|
2374
|
+
# filled. Fill a handler, then assert its real response here.
|
|
2375
|
+
require "spec_helper"
|
|
2376
|
+
|
|
2377
|
+
RSpec.describe "#{klass} routing (real Router)" do
|
|
2378
|
+
before(:each) { load File.expand_path("../src/routes/#{route}.rb", __dir__) }
|
|
2379
|
+
|
|
2380
|
+
it "registers all five CRUD routes" do
|
|
2381
|
+
paths = Tina4::Router.routes.map { |r| [r.method, r.path] }
|
|
2382
|
+
expect(paths).to include(["GET", "/api/#{route}"])
|
|
2383
|
+
expect(paths).to include(["GET", "/api/#{route}/{id:int}"])
|
|
2384
|
+
expect(paths).to include(["POST", "/api/#{route}"])
|
|
2385
|
+
expect(paths).to include(["PUT", "/api/#{route}/{id:int}"])
|
|
2386
|
+
expect(paths).to include(["DELETE", "/api/#{route}/{id:int}"])
|
|
2387
|
+
end
|
|
2388
|
+
|
|
2389
|
+
it "the list handler is a live stub (raises until filled)" do
|
|
2390
|
+
route, = Tina4::Router.match("GET", "/api/#{route}")
|
|
2391
|
+
expect { route.handler.call(nil, nil) }.to raise_error(NotImplementedError)
|
|
2392
|
+
end
|
|
2393
|
+
end
|
|
2394
|
+
RUBY
|
|
2395
|
+
write_test(route, content)
|
|
2396
|
+
end
|
|
2397
|
+
|
|
2398
|
+
# middleware -> a request routed THROUGH the real server dispatch.
|
|
2399
|
+
def emit_middleware_test(name, snake)
|
|
2400
|
+
content = <<~RUBY
|
|
2401
|
+
# frozen_string_literal: true
|
|
2402
|
+
#
|
|
2403
|
+
# Real dispatch spec for the #{name} middleware — no mocks.
|
|
2404
|
+
#
|
|
2405
|
+
# Generated with src/middleware/#{snake}.rb by `tina4ruby generate
|
|
2406
|
+
# middleware #{name}`. Drives the middleware through the REAL server
|
|
2407
|
+
# dispatch (Tina4::Middleware.run_before / run_after) with a real Request +
|
|
2408
|
+
# Response — the same code path the live server runs.
|
|
2409
|
+
require "spec_helper"
|
|
2410
|
+
require "stringio"
|
|
2411
|
+
require_relative "../src/middleware/#{snake}"
|
|
2412
|
+
|
|
2413
|
+
RSpec.describe "#{name} middleware (real dispatch)" do
|
|
2414
|
+
before(:each) { Tina4::Middleware.clear! if Tina4::Middleware.respond_to?(:clear!) }
|
|
2415
|
+
|
|
2416
|
+
def build_request
|
|
2417
|
+
env = {
|
|
2418
|
+
"REQUEST_METHOD" => "GET", "PATH_INFO" => "/", "QUERY_STRING" => "",
|
|
2419
|
+
"HTTP_HOST" => "localhost", "rack.input" => StringIO.new("")
|
|
2420
|
+
}
|
|
2421
|
+
Tina4::Request.new(env)
|
|
2422
|
+
end
|
|
2423
|
+
|
|
2424
|
+
it "before_* passes the request through (does not block the handler)" do
|
|
2425
|
+
response = Tina4::Response.new
|
|
2426
|
+
ok = Tina4::Middleware.run_before([#{name}], build_request, response)
|
|
2427
|
+
expect(ok).to be true # the scaffold does not halt the chain
|
|
2428
|
+
expect(response.status_code).to be < 400
|
|
2429
|
+
end
|
|
2430
|
+
|
|
2431
|
+
it "after_* runs and leaves the response intact" do
|
|
2432
|
+
response = Tina4::Response.new
|
|
2433
|
+
Tina4::Middleware.run_after([#{name}], build_request, response)
|
|
2434
|
+
expect(response.status_code).to be < 400
|
|
2435
|
+
end
|
|
2436
|
+
end
|
|
2437
|
+
RUBY
|
|
2438
|
+
write_test(snake, content)
|
|
2439
|
+
end
|
|
2440
|
+
|
|
2441
|
+
# service -> registrable / discoverable on the REAL ServiceRunner + loud stub.
|
|
2442
|
+
def emit_service_test(name, snake)
|
|
2443
|
+
content = <<~RUBY
|
|
2444
|
+
# frozen_string_literal: true
|
|
2445
|
+
#
|
|
2446
|
+
# Real ServiceRunner spec for the #{name} service — no mocks.
|
|
2447
|
+
#
|
|
2448
|
+
# Generated with src/services/#{snake}.rb by `tina4ruby generate service
|
|
2449
|
+
# #{name}`. Loading the file registers the scaffold on the REAL
|
|
2450
|
+
# class-level ServiceRunner registry; the task body is an AI-FILL stub that
|
|
2451
|
+
# raises until filled.
|
|
2452
|
+
require "spec_helper"
|
|
2453
|
+
require_relative "../src/services/#{snake}"
|
|
2454
|
+
|
|
2455
|
+
RSpec.describe "#{pascalize(snake)} service (real ServiceRunner)" do
|
|
2456
|
+
it "registers on the ServiceRunner registry" do
|
|
2457
|
+
expect(Tina4::ServiceRunner.list.any? { |s| s[:name] == "#{snake}" }).to be true
|
|
2458
|
+
end
|
|
2459
|
+
|
|
2460
|
+
it "the task is a live stub (raises until filled)" do
|
|
2461
|
+
expect { #{snake}_task(nil) }.to raise_error(NotImplementedError)
|
|
2462
|
+
end
|
|
2463
|
+
end
|
|
2464
|
+
RUBY
|
|
2465
|
+
write_test(snake, content)
|
|
2466
|
+
end
|
|
2467
|
+
|
|
2468
|
+
# queue -> push a REAL job onto the real file-backed Queue + daemon wiring.
|
|
2469
|
+
def emit_queue_test(topic, slug)
|
|
2470
|
+
content = <<~RUBY
|
|
2471
|
+
# frozen_string_literal: true
|
|
2472
|
+
#
|
|
2473
|
+
# Real file-backed Queue spec for the #{topic} worker — no mocks.
|
|
2474
|
+
#
|
|
2475
|
+
# Generated with src/services/#{slug}_consumer.rb by `tina4ruby generate
|
|
2476
|
+
# queue #{topic}`. Pushes a REAL job onto the real file-backed Queue and
|
|
2477
|
+
# asserts it is enqueued, and that the consumer is wired as a daemon
|
|
2478
|
+
# service. handle_#{slug} is an AI-FILL stub that raises until you fill it
|
|
2479
|
+
# — then assert the processed side effect here.
|
|
2480
|
+
require "spec_helper"
|
|
2481
|
+
require_relative "../src/services/#{slug}_consumer"
|
|
2482
|
+
|
|
2483
|
+
RSpec.describe "#{pascalize(slug)} queue (real file-backed Queue)" do
|
|
2484
|
+
it "publish enqueues a real job" do
|
|
2485
|
+
before_size = Tina4::Queue.new(topic: "#{topic}").size
|
|
2486
|
+
job = publish_#{slug}({ "hello" => "world" })
|
|
2487
|
+
expect(job).not_to be_nil
|
|
2488
|
+
expect(job.id).not_to be_nil
|
|
2489
|
+
expect(Tina4::Queue.new(topic: "#{topic}").size).to be >= before_size + 1
|
|
2490
|
+
end
|
|
2491
|
+
|
|
2492
|
+
it "the consumer is wired as a daemon service" do
|
|
2493
|
+
svc = Tina4::ServiceRunner.list.find { |s| s[:name] == "#{topic}-consumer" }
|
|
2494
|
+
expect(svc).not_to be_nil
|
|
2495
|
+
expect(svc[:options][:daemon]).to be true
|
|
2496
|
+
end
|
|
2497
|
+
|
|
2498
|
+
it "handle_#{slug} is a live stub (raises until filled)" do
|
|
2499
|
+
expect { handle_#{slug}({}) }.to raise_error(NotImplementedError)
|
|
2500
|
+
end
|
|
2501
|
+
end
|
|
2502
|
+
RUBY
|
|
2503
|
+
write_test(slug, content)
|
|
2504
|
+
end
|
|
2505
|
+
|
|
2506
|
+
# validator -> run the scaffold against valid + invalid real input.
|
|
2507
|
+
def emit_validator_test(name, snake)
|
|
2508
|
+
content = <<~RUBY
|
|
2509
|
+
# frozen_string_literal: true
|
|
2510
|
+
#
|
|
2511
|
+
# Real validation spec for validate_#{snake} — no mocks.
|
|
2512
|
+
#
|
|
2513
|
+
# Generated with src/validators/#{snake}.rb by `tina4ruby generate
|
|
2514
|
+
# validator #{name}`. The scaffold ships a starter rule (required "name"),
|
|
2515
|
+
# so this passes on generation — adjust the rules for your payload and
|
|
2516
|
+
# update these cases with them.
|
|
2517
|
+
require "spec_helper"
|
|
2518
|
+
require_relative "../src/validators/#{snake}"
|
|
2519
|
+
|
|
2520
|
+
RSpec.describe "#{pascalize(snake)} validator" do
|
|
2521
|
+
it "valid input passes" do
|
|
2522
|
+
expect(validate_#{snake}({ "name" => "Ada" }).is_valid?).to be true
|
|
2523
|
+
end
|
|
2524
|
+
|
|
2525
|
+
it "invalid input fails with errors" do
|
|
2526
|
+
result = validate_#{snake}({})
|
|
2527
|
+
expect(result.is_valid?).to be false
|
|
2528
|
+
expect(result.errors).not_to be_empty
|
|
2529
|
+
end
|
|
2530
|
+
end
|
|
2531
|
+
RUBY
|
|
2532
|
+
write_test(snake, content)
|
|
2533
|
+
end
|
|
2534
|
+
|
|
2535
|
+
# seeder -> run the scaffold against real SQLite, assert rows created.
|
|
2536
|
+
def emit_seeder_test(model, table)
|
|
2537
|
+
content = <<~RUBY
|
|
2538
|
+
# frozen_string_literal: true
|
|
2539
|
+
#
|
|
2540
|
+
# Real seeding spec for the #{model} seeder — no mocks, real SQLite.
|
|
2541
|
+
#
|
|
2542
|
+
# Generated with seeds/#{table}_seeder.rb by `tina4ruby generate seeder
|
|
2543
|
+
# #{model}`. Binds a real SQLite DB, creates the table, then loads the
|
|
2544
|
+
# scaffolded seeder (its guarded block auto-fills every field via FakeData
|
|
2545
|
+
# and seeds rows) and asserts rows were created.
|
|
2546
|
+
require "spec_helper"
|
|
2547
|
+
require "tmpdir"
|
|
2548
|
+
require_relative "../src/orm/#{table}"
|
|
2549
|
+
|
|
2550
|
+
RSpec.describe "#{model} seeder (real SQLite)" do
|
|
2551
|
+
before(:each) do
|
|
2552
|
+
@dir = Dir.mktmpdir("#{table}_seeder")
|
|
2553
|
+
Tina4.bind_database(Tina4::Database.new("sqlite:///" + File.join(@dir, "seed.db")))
|
|
2554
|
+
#{model}.create_table
|
|
2555
|
+
end
|
|
2556
|
+
|
|
2557
|
+
after(:each) { FileUtils.rm_rf(@dir) }
|
|
2558
|
+
|
|
2559
|
+
it "field_overrides returns a Hash" do
|
|
2560
|
+
load File.expand_path("../seeds/#{table}_seeder.rb", __dir__)
|
|
2561
|
+
expect(#{table}_field_overrides(Tina4::FakeData.new)).to be_a(Hash)
|
|
2562
|
+
end
|
|
2563
|
+
|
|
2564
|
+
it "running the seeder creates rows" do
|
|
2565
|
+
load File.expand_path("../seeds/#{table}_seeder.rb", __dir__)
|
|
2566
|
+
expect(#{model}.count).to be >= 1
|
|
2567
|
+
end
|
|
2568
|
+
end
|
|
2569
|
+
RUBY
|
|
2570
|
+
write_test("#{table}_seeder", content)
|
|
2571
|
+
end
|
|
2572
|
+
|
|
2573
|
+
# websocket -> real Router registration + drive the real handler (the
|
|
2574
|
+
# socket-free "close" event) directly (no mock socket).
|
|
2575
|
+
def emit_websocket_test(ws_path, base, _handler)
|
|
2576
|
+
content = <<~RUBY
|
|
2577
|
+
# frozen_string_literal: true
|
|
2578
|
+
#
|
|
2579
|
+
# Real handler spec for the #{ws_path} WebSocket route — no mocks.
|
|
2580
|
+
#
|
|
2581
|
+
# Generated with src/routes/ws_#{base}.rb by `tina4ruby generate websocket
|
|
2582
|
+
# ...`. Confirms the handler registers on the REAL Router and drives the
|
|
2583
|
+
# real handler for the :close event (no socket needed). The :message branch
|
|
2584
|
+
# is an AI-FILL stub that raises until you fill it; a full RFC6455 loopback
|
|
2585
|
+
# is out of scope for a unit spec, so assert its broadcast/response against
|
|
2586
|
+
# a live server once implemented.
|
|
2587
|
+
require "spec_helper"
|
|
2588
|
+
|
|
2589
|
+
RSpec.describe "#{pascalize(base)} WebSocket (real Router)" do
|
|
2590
|
+
# `load` (not require) re-registers the handler after spec_helper's
|
|
2591
|
+
# after(:each) Router.clear! wipes the ws routes between examples.
|
|
2592
|
+
before(:each) { load File.expand_path("../src/routes/ws_#{base}.rb", __dir__) }
|
|
2593
|
+
|
|
2594
|
+
def ws_route
|
|
2595
|
+
Tina4::Router.get_web_socket_routes.find { |r| r.path == "#{ws_path}" }
|
|
2596
|
+
end
|
|
2597
|
+
|
|
2598
|
+
it "registers the handler on the Router ws routes" do
|
|
2599
|
+
expect(ws_route).not_to be_nil
|
|
2600
|
+
end
|
|
2601
|
+
|
|
2602
|
+
it "handles the :close event cleanly (no connection needed)" do
|
|
2603
|
+
expect(ws_route.handler.call(nil, :close, nil)).to be_nil
|
|
2604
|
+
end
|
|
2605
|
+
|
|
2606
|
+
it "the :message branch is a live stub (raises until filled)" do
|
|
2607
|
+
expect { ws_route.handler.call(nil, :message, "hi") }.to raise_error(NotImplementedError)
|
|
2608
|
+
end
|
|
2609
|
+
end
|
|
2610
|
+
RUBY
|
|
2611
|
+
write_test("ws_#{base}", content)
|
|
2612
|
+
end
|
|
2613
|
+
|
|
2614
|
+
# listener -> emit the REAL event on the real bus, assert the listener ran.
|
|
2615
|
+
def emit_listener_test(event, slug)
|
|
2616
|
+
content = <<~RUBY
|
|
2617
|
+
# frozen_string_literal: true
|
|
2618
|
+
#
|
|
2619
|
+
# Real event-bus spec for the '#{event}' listener — no mocks.
|
|
2620
|
+
#
|
|
2621
|
+
# Generated with src/listeners/#{slug}.rb by `tina4ruby generate listener
|
|
2622
|
+
# #{event}`. Confirms the listener binds on the REAL event bus and that
|
|
2623
|
+
# emitting the event reaches it. The reaction body is an AI-FILL stub that
|
|
2624
|
+
# raises until filled, so a strict emit re-raises here (proving it ran).
|
|
2625
|
+
require "spec_helper"
|
|
2626
|
+
|
|
2627
|
+
RSpec.describe "#{pascalize(slug)} listener (real event bus)" do
|
|
2628
|
+
before(:each) do
|
|
2629
|
+
Tina4::Events.clear
|
|
2630
|
+
load File.expand_path("../src/listeners/#{slug}.rb", __dir__)
|
|
2631
|
+
end
|
|
2632
|
+
|
|
2633
|
+
it "binds on the event bus" do
|
|
2634
|
+
expect(Tina4::Events.events).to include("#{event}")
|
|
2635
|
+
expect(Tina4::Events.listeners("#{event}").length).to be >= 1
|
|
2636
|
+
end
|
|
2637
|
+
|
|
2638
|
+
it "emitting reaches the listener (strict re-raises the stub)" do
|
|
2639
|
+
expect { Tina4::Events.emit("#{event}", { "id" => 1 }, strict: true) }.to raise_error(NotImplementedError)
|
|
2640
|
+
end
|
|
2641
|
+
end
|
|
2642
|
+
RUBY
|
|
2643
|
+
write_test(slug, content)
|
|
2644
|
+
end
|
|
2645
|
+
|
|
2646
|
+
# auth -> real register / login / me end-to-end via the real TestClient.
|
|
2647
|
+
# No dynamic names (always User + /api/auth/*), so a fully-literal
|
|
2648
|
+
# single-quoted heredoc keeps the emitted `#{token}` interpolation intact.
|
|
2649
|
+
def emit_auth_test
|
|
2650
|
+
content = <<~'RUBY'
|
|
2651
|
+
# frozen_string_literal: true
|
|
2652
|
+
#
|
|
2653
|
+
# Real auth spec — register / login / me via the real TestClient.
|
|
2654
|
+
#
|
|
2655
|
+
# Generated with the auth scaffold by `tina4ruby generate auth`. No mocks:
|
|
2656
|
+
# real Router, real Auth (PBKDF2 + JWT), real SQLite. register + login are
|
|
2657
|
+
# public (.no_auth); the token from login authenticates /api/auth/me.
|
|
2658
|
+
require "spec_helper"
|
|
2659
|
+
require "tmpdir"
|
|
2660
|
+
require_relative "../src/orm/user"
|
|
2661
|
+
|
|
2662
|
+
RSpec.describe "Auth (register / login / me via real TestClient)" do
|
|
2663
|
+
let(:client) { Tina4::TestClient.new }
|
|
2664
|
+
|
|
2665
|
+
before(:each) do
|
|
2666
|
+
@dir = Dir.mktmpdir("auth_spec")
|
|
2667
|
+
# Fresh RSA key material so get_token / valid_token agree.
|
|
2668
|
+
Tina4::Auth.instance_variable_set(:@private_key, nil)
|
|
2669
|
+
Tina4::Auth.instance_variable_set(:@public_key, nil)
|
|
2670
|
+
Tina4::Auth.instance_variable_set(:@keys_dir, nil)
|
|
2671
|
+
Tina4::Auth.setup(@dir)
|
|
2672
|
+
# A stray API key would authorise every request regardless of the JWT.
|
|
2673
|
+
@prior_api_key = ENV.delete("TINA4_API_KEY")
|
|
2674
|
+
Tina4.bind_database(Tina4::Database.new("sqlite:///" + File.join(@dir, "auth.db")))
|
|
2675
|
+
User.create_table
|
|
2676
|
+
# `load` (not require) re-registers the routes after spec_helper's
|
|
2677
|
+
# after(:each) Router.clear! wipes them between examples.
|
|
2678
|
+
load File.expand_path("../src/routes/auth.rb", __dir__)
|
|
2679
|
+
end
|
|
2680
|
+
|
|
2681
|
+
after(:each) do
|
|
2682
|
+
ENV["TINA4_API_KEY"] = @prior_api_key if @prior_api_key
|
|
2683
|
+
FileUtils.rm_rf(@dir)
|
|
2684
|
+
end
|
|
2685
|
+
|
|
2686
|
+
it "register, then duplicate 409, then login, then me with the token" do
|
|
2687
|
+
reg = client.post("/api/auth/register", json: { email: "a@b.c", password: "secret12" })
|
|
2688
|
+
expect(reg.status).to eq(201)
|
|
2689
|
+
|
|
2690
|
+
dup = client.post("/api/auth/register", json: { email: "a@b.c", password: "secret12" })
|
|
2691
|
+
expect(dup.status).to eq(409)
|
|
2692
|
+
|
|
2693
|
+
login = client.post("/api/auth/login", json: { email: "a@b.c", password: "secret12" })
|
|
2694
|
+
expect(login.status).to eq(200)
|
|
2695
|
+
token = login.json["token"]
|
|
2696
|
+
expect(token).not_to be_nil
|
|
2697
|
+
|
|
2698
|
+
me = client.get("/api/auth/me", headers: { "Authorization" => "Bearer #{token}" })
|
|
2699
|
+
expect(me.status).to eq(200)
|
|
2700
|
+
expect(me.json["email"]).to eq("a@b.c")
|
|
2701
|
+
end
|
|
2702
|
+
|
|
2703
|
+
it "login with the wrong password is 401" do
|
|
2704
|
+
client.post("/api/auth/register", json: { email: "x@y.z", password: "secret12" })
|
|
2705
|
+
bad = client.post("/api/auth/login", json: { email: "x@y.z", password: "WRONG" })
|
|
2706
|
+
expect(bad.status).to eq(401)
|
|
2707
|
+
end
|
|
2708
|
+
|
|
2709
|
+
it "me without a token is 401" do
|
|
2710
|
+
expect(client.get("/api/auth/me").status).to eq(401)
|
|
2711
|
+
end
|
|
2712
|
+
end
|
|
2713
|
+
RUBY
|
|
2714
|
+
write_test("auth", content)
|
|
2715
|
+
end
|
|
2716
|
+
|
|
2717
|
+
# migration (create_*) -> apply UP then DOWN against real SQLite via the real
|
|
2718
|
+
# Migration runner, asserting the table appears then disappears. Only for
|
|
2719
|
+
# CREATE migrations — a placeholder ALTER migration has no real SQL yet.
|
|
2720
|
+
def emit_migration_test(migration_name, table, _up_file, _down_file)
|
|
2721
|
+
content = <<~RUBY
|
|
2722
|
+
# frozen_string_literal: true
|
|
2723
|
+
#
|
|
2724
|
+
# Real migration spec for #{migration_name} — no mocks, real SQLite.
|
|
2725
|
+
#
|
|
2726
|
+
# Generated with the migration by `tina4ruby generate migration
|
|
2727
|
+
# #{migration_name}`. Runs the migration through the REAL Tina4::Migration
|
|
2728
|
+
# runner against a fresh real SQLite database, asserts the table exists,
|
|
2729
|
+
# then rolls back and asserts it is gone — the raw UP/DOWN SQL the runner
|
|
2730
|
+
# executes.
|
|
2731
|
+
require "spec_helper"
|
|
2732
|
+
require "tmpdir"
|
|
2733
|
+
|
|
2734
|
+
RSpec.describe "#{pascalize(table)} migration (real SQLite up/down)" do
|
|
2735
|
+
it "UP creates the table and DOWN drops it" do
|
|
2736
|
+
dir = Dir.mktmpdir("#{table}_migration")
|
|
2737
|
+
db = Tina4::Database.new("sqlite:///" + File.join(dir, "mig.db"))
|
|
2738
|
+
migrations_dir = File.expand_path("../migrations", __dir__)
|
|
2739
|
+
migration = Tina4::Migration.new(db, migrations_dir: migrations_dir)
|
|
2740
|
+
|
|
2741
|
+
migration.run
|
|
2742
|
+
expect(db.table_exists?("#{table}")).to be true
|
|
2743
|
+
|
|
2744
|
+
migration.rollback(1)
|
|
2745
|
+
expect(db.table_exists?("#{table}")).to be false
|
|
2746
|
+
|
|
2747
|
+
db.close
|
|
2748
|
+
FileUtils.rm_rf(dir)
|
|
2749
|
+
end
|
|
2750
|
+
end
|
|
2751
|
+
RUBY
|
|
2752
|
+
write_test("#{table}_migration", content)
|
|
1868
2753
|
end
|
|
1869
2754
|
|
|
1870
2755
|
# ── help ──────────────────────────────────────────────────────────────
|
|
1871
2756
|
|
|
1872
|
-
|
|
1873
|
-
|
|
1874
|
-
|
|
1875
|
-
|
|
1876
|
-
|
|
1877
|
-
|
|
1878
|
-
|
|
1879
|
-
|
|
1880
|
-
|
|
1881
|
-
|
|
1882
|
-
|
|
1883
|
-
|
|
1884
|
-
|
|
1885
|
-
|
|
1886
|
-
|
|
1887
|
-
|
|
1888
|
-
|
|
1889
|
-
|
|
1890
|
-
|
|
1891
|
-
|
|
1892
|
-
|
|
1893
|
-
|
|
1894
|
-
|
|
1895
|
-
|
|
1896
|
-
|
|
1897
|
-
|
|
1898
|
-
|
|
1899
|
-
|
|
1900
|
-
|
|
1901
|
-
|
|
1902
|
-
|
|
1903
|
-
|
|
1904
|
-
|
|
1905
|
-
|
|
1906
|
-
|
|
1907
|
-
|
|
1908
|
-
|
|
1909
|
-
|
|
1910
|
-
|
|
1911
|
-
|
|
1912
|
-
|
|
1913
|
-
|
|
1914
|
-
|
|
1915
|
-
|
|
1916
|
-
|
|
1917
|
-
|
|
1918
|
-
|
|
1919
|
-
|
|
1920
|
-
|
|
1921
|
-
|
|
1922
|
-
|
|
1923
|
-
|
|
1924
|
-
|
|
1925
|
-
|
|
1926
|
-
|
|
1927
|
-
|
|
1928
|
-
|
|
1929
|
-
|
|
1930
|
-
|
|
1931
|
-
|
|
2757
|
+
# Print the human-readable command reference.
|
|
2758
|
+
#
|
|
2759
|
+
# Generated from the COMMANDS and GENERATORS registries — the SAME single
|
|
2760
|
+
# source of truth that drives dispatch (#run / #cmd_generate) and the
|
|
2761
|
+
# `commands --json` manifest — so the help text can never drift from what
|
|
2762
|
+
# the CLI actually does.
|
|
2763
|
+
def cmd_help(_argv = nil)
|
|
2764
|
+
command_rows = COMMANDS.map do |name, spec|
|
|
2765
|
+
["#{name} #{spec[:usage]}".rstrip, spec[:summary]]
|
|
2766
|
+
end
|
|
2767
|
+
generator_rows = GENERATORS.map do |name, spec|
|
|
2768
|
+
["generate #{name} #{spec[:usage]}".rstrip, spec[:summary]]
|
|
2769
|
+
end
|
|
2770
|
+
# Align summaries in a column; a left cell longer than the cap overflows
|
|
2771
|
+
# cleanly (2-space gap) rather than pushing every other summary out.
|
|
2772
|
+
pad = [46, (command_rows + generator_rows).map { |left, _| left.length }.max].min
|
|
2773
|
+
|
|
2774
|
+
row = lambda do |left, summary|
|
|
2775
|
+
gap = left.length <= pad ? pad : left.length
|
|
2776
|
+
" #{left.ljust(gap)} #{summary}"
|
|
2777
|
+
end
|
|
2778
|
+
|
|
2779
|
+
lines = ["Tina4 Ruby CLI", "", "Usage: tina4ruby COMMAND [options]", "", "Commands:"]
|
|
2780
|
+
lines += command_rows.map { |left, summary| row.call(left, summary) }
|
|
2781
|
+
lines += ["", "Generators:"]
|
|
2782
|
+
lines += generator_rows.map { |left, summary| row.call(left, summary) }
|
|
2783
|
+
lines += [
|
|
2784
|
+
"",
|
|
2785
|
+
"Scaffolding-first: logic-shaped generators (route without --model, service,",
|
|
2786
|
+
"queue, validator, seeder, websocket, listener) emit wiring + an AI-FILL",
|
|
2787
|
+
"placeholder (raise NotImplementedError) where the custom logic goes; CRUD-",
|
|
2788
|
+
"shaped ones emit working code. Writes are secure by default; use --public",
|
|
2789
|
+
"to open them.",
|
|
2790
|
+
"",
|
|
2791
|
+
"Metrics:",
|
|
2792
|
+
" metrics [--top N] [--json] [--fail-on warn|error] [--path DIR]",
|
|
2793
|
+
" --top N Show only the worst N offenders (default: 20)",
|
|
2794
|
+
" --json Print machine-readable JSON ({summary, offenders}) for CI",
|
|
2795
|
+
" --fail-on Exit 1 if any offender at/above this severity (warn|error)",
|
|
2796
|
+
" --path DIR Scan DIR (default: src/, auto-resolves to the framework)",
|
|
2797
|
+
"",
|
|
2798
|
+
"Field types: string, int, float, bool, text, datetime, blob",
|
|
2799
|
+
"Table names: singular by default (Product -> product)",
|
|
2800
|
+
"",
|
|
2801
|
+
"https://tina4.com",
|
|
2802
|
+
"",
|
|
2803
|
+
"Run 'tina4ruby COMMAND --help' for more information on a command.",
|
|
2804
|
+
]
|
|
2805
|
+
puts lines.join("\n")
|
|
2806
|
+
end
|
|
2807
|
+
|
|
2808
|
+
# ── commands (self-describing manifest) ─────────────────────────────────
|
|
2809
|
+
|
|
2810
|
+
# Build the machine-readable manifest of the CLI's command surface.
|
|
2811
|
+
#
|
|
2812
|
+
# Pure data: reads the COMMANDS registry and the framework version — no
|
|
2813
|
+
# bootstrap, no database, no migrations, no app imports. This is exactly
|
|
2814
|
+
# what `commands --json` serializes and what the tina4 client consumes to
|
|
2815
|
+
# discover which commands this framework supports.
|
|
2816
|
+
#
|
|
2817
|
+
# Shape:
|
|
2818
|
+
# { "framework" => "ruby", "version" => "<x.y.z>",
|
|
2819
|
+
# "commands" => [ { "name", "summary", "args"?, "subcommands"? }, ... ] }
|
|
2820
|
+
def commands_manifest
|
|
2821
|
+
require_relative "version"
|
|
2822
|
+
commands = COMMANDS.map do |name, spec|
|
|
2823
|
+
entry = { "name" => name, "summary" => spec[:summary] }
|
|
2824
|
+
entry["args"] = spec[:args].dup if spec[:args]
|
|
2825
|
+
entry["subcommands"] = spec[:subcommands].dup if spec[:subcommands]
|
|
2826
|
+
entry
|
|
2827
|
+
end
|
|
2828
|
+
{ "framework" => "ruby", "version" => Tina4::VERSION, "commands" => commands }
|
|
2829
|
+
end
|
|
2830
|
+
|
|
2831
|
+
# Emit the CLI's own command surface — the self-describing manifest.
|
|
2832
|
+
#
|
|
2833
|
+
# tina4ruby commands human-readable list
|
|
2834
|
+
# tina4ruby commands --json machine-readable manifest (for the tina4 client)
|
|
2835
|
+
#
|
|
2836
|
+
# CHEAP + side-effect-free by contract: it only prints the static COMMANDS
|
|
2837
|
+
# registry plus the framework version. It MUST NOT bootstrap the framework,
|
|
2838
|
+
# open a database, run migrations, or load app modules — the tina4 client
|
|
2839
|
+
# calls this on `tina4 --help`, in any directory, so it must be instant and
|
|
2840
|
+
# safe to run anywhere.
|
|
2841
|
+
def cmd_commands(argv = nil)
|
|
2842
|
+
require "json"
|
|
2843
|
+
argv = argv || []
|
|
2844
|
+
manifest = commands_manifest
|
|
2845
|
+
|
|
2846
|
+
if argv.include?("--json")
|
|
2847
|
+
puts JSON.pretty_generate(manifest)
|
|
2848
|
+
return
|
|
2849
|
+
end
|
|
2850
|
+
|
|
2851
|
+
puts "\nTina4 #{manifest['framework']} - #{manifest['version']}\n\n"
|
|
2852
|
+
width = manifest["commands"].map { |command| command["name"].length }.max
|
|
2853
|
+
manifest["commands"].each do |command|
|
|
2854
|
+
puts " #{command['name'].ljust(width)} #{command['summary']}"
|
|
2855
|
+
if command["subcommands"]
|
|
2856
|
+
puts " #{''.ljust(width)} #{command['subcommands'].join(', ')}"
|
|
2857
|
+
end
|
|
2858
|
+
end
|
|
2859
|
+
puts
|
|
1932
2860
|
end
|
|
1933
2861
|
|
|
1934
2862
|
# ── config resolution ──────────────────────────────────────────────────
|