inline_forms_schema_edit 8.1.42
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 +7 -0
- data/CHANGELOG.md +53 -0
- data/app/controllers/inline_forms/schema_controller.rb +228 -0
- data/app/models/inline_forms/schema_batch.rb +116 -0
- data/app/models/inline_forms/schema_intent_record.rb +80 -0
- data/app/views/inline_forms/schema/create.html.erb +57 -0
- data/app/views/inline_forms/schema/index.html.erb +63 -0
- data/app/views/inline_forms/schema/new.html.erb +49 -0
- data/app/views/inline_forms/schema/preview.html.erb +73 -0
- data/app/views/layouts/inline_forms_schema.html.erb +33 -0
- data/doc/schema-apply-workflow.yml.example +89 -0
- data/lib/generators/inline_forms_schema_edit/install_generator.rb +40 -0
- data/lib/generators/inline_forms_schema_edit/templates/create_schema_edit_tables.rb.erb +30 -0
- data/lib/inline_forms_schema_edit/batch_export.rb +41 -0
- data/lib/inline_forms_schema_edit/batch_import.rb +105 -0
- data/lib/inline_forms_schema_edit/engine.rb +13 -0
- data/lib/inline_forms_schema_edit/intent_validator.rb +71 -0
- data/lib/inline_forms_schema_edit/version.rb +7 -0
- data/lib/inline_forms_schema_edit.rb +69 -0
- data/lib/tasks/inline_forms_schema_edit.rake +83 -0
- metadata +75 -0
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
<h2>Preview: add <code><%= @intent.attribute %>:<%= @intent.form_element %></code> to <code><%= @intent.model_name %></code></h2>
|
|
2
|
+
|
|
3
|
+
<% if @is_header %>
|
|
4
|
+
<p class="notice">Section <strong>header</strong> — no column, no migration. It appears after a reload.</p>
|
|
5
|
+
<% end %>
|
|
6
|
+
|
|
7
|
+
<h2>Resulting attribute order</h2>
|
|
8
|
+
<table class="schema_list">
|
|
9
|
+
<thead><tr><th>Attribute</th><th>Form element</th></tr></thead>
|
|
10
|
+
<tbody>
|
|
11
|
+
<% @attribute_list.each do |attribute, form_element| %>
|
|
12
|
+
<tr class="<%= "new_row" if attribute.to_sym == @intent.attribute %>">
|
|
13
|
+
<td><%= attribute %><%= " (new)" if attribute.to_sym == @intent.attribute %></td>
|
|
14
|
+
<td><%= form_element %></td>
|
|
15
|
+
</tr>
|
|
16
|
+
<% end %>
|
|
17
|
+
</tbody>
|
|
18
|
+
</table>
|
|
19
|
+
|
|
20
|
+
<h2>The new <%= @is_header ? "header" : "field" %></h2>
|
|
21
|
+
<% if @is_header %>
|
|
22
|
+
<div class="row form_element_header">
|
|
23
|
+
<div class="large-12 column">
|
|
24
|
+
<%= @intent_params[:label].presence || @intent.model_class.human_attribute_name(@intent.attribute) %>
|
|
25
|
+
</div>
|
|
26
|
+
</div>
|
|
27
|
+
<% else %>
|
|
28
|
+
<p class="muted">Rendered with the live <code><%= @intent.form_element %></code> widget (no data yet):</p>
|
|
29
|
+
<div class="schema_field_preview">
|
|
30
|
+
<%= send("#{@intent.form_element}_edit", @preview, @intent.attribute) %>
|
|
31
|
+
</div>
|
|
32
|
+
<% if InlineForms::VALUE_BEARING_FORM_ELEMENTS.include?(@intent.form_element) %>
|
|
33
|
+
<p class="notice">
|
|
34
|
+
This is a choice element: a placeholder values hash
|
|
35
|
+
<code>{ 1 => 'one', 2 => 'two' }</code> is used. After applying, edit
|
|
36
|
+
the row in the model to your real values.
|
|
37
|
+
</p>
|
|
38
|
+
<% end %>
|
|
39
|
+
<% end %>
|
|
40
|
+
|
|
41
|
+
<% if @intent_params[:label].present? %>
|
|
42
|
+
<p class="muted">Label <code><%= @intent_params[:label] %></code> will be written for locale
|
|
43
|
+
<code><%= @intent_params[:locale] %></code>.</p>
|
|
44
|
+
<% end %>
|
|
45
|
+
|
|
46
|
+
<div class="actions">
|
|
47
|
+
<% unless Rails.env.production? %>
|
|
48
|
+
<%= form_tag inline_forms_schema_path, method: :post, data: { turbo: false } do %>
|
|
49
|
+
<input type="hidden" name="model_name" value="<%= @intent.model_name %>" />
|
|
50
|
+
<input type="hidden" name="attribute" value="<%= @intent.attribute %>" />
|
|
51
|
+
<input type="hidden" name="form_element" value="<%= @intent.form_element %>" />
|
|
52
|
+
<input type="hidden" name="after" value="<%= @intent.after %>" />
|
|
53
|
+
<input type="hidden" name="label" value="<%= @intent_params[:label] %>" />
|
|
54
|
+
<input type="hidden" name="locale" value="<%= @intent_params[:locale] %>" />
|
|
55
|
+
<%= submit_tag(@is_header ? "Apply (add header, no migration)" : "Apply (generate migration, no db:migrate)") %>
|
|
56
|
+
<% end %>
|
|
57
|
+
<% end %>
|
|
58
|
+
|
|
59
|
+
<% if @batching_available %>
|
|
60
|
+
<%= form_tag inline_forms_schema_draft_path, method: :post, data: { turbo: false } do %>
|
|
61
|
+
<input type="hidden" name="model_name" value="<%= @intent.model_name %>" />
|
|
62
|
+
<input type="hidden" name="attribute" value="<%= @intent.attribute %>" />
|
|
63
|
+
<input type="hidden" name="form_element" value="<%= @intent.form_element %>" />
|
|
64
|
+
<input type="hidden" name="after" value="<%= @intent.after %>" />
|
|
65
|
+
<input type="hidden" name="label" value="<%= @intent_params[:label] %>" />
|
|
66
|
+
<input type="hidden" name="locale" value="<%= @intent_params[:locale] %>" />
|
|
67
|
+
<%= submit_tag "Add to batch (draft — applies via the pipeline)" %>
|
|
68
|
+
<% end %>
|
|
69
|
+
<% end %>
|
|
70
|
+
|
|
71
|
+
<p><%= link_to "Back to edit", inline_forms_schema_new_path %>
|
|
72
|
+
· <%= link_to "Batches", inline_forms_schema_index_path %></p>
|
|
73
|
+
</div>
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="<%= I18n.locale %>">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
6
|
+
<title>inline_forms schema — add a field</title>
|
|
7
|
+
<%= csrf_meta_tags %>
|
|
8
|
+
<style>
|
|
9
|
+
body { font-family: system-ui, sans-serif; margin: 2rem auto; max-width: 48rem; padding: 0 1rem; color: #222; }
|
|
10
|
+
h1 { font-size: 1.4rem; } h2 { font-size: 1.1rem; margin-top: 1.5rem; }
|
|
11
|
+
label { display: block; margin: 0.75rem 0 0.25rem; font-weight: 600; }
|
|
12
|
+
input[type=text], select { width: 100%; padding: 0.4rem; box-sizing: border-box; }
|
|
13
|
+
.actions { margin-top: 1.25rem; }
|
|
14
|
+
button, input[type=submit] { padding: 0.5rem 1rem; cursor: pointer; }
|
|
15
|
+
.error { background: #fdecec; border: 1px solid #e0aaaa; color: #a00; padding: 0.5rem 0.75rem; border-radius: 4px; }
|
|
16
|
+
.notice { background: #fff8e1; border: 1px solid #e0c060; padding: 0.5rem 0.75rem; border-radius: 4px; }
|
|
17
|
+
.schema_list { border-collapse: collapse; width: 100%; margin-top: 0.5rem; }
|
|
18
|
+
.schema_list th, .schema_list td { text-align: left; padding: 0.3rem 0.6rem; border-bottom: 1px solid #eee; }
|
|
19
|
+
.schema_list tr.new_row { background: #eaf7ea; font-weight: 600; }
|
|
20
|
+
code { background: #f4f4f4; padding: 0.1rem 0.3rem; border-radius: 3px; }
|
|
21
|
+
.muted { color: #777; }
|
|
22
|
+
</style>
|
|
23
|
+
</head>
|
|
24
|
+
<%# Standalone dev utility: opt out of Turbo Drive so links + form POSTs do
|
|
25
|
+
plain full-page navigations. Without this, the "+ field" link keeps the
|
|
26
|
+
page in the host app's Turbo session and a POST that renders 200 HTML
|
|
27
|
+
(not a turbo_stream/redirect) is silently ignored by Turbo. %>
|
|
28
|
+
<body data-turbo="false">
|
|
29
|
+
<h1>inline_forms — add a field to a model</h1>
|
|
30
|
+
<p class="muted">Dev utility. Applies the <code>inline_forms_addto</code> generator (model edit + migration file); it does <strong>not</strong> run <code>db:migrate</code>.</p>
|
|
31
|
+
<%= yield %>
|
|
32
|
+
</body>
|
|
33
|
+
</html>
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
# EXAMPLE Forgejo Actions workflow for the automated schema pipeline
|
|
2
|
+
# (phase 3 of stuff/2026-07-11-schema-gui-gem-and-automated-pipeline-plan.md).
|
|
3
|
+
#
|
|
4
|
+
# Copy into your TENANT APP repo as .forgejo/workflows/schema-apply.yml and
|
|
5
|
+
# adapt the container image / secrets. Trigger it from the tenant app when a
|
|
6
|
+
# batch is submitted (webhook -> workflow_dispatch with batch_id), or run it
|
|
7
|
+
# on a schedule and let it pick up the oldest submitted batch.
|
|
8
|
+
#
|
|
9
|
+
# Loop (no human in it):
|
|
10
|
+
# fetch frozen batch (token) -> mark processing -> replay codegen
|
|
11
|
+
# -> test gate -> commit -> mark ready (git sha)
|
|
12
|
+
# Failure at any step marks the batch failed with the captured error;
|
|
13
|
+
# the tenant keeps running the old code.
|
|
14
|
+
#
|
|
15
|
+
# Secrets:
|
|
16
|
+
# SCHEMA_EXPORT_TOKEN — matches InlineFormsSchemaEdit.export_token on the tenant
|
|
17
|
+
# TENANT_URL — e.g. https://tenant.example.com
|
|
18
|
+
|
|
19
|
+
name: Schema apply
|
|
20
|
+
|
|
21
|
+
on:
|
|
22
|
+
workflow_dispatch:
|
|
23
|
+
inputs:
|
|
24
|
+
batch_id:
|
|
25
|
+
description: "SchemaBatch id to apply"
|
|
26
|
+
required: true
|
|
27
|
+
|
|
28
|
+
jobs:
|
|
29
|
+
schema-apply:
|
|
30
|
+
runs-on: docker
|
|
31
|
+
container:
|
|
32
|
+
image: papiamentu-ci:ruby-4.0.4-node20
|
|
33
|
+
steps:
|
|
34
|
+
- uses: actions/checkout@v4
|
|
35
|
+
|
|
36
|
+
- name: Fetch the frozen batch
|
|
37
|
+
run: |
|
|
38
|
+
curl --fail -sS \
|
|
39
|
+
-H "Authorization: Bearer ${{ secrets.SCHEMA_EXPORT_TOKEN }}" \
|
|
40
|
+
"${{ secrets.TENANT_URL }}/schema/batches/${{ inputs.batch_id }}/export.json" \
|
|
41
|
+
> /tmp/batch.json
|
|
42
|
+
|
|
43
|
+
- name: Mark processing
|
|
44
|
+
run: |
|
|
45
|
+
curl --fail -sS -X POST \
|
|
46
|
+
-H "Authorization: Bearer ${{ secrets.SCHEMA_EXPORT_TOKEN }}" \
|
|
47
|
+
-d "status=processing" \
|
|
48
|
+
"${{ secrets.TENANT_URL }}/schema/batches/${{ inputs.batch_id }}/status"
|
|
49
|
+
|
|
50
|
+
- name: Bundle
|
|
51
|
+
run: |
|
|
52
|
+
bundle config set --local path vendor/bundle
|
|
53
|
+
bundle install --jobs 4
|
|
54
|
+
|
|
55
|
+
- name: Replay the batch (codegen only)
|
|
56
|
+
run: bundle exec rake "schema_edit:apply_batch[/tmp/batch.json]"
|
|
57
|
+
|
|
58
|
+
- name: Test gate
|
|
59
|
+
run: bundle exec rails test
|
|
60
|
+
|
|
61
|
+
- name: Commit
|
|
62
|
+
run: |
|
|
63
|
+
git config user.email "schema-pipeline@invalid"
|
|
64
|
+
git config user.name "Schema pipeline"
|
|
65
|
+
git add -A
|
|
66
|
+
git commit -m "Schema batch ${{ inputs.batch_id }}: $(date -u +%FT%TZ)"
|
|
67
|
+
git push origin HEAD
|
|
68
|
+
|
|
69
|
+
- name: Mark ready
|
|
70
|
+
run: |
|
|
71
|
+
curl --fail -sS -X POST \
|
|
72
|
+
-H "Authorization: Bearer ${{ secrets.SCHEMA_EXPORT_TOKEN }}" \
|
|
73
|
+
-d "status=ready" -d "git_sha=$(git rev-parse HEAD)" \
|
|
74
|
+
"${{ secrets.TENANT_URL }}/schema/batches/${{ inputs.batch_id }}/status"
|
|
75
|
+
|
|
76
|
+
- name: Mark failed
|
|
77
|
+
if: failure()
|
|
78
|
+
run: |
|
|
79
|
+
curl -sS -X POST \
|
|
80
|
+
-H "Authorization: Bearer ${{ secrets.SCHEMA_EXPORT_TOKEN }}" \
|
|
81
|
+
-d "status=failed" -d "error=CI job failed; see the run log" \
|
|
82
|
+
"${{ secrets.TENANT_URL }}/schema/batches/${{ inputs.batch_id }}/status" || true
|
|
83
|
+
|
|
84
|
+
# After "ready": the tenant's scheduler runs `rake schema_edit:apply_due`
|
|
85
|
+
# (cron/whenever) which migrates due batches in their window, marks them
|
|
86
|
+
# applied, and runs InlineFormsSchemaEdit.restart_command. Deploying the
|
|
87
|
+
# committed code is your normal deploy (phase 4's rolling-restart ordering:
|
|
88
|
+
# migrate first — additive-only makes that safe — then restart onto the new
|
|
89
|
+
# code).
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# -*- encoding : utf-8 -*-
|
|
2
|
+
|
|
3
|
+
require "rails/generators"
|
|
4
|
+
require "rails/generators/migration"
|
|
5
|
+
|
|
6
|
+
module InlineFormsSchemaEdit
|
|
7
|
+
module Generators
|
|
8
|
+
# `rails g inline_forms_schema_edit:install`
|
|
9
|
+
#
|
|
10
|
+
# Writes the migration for the batch pipeline's two tables
|
|
11
|
+
# (inline_forms_schema_batches + inline_forms_schema_intents). Run by
|
|
12
|
+
# the installer when an app is created with --schema-edit; existing apps
|
|
13
|
+
# adding the gem later run it by hand.
|
|
14
|
+
class InstallGenerator < Rails::Generators::Base
|
|
15
|
+
include Rails::Generators::Migration
|
|
16
|
+
|
|
17
|
+
source_root File.expand_path("templates", __dir__)
|
|
18
|
+
|
|
19
|
+
def self.next_migration_number(dirname)
|
|
20
|
+
# Collision-free against migrations generated in the same second
|
|
21
|
+
# (same policy as inline_forms_addto since 8.1.41).
|
|
22
|
+
now = Time.now.utc.strftime("%Y%m%d%H%M%S")
|
|
23
|
+
existing = Dir.glob(File.join(dirname, "*.rb")).map { |f| File.basename(f)[/\A\d+/] }.compact
|
|
24
|
+
[ now, *existing.map(&:to_s) ].max.then do |highest|
|
|
25
|
+
highest == now ? now : (highest.to_i + 1).to_s
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def create_migration_file
|
|
30
|
+
migration_template "create_schema_edit_tables.rb.erb",
|
|
31
|
+
"db/migrate/create_inline_forms_schema_edit_tables.rb"
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def copy_ci_workflow_example
|
|
35
|
+
example = File.expand_path("../../../doc/schema-apply-workflow.yml.example", __dir__)
|
|
36
|
+
create_file "doc/schema-apply-workflow.yml.example", File.read(example)
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
class CreateInlineFormsSchemaEditTables < ActiveRecord::Migration[<%= ActiveRecord::Migration.current_version %>]
|
|
2
|
+
def change
|
|
3
|
+
create_table :inline_forms_schema_batches do |t|
|
|
4
|
+
t.string :status, null: false, default: "draft"
|
|
5
|
+
t.string :requested_by
|
|
6
|
+
t.datetime :submitted_at
|
|
7
|
+
t.datetime :window_at
|
|
8
|
+
t.datetime :applied_at
|
|
9
|
+
t.string :git_sha
|
|
10
|
+
t.string :content_digest
|
|
11
|
+
t.text :error
|
|
12
|
+
t.timestamps
|
|
13
|
+
end
|
|
14
|
+
add_index :inline_forms_schema_batches, :status
|
|
15
|
+
|
|
16
|
+
create_table :inline_forms_schema_intents do |t|
|
|
17
|
+
t.references :batch, null: false, index: true
|
|
18
|
+
t.string :target_model, null: false
|
|
19
|
+
t.string :attr_name, null: false
|
|
20
|
+
t.string :form_element, null: false
|
|
21
|
+
t.string :after_attr
|
|
22
|
+
t.string :before_attr
|
|
23
|
+
t.string :label
|
|
24
|
+
t.string :locale
|
|
25
|
+
t.integer :position
|
|
26
|
+
t.string :migration_version
|
|
27
|
+
t.timestamps
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# -*- encoding : utf-8 -*-
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
require "json"
|
|
5
|
+
|
|
6
|
+
module InlineFormsSchemaEdit
|
|
7
|
+
# Serializes a frozen SchemaBatch for transport to the CI/dev side, sealed
|
|
8
|
+
# with a content digest. The digest is computed over the canonical JSON of
|
|
9
|
+
# the ordered intent list only (not timestamps/ids), so the same logical
|
|
10
|
+
# batch always digests identically; SchemaBatch#submit! stores it and
|
|
11
|
+
# BatchImport re-verifies it before replaying.
|
|
12
|
+
module BatchExport
|
|
13
|
+
FORMAT = 1
|
|
14
|
+
|
|
15
|
+
module_function
|
|
16
|
+
|
|
17
|
+
def digest_for(batch)
|
|
18
|
+
canonical = JSON.generate(batch.intents.reload.map(&:as_export))
|
|
19
|
+
"sha256:#{Digest::SHA256.hexdigest(canonical)}"
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def payload(batch)
|
|
23
|
+
{
|
|
24
|
+
"format" => FORMAT,
|
|
25
|
+
"batch" => {
|
|
26
|
+
"id" => batch.id,
|
|
27
|
+
"status" => batch.status,
|
|
28
|
+
"requested_by" => batch.requested_by,
|
|
29
|
+
"submitted_at" => batch.submitted_at&.iso8601,
|
|
30
|
+
"window_at" => batch.window_at&.iso8601
|
|
31
|
+
},
|
|
32
|
+
"intents" => batch.intents.map(&:as_export),
|
|
33
|
+
"digest" => batch.content_digest || digest_for(batch)
|
|
34
|
+
}
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def to_json(batch)
|
|
38
|
+
JSON.pretty_generate(payload(batch))
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
# -*- encoding : utf-8 -*-
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
require "json"
|
|
5
|
+
|
|
6
|
+
module InlineFormsSchemaEdit
|
|
7
|
+
# The import/replay half of the pipeline (phase 2): takes an exported
|
|
8
|
+
# batch payload and materializes it into the CURRENT checkout — validates
|
|
9
|
+
# every intent against this checkout's models, then replays them in order
|
|
10
|
+
# through InlineForms::SchemaApply#generate! (+ SchemaLabel for labels).
|
|
11
|
+
#
|
|
12
|
+
# Deliberately does NOT migrate and does NOT commit: it only writes model
|
|
13
|
+
# edits + migration files and reports what it did, so the same class
|
|
14
|
+
# serves a developer replaying by hand and CI replaying automatically
|
|
15
|
+
# (CI's job then runs the test gate, commits, builds).
|
|
16
|
+
class BatchImport
|
|
17
|
+
class ImportError < StandardError; end
|
|
18
|
+
|
|
19
|
+
Result = Struct.new(:applied, :migrations, :labels, :plan, keyword_init: true)
|
|
20
|
+
|
|
21
|
+
attr_reader :payload
|
|
22
|
+
|
|
23
|
+
# Injectable for tests: executor replaces the addto generator run,
|
|
24
|
+
# label_writer replaces SchemaLabel.write.
|
|
25
|
+
def initialize(payload, executor: nil, label_writer: nil)
|
|
26
|
+
@payload = payload.is_a?(String) ? JSON.parse(payload) : payload
|
|
27
|
+
@executor = executor
|
|
28
|
+
@label_writer = label_writer || InlineForms::SchemaLabel.method(:write)
|
|
29
|
+
rescue JSON::ParserError => e
|
|
30
|
+
raise ImportError, "not valid JSON: #{e.message}"
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def self.from_file(path, **kwargs)
|
|
34
|
+
new(File.read(path), **kwargs)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Verify structure + digest + per-intent validity against this checkout.
|
|
38
|
+
# Returns the intents array (raises ImportError on any problem).
|
|
39
|
+
def verify!
|
|
40
|
+
raise ImportError, "unsupported format #{payload['format'].inspect}" unless payload["format"] == BatchExport::FORMAT
|
|
41
|
+
|
|
42
|
+
intents = payload["intents"]
|
|
43
|
+
raise ImportError, "batch has no intents" if intents.blank?
|
|
44
|
+
|
|
45
|
+
canonical = JSON.generate(intents)
|
|
46
|
+
digest = "sha256:#{Digest::SHA256.hexdigest(canonical)}"
|
|
47
|
+
unless digest == payload["digest"]
|
|
48
|
+
raise ImportError, "digest mismatch: payload says #{payload['digest'].inspect}, content is #{digest.inspect} — batch changed after submit?"
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
intents.each_with_index do |intent, i|
|
|
52
|
+
error = IntentValidator.error_for(intent)
|
|
53
|
+
raise ImportError, "intent ##{i + 1} (#{intent['model_name']}##{intent['attribute']}): #{error}" if error
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
intents
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Replay all intents into destination_root. Stops at the first failure
|
|
60
|
+
# (raises), leaving earlier generated files in place for inspection —
|
|
61
|
+
# the caller (CI) works on a clean tree it can reset.
|
|
62
|
+
def apply!(destination_root: Rails.root)
|
|
63
|
+
intents = verify!
|
|
64
|
+
migrations = []
|
|
65
|
+
labels = []
|
|
66
|
+
plan = []
|
|
67
|
+
|
|
68
|
+
intents.each do |intent_hash|
|
|
69
|
+
intent = build_intent(intent_hash)
|
|
70
|
+
apply = InlineForms::SchemaApply.new(intent)
|
|
71
|
+
plan.concat(apply.preview_plan)
|
|
72
|
+
|
|
73
|
+
migration = apply.generate!(
|
|
74
|
+
destination_root: destination_root,
|
|
75
|
+
executor: @executor || InlineForms::SchemaApply::DEFAULT_GENERATE_EXECUTOR
|
|
76
|
+
)
|
|
77
|
+
migrations << migration if migration
|
|
78
|
+
|
|
79
|
+
if intent_hash["label"].present?
|
|
80
|
+
labels << @label_writer.call(
|
|
81
|
+
destination_root: destination_root,
|
|
82
|
+
model_class: intent.model_class,
|
|
83
|
+
attribute: intent.attribute,
|
|
84
|
+
label: intent_hash["label"],
|
|
85
|
+
locale: intent_hash["locale"].presence || I18n.default_locale.to_s
|
|
86
|
+
)
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
Result.new(applied: intents.size, migrations: migrations, labels: labels, plan: plan)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
private
|
|
94
|
+
|
|
95
|
+
def build_intent(h)
|
|
96
|
+
InlineForms::SchemaIntent.new(
|
|
97
|
+
model_name: h["model_name"],
|
|
98
|
+
attribute: h["attribute"],
|
|
99
|
+
form_element: h["form_element"],
|
|
100
|
+
after: h["after"].presence,
|
|
101
|
+
before: h["before"].presence
|
|
102
|
+
)
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# -*- encoding : utf-8 -*-
|
|
2
|
+
|
|
3
|
+
module InlineFormsSchemaEdit
|
|
4
|
+
# Deliberately NOT `isolate_namespace`: the gem contributes
|
|
5
|
+
# `InlineForms::SchemaController` (+ its views and layout) into the host
|
|
6
|
+
# app, in the same namespace the inline_forms engine uses, so the routes
|
|
7
|
+
# the installer writes (`inline_forms/schema#new` etc.) and the staging
|
|
8
|
+
# services in inline_forms (`SchemaIntent`/`SchemaPreview`/`SchemaApply`/
|
|
9
|
+
# `SchemaLabel`) resolve exactly as they did when the controller lived in
|
|
10
|
+
# the engine itself.
|
|
11
|
+
class Engine < Rails::Engine
|
|
12
|
+
end
|
|
13
|
+
end
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# -*- encoding : utf-8 -*-
|
|
2
|
+
|
|
3
|
+
module InlineFormsSchemaEdit
|
|
4
|
+
# Validation shared by the GUI (SchemaController) and the import/replay
|
|
5
|
+
# side (BatchImport / rake schema_edit:apply_batch). Validates a plain
|
|
6
|
+
# params-shaped hash against the CURRENT app's models — deliberately
|
|
7
|
+
# re-run at import time, because the checkout that replays a batch may be
|
|
8
|
+
# newer than the tenant that drafted it.
|
|
9
|
+
#
|
|
10
|
+
# ADDITIVE-ONLY GUARANTEE: only scalar form elements from
|
|
11
|
+
# SchemaPreview.supported_form_elements plus :header are accepted; those
|
|
12
|
+
# map to `add_column` (nullable) or no column at all. Nothing destructive
|
|
13
|
+
# or relational can enter a batch through this gate.
|
|
14
|
+
module IntentValidator
|
|
15
|
+
HEADER = "header"
|
|
16
|
+
|
|
17
|
+
ATTRIBUTE_FORMAT = /\A[a-z_][a-z0-9_]*\z/
|
|
18
|
+
|
|
19
|
+
module_function
|
|
20
|
+
|
|
21
|
+
# Returns an error string, or nil when the intent is usable.
|
|
22
|
+
# `p` keys (string or symbol): model_name, attribute, form_element.
|
|
23
|
+
def error_for(p)
|
|
24
|
+
p = p.symbolize_keys
|
|
25
|
+
return "Choose a model." if p[:model_name].blank?
|
|
26
|
+
return "Enter an attribute name." if p[:attribute].blank?
|
|
27
|
+
unless p[:attribute].to_s.match?(ATTRIBUTE_FORMAT)
|
|
28
|
+
return "Attribute name must be lowercase letters, digits and underscores (e.g. internal_note)."
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
allowed = InlineForms::SchemaPreview.supported_form_elements.map(&:to_s) + [ HEADER ]
|
|
32
|
+
unless allowed.include?(p[:form_element].to_s)
|
|
33
|
+
return "Unsupported form element #{p[:form_element].inspect}."
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
klass = safe_model_class(p[:model_name].to_s)
|
|
37
|
+
return "Unknown model #{p[:model_name].inspect}." unless klass
|
|
38
|
+
unless model_usable?(klass)
|
|
39
|
+
return "#{p[:model_name]} is not an inline_forms model (no inline_forms_attribute_list)."
|
|
40
|
+
end
|
|
41
|
+
# A header has no column, so the column-collision check does not apply.
|
|
42
|
+
if p[:form_element].to_s != HEADER && klass.column_names.include?(p[:attribute].to_s)
|
|
43
|
+
return "#{p[:model_name]} already has a #{p[:attribute]} column."
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
nil
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def safe_model_class(name)
|
|
50
|
+
klass = name.safe_constantize
|
|
51
|
+
klass if klass.is_a?(Class) && klass < ActiveRecord::Base
|
|
52
|
+
rescue StandardError
|
|
53
|
+
nil
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def model_usable?(klass)
|
|
57
|
+
!klass.abstract_class? &&
|
|
58
|
+
klass.instance_methods.include?(:inline_forms_attribute_list) &&
|
|
59
|
+
klass.respond_to?(:column_names)
|
|
60
|
+
rescue StandardError
|
|
61
|
+
false
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def candidate_models
|
|
65
|
+
ActiveRecord::Base.descendants.select { |k| model_usable?(k) && k.name.present? }
|
|
66
|
+
.map(&:name).uniq.sort
|
|
67
|
+
rescue StandardError
|
|
68
|
+
[]
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# -*- encoding : utf-8 -*-
|
|
2
|
+
|
|
3
|
+
require "inline_forms_schema_edit/version"
|
|
4
|
+
require "inline_forms_schema_edit/engine" if defined?(Rails::Engine)
|
|
5
|
+
require "inline_forms_schema_edit/intent_validator"
|
|
6
|
+
require "inline_forms_schema_edit/batch_export"
|
|
7
|
+
require "inline_forms_schema_edit/batch_import"
|
|
8
|
+
|
|
9
|
+
# InlineFormsSchemaEdit packages the schema-change GUI (add a field to a model
|
|
10
|
+
# through the browser) as a mountable engine, separate from the inline_forms
|
|
11
|
+
# runtime engine. Apps opt in at creation time (`inline_forms create
|
|
12
|
+
# --schema-edit`, implied by `--example`); apps that never change their own
|
|
13
|
+
# schema ship without this surface entirely.
|
|
14
|
+
#
|
|
15
|
+
# The GUI is the web layer plus the batch pipeline (persisted intents,
|
|
16
|
+
# freeze-on-submit, export/import/replay). The codegen machinery it drives
|
|
17
|
+
# lives in inline_forms: SchemaIntent (the proposed change), SchemaPreview
|
|
18
|
+
# (cheap subclass + virtual-attribute preview), SchemaApply (runs the
|
|
19
|
+
# inline_forms_addto generator; never db:migrate) and SchemaLabel (locale
|
|
20
|
+
# label writing). See stuff/2026-07-11-schema-gui-gem-and-automated-pipeline-plan.md.
|
|
21
|
+
module InlineFormsSchemaEdit
|
|
22
|
+
# Production posture. By default the whole GUI stays non-production (the
|
|
23
|
+
# phase-0 behavior: authoring happens on a dev checkout). A SaaS tenant app
|
|
24
|
+
# that drafts intents in production opts in explicitly:
|
|
25
|
+
#
|
|
26
|
+
# InlineFormsSchemaEdit.production_drafting = true
|
|
27
|
+
#
|
|
28
|
+
# Even then, only DRAFTING (new/preview/draft/index/submit) is allowed in
|
|
29
|
+
# production; direct apply (codegen into the running app's tree) is never
|
|
30
|
+
# available there — production never writes code.
|
|
31
|
+
mattr_accessor :production_drafting
|
|
32
|
+
self.production_drafting = false
|
|
33
|
+
|
|
34
|
+
# Shared-secret token for the machine endpoints (batch export + status
|
|
35
|
+
# callback) used by the CI pipeline. Both endpoints 404 unless a token is
|
|
36
|
+
# configured — secure by default. Also settable via ENV.
|
|
37
|
+
mattr_writer :export_token
|
|
38
|
+
def self.export_token
|
|
39
|
+
@@export_token.presence || ENV["INLINE_FORMS_SCHEMA_EXPORT_TOKEN"].presence
|
|
40
|
+
end
|
|
41
|
+
self.export_token = nil
|
|
42
|
+
|
|
43
|
+
# Optional command run by `rake schema_edit:apply_due` after migrating a due
|
|
44
|
+
# batch (e.g. "touch tmp/restart.txt" for Passenger, or a systemd restart).
|
|
45
|
+
# nil = no restart is attempted; the deploy tooling owns it.
|
|
46
|
+
mattr_accessor :restart_command
|
|
47
|
+
self.restart_command = nil
|
|
48
|
+
|
|
49
|
+
# All GUI + pipeline routes, drawn from one place so gem upgrades can add
|
|
50
|
+
# routes without editing the app's routes.rb. The installer writes a single
|
|
51
|
+
# line into generated apps:
|
|
52
|
+
#
|
|
53
|
+
# InlineFormsSchemaEdit.draw_routes(self)
|
|
54
|
+
#
|
|
55
|
+
# (Route names are kept identical to the phase-0 literal routes.)
|
|
56
|
+
def self.draw_routes(router)
|
|
57
|
+
router.get "schema", to: "inline_forms/schema#index", as: :inline_forms_schema_index
|
|
58
|
+
router.get "schema/new", to: "inline_forms/schema#new", as: :inline_forms_schema_new
|
|
59
|
+
router.post "schema/preview", to: "inline_forms/schema#preview", as: :inline_forms_schema_preview
|
|
60
|
+
router.post "schema", to: "inline_forms/schema#create", as: :inline_forms_schema
|
|
61
|
+
router.post "schema/draft", to: "inline_forms/schema#draft", as: :inline_forms_schema_draft
|
|
62
|
+
router.delete "schema/draft/:id", to: "inline_forms/schema#remove_draft", as: :inline_forms_schema_remove_draft
|
|
63
|
+
router.post "schema/batch/submit", to: "inline_forms/schema#submit_batch", as: :inline_forms_schema_submit_batch
|
|
64
|
+
router.get "schema/batches/:id/export", to: "inline_forms/schema#export",
|
|
65
|
+
defaults: { format: :json }, as: :inline_forms_schema_export
|
|
66
|
+
router.post "schema/batches/:id/status", to: "inline_forms/schema#batch_status",
|
|
67
|
+
as: :inline_forms_schema_batch_status
|
|
68
|
+
end
|
|
69
|
+
end
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# -*- encoding : utf-8 -*-
|
|
2
|
+
|
|
3
|
+
require "shellwords"
|
|
4
|
+
|
|
5
|
+
# The pipeline's command-line surface (phases 2-4 of
|
|
6
|
+
# stuff/2026-07-11-schema-gui-gem-and-automated-pipeline-plan.md).
|
|
7
|
+
#
|
|
8
|
+
# Tenant side: export_batch, mark_batch, apply_due
|
|
9
|
+
# Checkout side: apply_batch (replay into this checkout; no migrate, no commit)
|
|
10
|
+
namespace :schema_edit do
|
|
11
|
+
desc "Export a frozen batch as JSON (stdout, or FILE=path). Usage: schema_edit:export_batch[batch_id]"
|
|
12
|
+
task :export_batch, [ :batch_id ] => :environment do |_t, args|
|
|
13
|
+
batch = InlineForms::SchemaBatch.find(args.fetch(:batch_id))
|
|
14
|
+
abort "Batch #{batch.id} is still a draft — submit it first." if batch.draft?
|
|
15
|
+
|
|
16
|
+
json = InlineFormsSchemaEdit::BatchExport.to_json(batch)
|
|
17
|
+
if ENV["FILE"].to_s.empty?
|
|
18
|
+
puts json
|
|
19
|
+
else
|
|
20
|
+
File.write(ENV["FILE"], json)
|
|
21
|
+
puts "Wrote batch #{batch.id} (#{batch.intents.size} intents) to #{ENV['FILE']}"
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
desc "Replay an exported batch into THIS checkout (codegen only; no db:migrate, no commit). Usage: schema_edit:apply_batch[export.json]"
|
|
26
|
+
task :apply_batch, [ :file ] => :environment do |_t, args|
|
|
27
|
+
file = args.fetch(:file)
|
|
28
|
+
abort "No such file: #{file}" unless File.file?(file)
|
|
29
|
+
|
|
30
|
+
# Refuse to replay onto a dirty tree: the generated diff must be exactly
|
|
31
|
+
# this batch, nothing else (SKIP_GIT_CHECK=1 to override, e.g. in tests).
|
|
32
|
+
if ENV["SKIP_GIT_CHECK"].to_s.empty? && File.directory?(Rails.root.join(".git"))
|
|
33
|
+
dirty = `git -C #{Rails.root.to_s.shellescape} status --porcelain`.strip
|
|
34
|
+
abort "Working tree is not clean; commit or stash first:\n#{dirty}" unless dirty.empty?
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
import = InlineFormsSchemaEdit::BatchImport.from_file(file)
|
|
38
|
+
result = import.apply!(destination_root: Rails.root)
|
|
39
|
+
|
|
40
|
+
puts "Replayed #{result.applied} intent(s)."
|
|
41
|
+
result.plan.each { |line| puts " plan: #{line}" }
|
|
42
|
+
result.migrations.each { |m| puts " migration: #{m}" }
|
|
43
|
+
result.labels.each { |l| puts " label: #{l}" }
|
|
44
|
+
puts "NOT migrated, NOT committed — run the test gate, then commit."
|
|
45
|
+
rescue InlineFormsSchemaEdit::BatchImport::ImportError => e
|
|
46
|
+
abort "Import failed: #{e.message}"
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
desc "Report pipeline progress on a batch (CI-side helper against the tenant DB). Usage: schema_edit:mark_batch[batch_id,status] GIT_SHA=... ERROR=..."
|
|
50
|
+
task :mark_batch, [ :batch_id, :status ] => :environment do |_t, args|
|
|
51
|
+
batch = InlineForms::SchemaBatch.find(args.fetch(:batch_id))
|
|
52
|
+
batch.transition!(args.fetch(:status), git_sha: ENV["GIT_SHA"].presence, error: ENV["ERROR"].presence)
|
|
53
|
+
puts "Batch #{batch.id}: #{batch.status}"
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
desc "Apply due batches (phase 4): for each ready batch whose window has arrived, run db:migrate, mark applied, run the configured restart command"
|
|
57
|
+
task apply_due: :environment do
|
|
58
|
+
due = InlineForms::SchemaBatch.with_status(:ready).select(&:due_for_apply?)
|
|
59
|
+
if due.empty?
|
|
60
|
+
puts "No batches due."
|
|
61
|
+
next
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
due.each do |batch|
|
|
65
|
+
puts "Applying batch #{batch.id} (window: #{batch.window_at || 'next window'})..."
|
|
66
|
+
begin
|
|
67
|
+
# Additive-only migrations (the GUI vocabulary guarantees it), so
|
|
68
|
+
# migrating ahead of the restart is safe (expand/contract).
|
|
69
|
+
ActiveRecord::Tasks::DatabaseTasks.migrate
|
|
70
|
+
batch.transition!(:applied)
|
|
71
|
+
puts " migrated + marked applied."
|
|
72
|
+
|
|
73
|
+
if (cmd = InlineFormsSchemaEdit.restart_command).present?
|
|
74
|
+
puts " restart: #{cmd}"
|
|
75
|
+
system(cmd) || puts(" WARNING: restart command exited non-zero")
|
|
76
|
+
end
|
|
77
|
+
rescue StandardError => e
|
|
78
|
+
batch.transition!(:failed, error: "apply_due: #{e.class}: #{e.message}")
|
|
79
|
+
puts " FAILED: #{e.message} (batch marked failed)"
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|