tina4ruby 3.13.148 → 3.13.150

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 5f81aa317ab408c5f85fa182149d2bddf74f76a6dbe0d07eab1346335f2feb18
4
- data.tar.gz: a877a9d71c42570d719c721427d164581919c15e235e17d366891a5337a7920c
3
+ metadata.gz: e63d80b48d2821411c3174e3307176cdf996bfc722c2c228be2b829df8a898c3
4
+ data.tar.gz: 4323b78fdba05283005597a4c8489d29a8b74cb02754bc9ec7b4c88f361d0cbc
5
5
  SHA512:
6
- metadata.gz: 4da03de83c10544ac690ce24d6b951b6ba688fab57fb01cb4cf5a630d1e1d41dd5fbab7397538a2910708409c3fb6fea74285ce84fd74559fb883d83303b66d6
7
- data.tar.gz: cb21b614d729b774a1d9ec16ada8674cab6663e675a370ed7cb8b3f588b3c038036ba0d43bedcf764f1ab673b117df005e30063d50612ce8ace479300104d127
6
+ metadata.gz: 68483df7f4ae92df44d121678397b0c6a4884277e88c7321fc0ed57ba36e34ae26bb8bca3f148eed1904127907d2a0c20369ca729341131ebea409a3e47b799b
7
+ data.tar.gz: aa95f3c686a89537e480bdc2d49de82a1d8cec58dfe0e1c24398f8f19ea3552ba9ec2eac8a351ef859971400ed0756afe2e8421bcd4461ee4c02ffce21e6f715
data/CHANGELOG.md CHANGED
@@ -6,6 +6,32 @@ number means the same thing everywhere.
6
6
  **The authoritative release notes for every shipped version live in the documentation:**
7
7
  https://tina4.com/ruby/36-releases
8
8
 
