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.
@@ -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> &mdash; 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 =&gt; 'one', 2 =&gt; '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
+ &middot; <%= 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 &mdash; 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 &mdash; 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,7 @@
1
+ # -*- encoding : utf-8 -*-
2
+
3
+ module InlineFormsSchemaEdit
4
+ # Lockstep with inline_forms / inline_forms_installer / validation_hints
5
+ # (see stuff/prompt/workflow.md): all gems bump together on the 8.1 line.
6
+ VERSION = "8.1.42"
7
+ 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