tina4ruby 3.13.147 → 3.13.149
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/CHANGELOG.md +24 -0
- data/lib/tina4/auto_crud.rb +49 -0
- data/lib/tina4/cli.rb +79 -20
- data/lib/tina4/crud.rb +411 -659
- data/lib/tina4/dev_admin.rb +53 -1
- data/lib/tina4/dispatch_pipeline.rb +17 -2
- data/lib/tina4/messenger.rb +52 -2
- data/lib/tina4/migration.rb +132 -16
- data/lib/tina4/session.rb +12 -1
- data/lib/tina4/templates/crud/form.twig +26 -0
- data/lib/tina4/templates/crud/modals.twig +31 -0
- data/lib/tina4/templates/crud/page.twig +401 -0
- data/lib/tina4/templates/crud/table.twig +69 -0
- data/lib/tina4/version.rb +1 -1
- metadata +6 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: '08653f37ab3b3baf01b6103f97ec3893f910fbaf5a50b4b34a5521c872e9a225'
|
|
4
|
+
data.tar.gz: 2df60c055ca63a7957f79da033add45a12e3418f1e5890a3a986c254a8f4f6ec
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: dafb902ac5f0b385862acf78fbc1ded074233082a98d7dac462f095fb82fc4043a0ff5369eb9a7dbcd0c4662daab0c00b34a1c1647bcb6f29332a7d4882bbc30
|
|
7
|
+
data.tar.gz: 2408c4a0f724372a555efb9a39d3dd30b4334476109e257e4eb301d38db666f27f932bcd6b278c0087e3080f109042924236b550c6fafb6d8763b0db9e9c505d
|
data/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,30 @@ 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.149 — 2026-10-10
|
|
10
|
+
|
|
11
|
+
### CRUD
|
|
12
|
+
- A CRUD admin page out of the box: `Tina4::Crud.to_crud(request, model: Model)` renders a
|
|
13
|
+
searchable, sortable, paginated admin screen and registers the model's AutoCrud routes (ADR-0094).
|
|
14
|
+
|
|
15
|
+
### Migrations
|
|
16
|
+
- Startup auto-migration holds a cross-process lock, so concurrent boots apply each migration
|
|
17
|
+
exactly once; the SQLite/Firebird lock file now lives in the system temp dir (#277).
|
|
18
|
+
|
|
19
|
+
### Dev server
|
|
20
|
+
- The dev dashboard and toolbar are reachable from a Docker dev box via `TINA4_DEV_ALLOWED_PEERS`;
|
|
21
|
+
a viewer the gate refuses gets no toolbar (#279).
|
|
22
|
+
- The database layer accepts the `pgsql://` scheme as an alias of postgres (#280).
|
|
23
|
+
- A configurable SMTP timeout is reported as a timeout instead of hanging the request (#278).
|
|
24
|
+
|
|
25
|
+
## 3.13.148 — 2026-10-05
|
|
26
|
+
|
|
27
|
+
### Session
|
|
28
|
+
- An anonymous request stores no session — a request that never reads or writes session state no longer emits a `Set-Cookie` or persists an empty session record (cross-framework fix, at parity across Python, PHP, Ruby, Node.js).
|
|
29
|
+
|
|
30
|
+
### AI skills
|
|
31
|
+
- `tina4-developer-ruby` skill `updated_for_version` bumped to this release.
|
|
32
|
+
|
|
9
33
|
## 3.13.147 — 2026-10-05
|
|
10
34
|
|
|
11
35
|
### Dev MCP tools (fixes #271)
|
data/lib/tina4/auto_crud.rb
CHANGED
|
@@ -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]',
|
|
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
|
-
#
|
|
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
|
|
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.
|
|
2171
|
-
#
|
|
2172
|
-
|
|
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
|
-
#
|
|
2179
|
-
|
|
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
|
-
#
|
|
2182
|
-
|
|
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
|
|
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 ─────────────────────────────────────────────
|