9
+ ## 3.13.150 — 2026-10-11
10
+
11
+ ### Dev admin
12
+ - The dashboard AI talks to the `tina4 ai serve` gateway on :11435, not a model
13
+ directly; `/__dev/split` serves the shared split-preview asset; the scaffold
14
+ panel is data-driven from the CLI generators; and the Code Landscape metrics
15
+ bubbles are free to spread (zoom-out + Fit frame the layout, was crammed into
16
+ the viewport). The dashboard metrics scan passes `--no-history` so it stops
17
+ self-reloading. Shipped in the shared SPA bundle.
18
+
19
+ ## 3.13.149 — 2026-10-10
20
+
21
+ ### CRUD
22
+ - A CRUD admin page out of the box: `Tina4::Crud.to_crud(request, model: Model)` renders a
23
+ searchable, sortable, paginated admin screen and registers the model's AutoCrud routes (ADR-0094).
24
+
25
+ ### Migrations
26
+ - Startup auto-migration holds a cross-process lock, so concurrent boots apply each migration
27
+ exactly once; the SQLite/Firebird lock file now lives in the system temp dir (#277).
28
+
29
+ ### Dev server
30
+ - The dev dashboard and toolbar are reachable from a Docker dev box via `TINA4_DEV_ALLOWED_PEERS`;
31
+ a viewer the gate refuses gets no toolbar (#279).
32
+ - The database layer accepts the `pgsql://` scheme as an alias of postgres (#280).
33
+ - A configurable SMTP timeout is reported as a timeout instead of hanging the request (#278).
34
+
9
35
  ## 3.13.148 — 2026-10-05
10
36
 
11
37
  ### Session
@@ -149,9 +149,21 @@ module Tina4
149
149
  end
150
150
  next res.error("UNKNOWN_FIELD", "Unknown filter field '#{unknown_filter_field}'", 400) if unknown_filter_field
151
151
 
152
+ # ADR-0094: ?search=term full-text filters the list — a
153
+ # case-insensitive LIKE %term% OR'd across the model's declared
154
+ # string/text columns, added to the WHERE before limit/offset so the
155
+ # envelope's total reflects the filtered set. A model with no
156
+ # string/text column simply matches nothing; it never errors. This is
157
+ # what the CRUD admin grid (and any client) uses to search.
158
+ apply_search_clause(model_class, req.query["search"], filter_conditions, filter_values)
159
+
152
160
  order_by, unknown_sort_field = parse_sort(model_class, req.query["sort"])
153
161
  next res.error("UNKNOWN_FIELD", "Unknown sort field '#{unknown_sort_field}'", 400) if unknown_sort_field
154
162
 
163
+ # ADR-0094: honour an explicit ?sort_dir=asc|desc for a single bare
164
+ # ?sort=column (the CRUD grid's spelling). See #apply_sort_direction.
165
+ order_by = apply_sort_direction(order_by, req.query["sort"], req.query["sort_dir"])
166
+
155
167
  if filter_conditions.empty?
156
168
  records = model_class.all(limit: limit, offset: offset, order_by: order_by)
157
169
  total = model_class.count
@@ -370,6 +382,43 @@ module Tina4
370
382
  id.nil? ? nil : model_class.find_by_id(id)
371
383
  end
372
384
 
385
+ # ADR-0094: append the ?search=term clause — a case-insensitive LIKE
386
+ # %term% OR'd across the model's declared string/text columns — onto the
387
+ # WHERE being built (conditions + bound values), before limit/offset so
388
+ # the envelope's total reflects the filtered set. A blank term, or a
389
+ # model with no string/text column, adds nothing (matches nothing, never
390
+ # errors). Mutates the two arrays in place and returns nil.
391
+ def apply_search_clause(model_class, raw_term, conditions, values)
392
+ term = raw_term.to_s.strip
393
+ return if term.empty?
394
+
395
+ columns = model_class.field_definitions
396
+ .select { |_name, opts| %i[string text].include?(opts[:type]) }
397
+ .keys
398
+ .map { |attr| model_class.resolve_field_column(attr) || attr.to_s }
399
+ return if columns.empty?
400
+
401
+ conditions << "(#{columns.map { |col| "#{col} LIKE ?" }.join(' OR ')})"
402
+ columns.each { values << "%#{term}%" }
403
+ nil
404
+ end
405
+
406
+ # ADR-0094: honour an explicit ?sort_dir=asc|desc for a single bare
407
+ # ?sort=column (the CRUD grid's spelling). The Mongo-style "-field,field"
408
+ # sort keeps its own inline direction and ignores sort_dir. An unknown
409
+ # sort column is already a 400 (ADR-0069); the CRUD grid only ever emits
410
+ # real model columns, so it never trips that — this just adds the
411
+ # direction toggle its headers need. Returns order_by with its trailing
412
+ # direction replaced, or unchanged when sort_dir does not apply.
413
+ def apply_sort_direction(order_by, sort_param, sort_dir)
414
+ param = sort_param.to_s.strip
415
+ return order_by if order_by.nil? || sort_dir.nil? || param.empty? ||
416
+ param.include?(",") || param.start_with?("-")
417
+
418
+ direction = sort_dir.to_s.downcase == "desc" ? "DESC" : "ASC"
419
+ order_by.sub(/\s+(?:ASC|DESC)\s*\z/i, " #{direction}")
420
+ end
421
+
373
422
  # Parse the sort parameter: "-name,created_at" => "name DESC, created_at ASC".
374
423
  #
375
424
  # ADR-0069: each part (empty parts skipped, a leading "-" means DESC) must
data/lib/tina4/cli.rb CHANGED
@@ -39,7 +39,7 @@ module Tina4
39
39
  GENERATORS = {
40
40
  "model" => { handler: :generate_model, usage: '<Name> [--fields "name:string,price:float"] [--table-name <name>]', summary: "ORM model + matching migration" },
41
41
  "route" => { handler: :generate_route, usage: "<name> [--model Name] [--public]", summary: "CRUD route file, secure by default (--public opens writes)" },
42
- "crud" => { handler: :generate_crud, usage: '<Name> [--fields "..."] [--public]', summary: "Model + migration + routes + form + view + test" },
42
+ "crud" => { handler: :generate_crud, usage: '<Name> [--fields "..."] [--public] [--no-templates]', summary: "Model + migration + AutoCrud-backed admin page (to_crud) + editable crud/ templates + gate test" },
43
43
  "migration" => { handler: :generate_migration, usage: "<description>", summary: "Timestamped migration file (UP/DOWN)" },
44
44
  "middleware" => { handler: :generate_middleware, usage: "<Name>", summary: "Middleware with before/after hooks" },
45
45
  "test" => { handler: :generate_test, usage: "<name> [--model Name]", summary: "RSpec test file" },
@@ -2152,38 +2152,97 @@ module Tina4
2152
2152
  # ── Generator: crud ──────────────────────────────────────────────────
2153
2153
 
2154
2154
  def generate_crud(name, flags)
2155
- # Quiet here — the generate_model sub-call below announces once (#123).
2155
+ # ADR-0094: `generate crud` scaffolds an admin PAGE rendered by
2156
+ # Tina4::Crud.to_crud, whose REST backend is delegated entirely to
2157
+ # AutoCrud (no hand-written list/detail/write routes). The generated page
2158
+ # route registers AutoCrud for the model (secure-by-default; --public opens
2159
+ # writes) and renders to_crud; the overridable crud/*.twig templates are
2160
+ # copied into the app so the developer can edit them immediately.
2161
+ #
2162
+ # The table name (singular, e.g. "widget") is the canonical path: the model
2163
+ # declares table_name "<table>", AutoCrud serves /api/<table>, and the gate
2164
+ # test + page route all key off it.
2156
2165
  table = resolve_table(name, flags)
2157
- # Always derive the plural route from the CLASS NAME, not by appending
2158
- # "s" to the table — otherwise a reserved-word table (already plural,
2159
- # Order -> orders) becomes orderss, and a `y`-ending class (Category)
2160
- # becomes categorys instead of categories.
2161
- route_name = to_route_name(name)
2162
2166
  is_public = flags["public"] ? true : false
2163
2167
 
2164
2168
  puts "\n Generating CRUD for #{name}...\n"
2165
2169
 
2166
2170
  # 1. Model + migration (emit_test: false — crud emits its own broader gate
2167
- # spec at step 5, so the sub-generators stay quiet to avoid double-emit).
2171
+ # spec at step 4, so the sub-generators stay quiet to avoid double-emit).
2168
2172
  generate_model(name, flags, emit_test: false)
2169
2173
 
2170
- # 2. Routes with model — secure-by-default; thread --public through so
2171
- # `generate crud X --public` opens the writes (mirrors AutoCrud public:).
2172
- # emit_test: false — the crud gate spec at step 5 targets the same file.
2173
- generate_route(route_name, { "model" => name, "public" => is_public }, emit_test: false)
2174
-
2175
- # 3. Form
2176
- generate_form(name, flags)
2174
+ # 2. Admin page route: registers AutoCrud (secure-by-default; --public
2175
+ # opens writes) and renders the to_crud admin UI at /admin/<table>.
2176
+ generate_crud_admin_route(name, table, is_public)
2177
2177
 
2178
- # 4. View (list + detail)
2179
- generate_view(name, flags)
2178
+ # 3. Copy the overridable crud/*.twig templates into the app (unless
2179
+ # --no-templates) so the developer owns an editable copy straight away.
2180
+ copy_crud_templates unless flags["no-templates"]
2180
2181
 
2181
- # 5. Test — secure-by-default gate test (behavioural, real TestClient).
2182
- generate_test(route_name, { "model" => name, "secure_writes" => true, "public" => is_public })
2182
+ # 4. Test — secure-by-default gate test (behavioural, real TestClient).
2183
+ # Keyed off the table, matching the AutoCrud routes + the route file.
2184
+ generate_test(table, { "model" => name, "secure_writes" => true, "public" => is_public })
2183
2185
 
2184
2186
  puts "\n CRUD generation complete for #{name}."
2185
2187
  puts " Run: tina4ruby migrate"
2186
- puts " Visit: /swagger to see the API docs"
2188
+ puts " Visit: /admin/#{table} for the admin UI, or /swagger for the API docs"
2189
+ end
2190
+
2191
+ # Write the admin page route (src/routes/<table>.rb). It wires the AutoCrud
2192
+ # REST backend for the model and renders the to_crud admin page — ADR-0094:
2193
+ # to_crud owns no routes of its own, so the backend is AutoCrud and only the
2194
+ # GET page lives here. Secure-by-default; `is_public` opens the writes.
2195
+ def generate_crud_admin_route(model, table, is_public)
2196
+ model_snake = to_snake_case(model)
2197
+ dir = "src/routes"
2198
+ FileUtils.mkdir_p(dir)
2199
+ path = File.join(dir, "#{table}.rb")
2200
+ if File.exist?(path)
2201
+ puts " File already exists: #{path}"
2202
+ return
2203
+ end
2204
+
2205
+ page_verb = is_public ? "get" : "secure_get"
2206
+ write_doc = is_public ? "--public: the admin page and the writes are OPEN (no token)." : "Secure by default: the admin page AND the writes require a valid Bearer token (pass --public to open them)."
2207
+ page_doc = is_public ? "" : "\n # NOTE: the admin page is secured (secure_get). A browser needs a valid\n # token/session to open it; wire your login (Tina4::Auth / Session) or run\n # `generate crud #{model} --public` for an open page. The AutoCrud READ\n # API (GET #{table}, GET #{table}/{id}) stays public by AutoCrud's default."
2208
+ content = <<~RUBY
2209
+ require_relative "../orm/#{model_snake}"
2210
+
2211
+ # #{model} admin — one server-rendered CRUD page (searchable, sortable,
2212
+ # paginated table + create/edit/delete modals). The REST backend (GET
2213
+ # list, GET/{id}, POST, PUT, DELETE) is AutoCrud; this file owns only the
2214
+ # GET admin page. #{write_doc}#{page_doc}
2215
+ #
2216
+ # Restyle the UI by editing templates/crud/*.twig (copied into this app).
2217
+ Tina4::AutoCrud.register(#{model}, public: #{is_public})
2218
+ Tina4::AutoCrud.generate_routes
2219
+
2220
+ Tina4.#{page_verb} "/admin/#{table}" do |request, response|
2221
+ response.html(Tina4::Crud.to_crud(request, model: #{model}, title: "#{model} Admin"))
2222
+ end
2223
+ RUBY
2224
+
2225
+ File.write(path, content)
2226
+ puts " Created #{path}"
2227
+ end
2228
+
2229
+ # Copy the framework's overridable crud/*.twig templates into the app's
2230
+ # src/templates/crud/ so the developer can edit them in place (an app copy
2231
+ # wins over the gem's via Tina4::Template's app-first resolution). Existing
2232
+ # files are left untouched.
2233
+ def copy_crud_templates
2234
+ source_dir = File.join(__dir__, "templates", "crud")
2235
+ target_dir = File.join("src", "templates", "crud")
2236
+ FileUtils.mkdir_p(target_dir)
2237
+ Dir.glob(File.join(source_dir, "*.twig")).sort.each do |src|
2238
+ dest = File.join(target_dir, File.basename(src))
2239
+ if File.exist?(dest)
2240
+ puts " File already exists: #{dest}"
2241
+ else
2242
+ FileUtils.cp(src, dest)
2243
+ puts " Created #{dest}"
2244
+ end
2245
+ end
2187
2246
  end
2188
2247
 
2189
2248
  # ── Generator: migration ─────────────────────────────────────────────