open-loam 0.1.0
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 +71 -0
- data/LICENSE +21 -0
- data/README.md +177 -0
- data/app/jobs/loam/custom_field_reindex_job.rb +22 -0
- data/app/jobs/loam/event_delivery_job.rb +26 -0
- data/app/jobs/loam/event_redelivery_sweep_job.rb +21 -0
- data/app/jobs/loam/webhook_delivery_job.rb +59 -0
- data/app/models/loam/api_token.rb +40 -0
- data/app/models/loam/audit_record.rb +18 -0
- data/app/models/loam/auth_attempt.rb +13 -0
- data/app/models/loam/business_rule.rb +49 -0
- data/app/models/loam/business_rule_run.rb +27 -0
- data/app/models/loam/comment.rb +28 -0
- data/app/models/loam/config.rb +27 -0
- data/app/models/loam/custom_field_value.rb +18 -0
- data/app/models/loam/dashboard_widget.rb +16 -0
- data/app/models/loam/dictionary.rb +20 -0
- data/app/models/loam/dictionary_entry.rb +31 -0
- data/app/models/loam/event_delivery.rb +38 -0
- data/app/models/loam/field_definition.rb +48 -0
- data/app/models/loam/inbound_webhook_delivery.rb +24 -0
- data/app/models/loam/inbound_webhook_source.rb +55 -0
- data/app/models/loam/membership.rb +27 -0
- data/app/models/loam/mfa_credential.rb +114 -0
- data/app/models/loam/notification.rb +36 -0
- data/app/models/loam/pending_action.rb +208 -0
- data/app/models/loam/perspective.rb +104 -0
- data/app/models/loam/progress_job.rb +103 -0
- data/app/models/loam/record_lock.rb +19 -0
- data/app/models/loam/scheduled_job.rb +42 -0
- data/app/models/loam/search_token.rb +9 -0
- data/app/models/loam/sso_identity.rb +13 -0
- data/app/models/loam/sso_provider.rb +35 -0
- data/app/models/loam/tenant.rb +20 -0
- data/app/models/loam/translation.rb +17 -0
- data/app/models/loam/webhook_endpoint.rb +26 -0
- data/app/views/loam/attachments/_attachments.html.erb +16 -0
- data/app/views/loam/comments/_comments.html.erb +24 -0
- data/app/views/loam/custom_fields/_fields.html.erb +35 -0
- data/lib/generators/loam/entity/entity_generator.rb +160 -0
- data/lib/generators/loam/entity/templates/api_controller.rb +67 -0
- data/lib/generators/loam/entity/templates/controller.rb +181 -0
- data/lib/generators/loam/entity/templates/entity_test.rb +107 -0
- data/lib/generators/loam/entity/templates/migration.rb +26 -0
- data/lib/generators/loam/entity/templates/model.rb +27 -0
- data/lib/generators/loam/entity/templates/policy.rb +10 -0
- data/lib/generators/loam/entity/templates/views/_form.html.erb +63 -0
- data/lib/generators/loam/entity/templates/views/deleted.html.erb +41 -0
- data/lib/generators/loam/entity/templates/views/edit.html.erb +3 -0
- data/lib/generators/loam/entity/templates/views/index.html.erb +90 -0
- data/lib/generators/loam/entity/templates/views/new.html.erb +3 -0
- data/lib/generators/loam/entity/templates/views/show.html.erb +36 -0
- data/lib/generators/loam/install/install_generator.rb +288 -0
- data/lib/generators/loam/install/templates/AGENTS.md +341 -0
- data/lib/generators/loam/install/templates/admin/api_docs_controller.rb +17 -0
- data/lib/generators/loam/install/templates/admin/api_docs_index.html.erb +34 -0
- data/lib/generators/loam/install/templates/admin/api_tokens_controller.rb +30 -0
- data/lib/generators/loam/install/templates/admin/api_tokens_index.html.erb +39 -0
- data/lib/generators/loam/install/templates/admin/base_controller.rb +224 -0
- data/lib/generators/loam/install/templates/admin/business_rules_controller.rb +64 -0
- data/lib/generators/loam/install/templates/admin/business_rules_edit.html.erb +3 -0
- data/lib/generators/loam/install/templates/admin/business_rules_form.html.erb +32 -0
- data/lib/generators/loam/install/templates/admin/business_rules_index.html.erb +45 -0
- data/lib/generators/loam/install/templates/admin/business_rules_new.html.erb +3 -0
- data/lib/generators/loam/install/templates/admin/comments_controller.rb +39 -0
- data/lib/generators/loam/install/templates/admin/configs_controller.rb +46 -0
- data/lib/generators/loam/install/templates/admin/configs_edit.html.erb +22 -0
- data/lib/generators/loam/install/templates/admin/configs_index.html.erb +30 -0
- data/lib/generators/loam/install/templates/admin/dashboard_controller.rb +10 -0
- data/lib/generators/loam/install/templates/admin/dashboard_index.html.erb +26 -0
- data/lib/generators/loam/install/templates/admin/dashboard_widgets_controller.rb +23 -0
- data/lib/generators/loam/install/templates/admin/dashboard_widgets_index.html.erb +24 -0
- data/lib/generators/loam/install/templates/admin/dictionaries_controller.rb +55 -0
- data/lib/generators/loam/install/templates/admin/dictionaries_edit.html.erb +45 -0
- data/lib/generators/loam/install/templates/admin/dictionaries_form.html.erb +10 -0
- data/lib/generators/loam/install/templates/admin/dictionaries_index.html.erb +21 -0
- data/lib/generators/loam/install/templates/admin/dictionaries_new.html.erb +5 -0
- data/lib/generators/loam/install/templates/admin/dictionary_entries_controller.rb +47 -0
- data/lib/generators/loam/install/templates/admin/event_deliveries_controller.rb +23 -0
- data/lib/generators/loam/install/templates/admin/event_deliveries_index.html.erb +49 -0
- data/lib/generators/loam/install/templates/admin/events_controller.rb +42 -0
- data/lib/generators/loam/install/templates/admin/features_controller.rb +35 -0
- data/lib/generators/loam/install/templates/admin/features_index.html.erb +37 -0
- data/lib/generators/loam/install/templates/admin/field_definitions_controller.rb +52 -0
- data/lib/generators/loam/install/templates/admin/field_definitions_index.html.erb +30 -0
- data/lib/generators/loam/install/templates/admin/field_definitions_new.html.erb +39 -0
- data/lib/generators/loam/install/templates/admin/history_controller.rb +38 -0
- data/lib/generators/loam/install/templates/admin/history_index.html.erb +35 -0
- data/lib/generators/loam/install/templates/admin/imports_controller.rb +75 -0
- data/lib/generators/loam/install/templates/admin/imports_new.html.erb +10 -0
- data/lib/generators/loam/install/templates/admin/imports_preview.html.erb +32 -0
- data/lib/generators/loam/install/templates/admin/imports_summary.html.erb +32 -0
- data/lib/generators/loam/install/templates/admin/inbound_webhook_sources_controller.rb +61 -0
- data/lib/generators/loam/install/templates/admin/inbound_webhook_sources_index.html.erb +49 -0
- data/lib/generators/loam/install/templates/admin/inbound_webhook_sources_new.html.erb +32 -0
- data/lib/generators/loam/install/templates/admin/layout.html.erb +76 -0
- data/lib/generators/loam/install/templates/admin/mfa_activated.html.erb +12 -0
- data/lib/generators/loam/install/templates/admin/mfa_controller.rb +61 -0
- data/lib/generators/loam/install/templates/admin/mfa_new.html.erb +23 -0
- data/lib/generators/loam/install/templates/admin/mfa_show.html.erb +19 -0
- data/lib/generators/loam/install/templates/admin/notifications_controller.rb +23 -0
- data/lib/generators/loam/install/templates/admin/notifications_index.html.erb +32 -0
- data/lib/generators/loam/install/templates/admin/overrides_controller.rb +14 -0
- data/lib/generators/loam/install/templates/admin/overrides_index.html.erb +29 -0
- data/lib/generators/loam/install/templates/admin/pagination.rb +23 -0
- data/lib/generators/loam/install/templates/admin/pending_actions_controller.rb +38 -0
- data/lib/generators/loam/install/templates/admin/pending_actions_index.html.erb +40 -0
- data/lib/generators/loam/install/templates/admin/perspectives_controller.rb +98 -0
- data/lib/generators/loam/install/templates/admin/perspectives_index.html.erb +38 -0
- data/lib/generators/loam/install/templates/admin/progress_jobs_controller.rb +19 -0
- data/lib/generators/loam/install/templates/admin/progress_jobs_index.html.erb +31 -0
- data/lib/generators/loam/install/templates/admin/record_locks_controller.rb +17 -0
- data/lib/generators/loam/install/templates/admin/scheduled_jobs_controller.rb +69 -0
- data/lib/generators/loam/install/templates/admin/scheduled_jobs_edit.html.erb +5 -0
- data/lib/generators/loam/install/templates/admin/scheduled_jobs_form.html.erb +16 -0
- data/lib/generators/loam/install/templates/admin/scheduled_jobs_index.html.erb +28 -0
- data/lib/generators/loam/install/templates/admin/scheduled_jobs_new.html.erb +5 -0
- data/lib/generators/loam/install/templates/admin/search_controller.rb +49 -0
- data/lib/generators/loam/install/templates/admin/search_index.html.erb +23 -0
- data/lib/generators/loam/install/templates/admin/sessions_controller.rb +248 -0
- data/lib/generators/loam/install/templates/admin/sessions_mfa_challenge.html.erb +18 -0
- data/lib/generators/loam/install/templates/admin/sessions_new.html.erb +45 -0
- data/lib/generators/loam/install/templates/admin/sso_providers_controller.rb +65 -0
- data/lib/generators/loam/install/templates/admin/sso_providers_edit.html.erb +3 -0
- data/lib/generators/loam/install/templates/admin/sso_providers_form.html.erb +32 -0
- data/lib/generators/loam/install/templates/admin/sso_providers_index.html.erb +26 -0
- data/lib/generators/loam/install/templates/admin/sso_providers_new.html.erb +3 -0
- data/lib/generators/loam/install/templates/admin/sudo_controller.rb +45 -0
- data/lib/generators/loam/install/templates/admin/sudo_new.html.erb +24 -0
- data/lib/generators/loam/install/templates/admin/translations_controller.rb +37 -0
- data/lib/generators/loam/install/templates/admin/translations_index.html.erb +21 -0
- data/lib/generators/loam/install/templates/admin/webhook_endpoints_controller.rb +38 -0
- data/lib/generators/loam/install/templates/admin/webhook_endpoints_index.html.erb +32 -0
- data/lib/generators/loam/install/templates/admin/webhook_endpoints_new.html.erb +28 -0
- data/lib/generators/loam/install/templates/admin.css +29 -0
- data/lib/generators/loam/install/templates/api_base_controller.rb +90 -0
- data/lib/generators/loam/install/templates/guardrails_test.rb +79 -0
- data/lib/generators/loam/install/templates/import_job.rb +25 -0
- data/lib/generators/loam/install/templates/inbound_webhooks_controller.rb +17 -0
- data/lib/generators/loam/install/templates/initializer.rb +218 -0
- data/lib/generators/loam/install/templates/migrations/create_loam_api_tokens.rb +15 -0
- data/lib/generators/loam/install/templates/migrations/create_loam_audit_records.rb +14 -0
- data/lib/generators/loam/install/templates/migrations/create_loam_auth_attempts.rb +11 -0
- data/lib/generators/loam/install/templates/migrations/create_loam_business_rules.rb +29 -0
- data/lib/generators/loam/install/templates/migrations/create_loam_comments.rb +13 -0
- data/lib/generators/loam/install/templates/migrations/create_loam_configs.rb +21 -0
- data/lib/generators/loam/install/templates/migrations/create_loam_custom_field_values.rb +19 -0
- data/lib/generators/loam/install/templates/migrations/create_loam_dashboard_widgets.rb +12 -0
- data/lib/generators/loam/install/templates/migrations/create_loam_dictionaries.rb +11 -0
- data/lib/generators/loam/install/templates/migrations/create_loam_dictionary_entries.rb +18 -0
- data/lib/generators/loam/install/templates/migrations/create_loam_event_deliveries.rb +17 -0
- data/lib/generators/loam/install/templates/migrations/create_loam_field_definitions.rb +15 -0
- data/lib/generators/loam/install/templates/migrations/create_loam_inbound_webhooks.rb +31 -0
- data/lib/generators/loam/install/templates/migrations/create_loam_memberships.rb +11 -0
- data/lib/generators/loam/install/templates/migrations/create_loam_mfa_credentials.rb +13 -0
- data/lib/generators/loam/install/templates/migrations/create_loam_notifications.rb +15 -0
- data/lib/generators/loam/install/templates/migrations/create_loam_pending_actions.rb +26 -0
- data/lib/generators/loam/install/templates/migrations/create_loam_perspectives.rb +17 -0
- data/lib/generators/loam/install/templates/migrations/create_loam_progress_jobs.rb +20 -0
- data/lib/generators/loam/install/templates/migrations/create_loam_record_locks.rb +16 -0
- data/lib/generators/loam/install/templates/migrations/create_loam_scheduled_jobs.rb +20 -0
- data/lib/generators/loam/install/templates/migrations/create_loam_search_tokens.rb +18 -0
- data/lib/generators/loam/install/templates/migrations/create_loam_sso_providers.rb +34 -0
- data/lib/generators/loam/install/templates/migrations/create_loam_tenants.rb +9 -0
- data/lib/generators/loam/install/templates/migrations/create_loam_translations.rb +15 -0
- data/lib/generators/loam/install/templates/migrations/create_loam_webhook_endpoints.rb +13 -0
- data/lib/generators/loam/install/templates/migrations/create_users.rb +13 -0
- data/lib/generators/loam/install/templates/user.rb +21 -0
- data/lib/generators/loam/primary_key_options.rb +101 -0
- data/lib/loam/attachable.rb +27 -0
- data/lib/loam/auditable.rb +67 -0
- data/lib/loam/auth_throttle.rb +83 -0
- data/lib/loam/base32.rb +23 -0
- data/lib/loam/bulk.rb +59 -0
- data/lib/loam/business_rules/actions.rb +92 -0
- data/lib/loam/business_rules/condition.rb +102 -0
- data/lib/loam/business_rules.rb +123 -0
- data/lib/loam/commentable.rb +22 -0
- data/lib/loam/configs.rb +115 -0
- data/lib/loam/cron.rb +98 -0
- data/lib/loam/csv.rb +18 -0
- data/lib/loam/current.rb +21 -0
- data/lib/loam/custom_field_index.rb +262 -0
- data/lib/loam/custom_fields.rb +114 -0
- data/lib/loam/dashboard.rb +17 -0
- data/lib/loam/dictionaries.rb +55 -0
- data/lib/loam/durable_events.rb +174 -0
- data/lib/loam/encryptable.rb +145 -0
- data/lib/loam/encryption/cipher.rb +75 -0
- data/lib/loam/encryption/key_provider.rb +43 -0
- data/lib/loam/encryption.rb +125 -0
- data/lib/loam/engine.rb +57 -0
- data/lib/loam/enrichers.rb +105 -0
- data/lib/loam/errors.rb +49 -0
- data/lib/loam/eval.rb +47 -0
- data/lib/loam/event_stream.rb +80 -0
- data/lib/loam/eventful.rb +45 -0
- data/lib/loam/events.rb +51 -0
- data/lib/loam/export.rb +74 -0
- data/lib/loam/features.rb +75 -0
- data/lib/loam/generated_key.rb +37 -0
- data/lib/loam/import.rb +168 -0
- data/lib/loam/inbound_webhooks.rb +125 -0
- data/lib/loam/lifecycle.rb +180 -0
- data/lib/loam/locales/loam.en.yml +80 -0
- data/lib/loam/mcp/server.rb +60 -0
- data/lib/loam/mcp.rb +304 -0
- data/lib/loam/notifications.rb +36 -0
- data/lib/loam/open_api.rb +194 -0
- data/lib/loam/overrides.rb +110 -0
- data/lib/loam/pending_actions.rb +60 -0
- data/lib/loam/permissions.rb +79 -0
- data/lib/loam/perspectives.rb +62 -0
- data/lib/loam/policy.rb +106 -0
- data/lib/loam/progress.rb +31 -0
- data/lib/loam/record_locks.rb +89 -0
- data/lib/loam/scheduler.rb +153 -0
- data/lib/loam/search/driver.rb +31 -0
- data/lib/loam/search/like_driver.rb +29 -0
- data/lib/loam/search/token_driver.rb +88 -0
- data/lib/loam/search.rb +27 -0
- data/lib/loam/searchable.rb +90 -0
- data/lib/loam/soft_deletable.rb +72 -0
- data/lib/loam/sso/claims.rb +10 -0
- data/lib/loam/sso/fake_provider.rb +51 -0
- data/lib/loam/sso/http_client.rb +42 -0
- data/lib/loam/sso/oidc_provider.rb +64 -0
- data/lib/loam/sso.rb +129 -0
- data/lib/loam/telemetry.rb +43 -0
- data/lib/loam/tenant_record.rb +36 -0
- data/lib/loam/test_helpers.rb +15 -0
- data/lib/loam/totp.rb +63 -0
- data/lib/loam/translatable.rb +88 -0
- data/lib/loam/undo.rb +126 -0
- data/lib/loam/version.rb +3 -0
- data/lib/loam/webhooks.rb +34 -0
- data/lib/loam/widgets.rb +77 -0
- data/lib/loam/workflow.rb +256 -0
- data/lib/loam.rb +122 -0
- data/lib/open-loam.rb +5 -0
- data/lib/tasks/loam.rake +171 -0
- data/lib/tasks/loam_eval.rake +32 -0
- data/lib/tasks/loam_mcp.rake +20 -0
- metadata +332 -0
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
module Loam
|
|
2
|
+
# The read-model index for custom fields (Loam::CustomFieldValue) — a typed-EAV
|
|
3
|
+
# projection that makes custom-field filter/sort/search index-backed.
|
|
4
|
+
#
|
|
5
|
+
# Loam::CustomFieldIndex.filter(DamageReport, "severity", "eq", "critical") # a relation
|
|
6
|
+
# Loam::CustomFieldIndex.order(DamageReport, "severity", :asc)
|
|
7
|
+
# Loam::CustomFieldIndex.reindex(DamageReport) # rebuild
|
|
8
|
+
#
|
|
9
|
+
# SAFETY: `field_key` must name a real Loam::FieldDefinition (no arbitrary
|
|
10
|
+
# keys); values are cast to the declared type; queries are tenant-scoped
|
|
11
|
+
# throughout (index rows carry tenant_id, the model relation is default-scoped),
|
|
12
|
+
# so a filter can't reach across tenants. Encrypted data lives in real columns,
|
|
13
|
+
# never in a custom field — nothing encrypted is projected here.
|
|
14
|
+
module CustomFieldIndex
|
|
15
|
+
OPS = %w[eq neq gt gte lt lte contains present blank].freeze
|
|
16
|
+
|
|
17
|
+
module_function
|
|
18
|
+
|
|
19
|
+
# --- maintenance (called from Loam::CustomFields) ---
|
|
20
|
+
|
|
21
|
+
# Re-project one record's custom fields (delete + insert). Soft-delete keeps
|
|
22
|
+
# the rows (the filter's base scope hides the record anyway — the same
|
|
23
|
+
# decision as the L-912 search token driver); a hard destroy removes them.
|
|
24
|
+
def project(record)
|
|
25
|
+
model = record.class
|
|
26
|
+
return unless model.respond_to?(:custom_field_definitions)
|
|
27
|
+
|
|
28
|
+
type = index_type(model)
|
|
29
|
+
Loam::CustomFieldValue.where(indexable_type: type, indexable_id: record.id).delete_all
|
|
30
|
+
|
|
31
|
+
rows = model.custom_field_definitions.filter_map { |definition| row_for(record, definition, type) }
|
|
32
|
+
Loam::CustomFieldValue.insert_all(rows) if rows.any?
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def remove(record)
|
|
36
|
+
Loam::CustomFieldValue.where(indexable_type: index_type(record.class), indexable_id: record.id).delete_all
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def reindex(model)
|
|
40
|
+
Loam::CustomFieldValue.where(indexable_type: index_type(model)).delete_all
|
|
41
|
+
model.find_each { |record| project(record) }
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# --- query API ---
|
|
45
|
+
|
|
46
|
+
def filter(model, field_key, op, value = nil)
|
|
47
|
+
field_key = field_key.to_s
|
|
48
|
+
refuse_unknown_field!(model, field_key)
|
|
49
|
+
refuse_unreadable_field!(model, field_key)
|
|
50
|
+
raise Loam::Error, "unknown custom-field op #{op.inspect}" unless OPS.include?(op.to_s)
|
|
51
|
+
|
|
52
|
+
if covered?(model, field_key)
|
|
53
|
+
self.partial = false
|
|
54
|
+
index_filter(model, field_key, op, value)
|
|
55
|
+
else
|
|
56
|
+
# CORRECTNESS OVER SPEED: the index is known-incomplete for this field
|
|
57
|
+
# (coverage gap), so serve an authoritative JSON-scan result — never a
|
|
58
|
+
# silently-partial index set — AND enqueue a background reindex (deduped)
|
|
59
|
+
# to heal the gap so subsequent queries are fast again. `partial?` lets
|
|
60
|
+
# the caller surface an honest "results may be incomplete, reindexing…".
|
|
61
|
+
self.partial = true
|
|
62
|
+
schedule_reindex(model)
|
|
63
|
+
json_filter(model, field_key, op, value)
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# The index-backed relation (used when coverage is complete).
|
|
68
|
+
def index_filter(model, field_key, op, value)
|
|
69
|
+
rows = base(model).where(field_key: field_key)
|
|
70
|
+
|
|
71
|
+
if op.to_s == "blank"
|
|
72
|
+
present_ids = rows.where.not(value_text: [ nil, "" ]).select(:indexable_id)
|
|
73
|
+
return model.where.not(id: present_ids)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
model.where(id: predicate(rows, op.to_s, value).select(:indexable_id))
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Order a model's records by an indexed custom field (value_text — good for
|
|
80
|
+
# dictionary/string fields, the common case; numeric-aware ordering is a
|
|
81
|
+
# follow-up). Records with no value sort last (LEFT JOIN + NULLs).
|
|
82
|
+
def order(model, field_key, dir = :asc)
|
|
83
|
+
refuse_unknown_field!(model, field_key.to_s)
|
|
84
|
+
refuse_unreadable_field!(model, field_key.to_s)
|
|
85
|
+
# The LEFT JOIN keeps EVERY record (un-indexed ones sort as NULL), so an
|
|
86
|
+
# incomplete index mis-orders the gap but never drops a row. Flag it and
|
|
87
|
+
# heal in the background.
|
|
88
|
+
unless covered?(model, field_key.to_s)
|
|
89
|
+
self.partial = true
|
|
90
|
+
schedule_reindex(model)
|
|
91
|
+
end
|
|
92
|
+
table = Loam::CustomFieldValue.table_name
|
|
93
|
+
conn = model.connection
|
|
94
|
+
join = "LEFT JOIN #{table} ON #{table}.indexable_type = #{conn.quote(index_type(model))} " \
|
|
95
|
+
"AND #{table}.indexable_id = #{model.table_name}.id " \
|
|
96
|
+
"AND #{table}.field_key = #{conn.quote(field_key.to_s)} " \
|
|
97
|
+
"AND #{table}.tenant_id = #{Loam.tenant!.id}"
|
|
98
|
+
model.joins(join).order(Arel.sql("#{table}.value_text #{dir.to_s.casecmp('desc').zero? ? 'DESC' : 'ASC'}"))
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# --- coverage + self-heal (L-919) ---
|
|
102
|
+
|
|
103
|
+
# How complete the index is for a field: how many live records HAVE a value
|
|
104
|
+
# (the authoritative JSON column) vs how many index rows exist. The trust
|
|
105
|
+
# signal — "9,980 of 10,000 indexed" tells an operator whether the index is
|
|
106
|
+
# complete or drifting (legacy data, a bulk write that bypassed the hook, a
|
|
107
|
+
# field def added before a backfill). Counting expected does a JSON pass, so
|
|
108
|
+
# it's a periodic READOUT, never a per-row query cost.
|
|
109
|
+
def coverage(model, field_key)
|
|
110
|
+
field_key = field_key.to_s
|
|
111
|
+
exp = expected(model, field_key)
|
|
112
|
+
got = indexed(model, field_key)
|
|
113
|
+
{ field_key: field_key, expected: exp, indexed: got, complete: got >= exp,
|
|
114
|
+
ratio: exp.zero? ? 1.0 : (got.to_f / exp) }
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def covered?(model, field_key)
|
|
118
|
+
coverage(model, field_key)[:complete]
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def indexed(model, field_key)
|
|
122
|
+
base(model).where(field_key: field_key.to_s).where.not(value_text: [ nil, "" ]).count
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def expected(model, field_key)
|
|
126
|
+
return 0 unless model.column_names.include?("custom_fields")
|
|
127
|
+
|
|
128
|
+
key = field_key.to_s
|
|
129
|
+
model.pluck(:custom_fields).count { |cf| cf.is_a?(Hash) && cf[key].to_s.strip != "" }
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# Was the last filter/order served over an incomplete index? The admin surfaces
|
|
133
|
+
# this as an honest "results may be incomplete, reindexing…".
|
|
134
|
+
def partial?
|
|
135
|
+
Thread.current[:loam_index_partial] == true
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def partial=(value)
|
|
139
|
+
Thread.current[:loam_index_partial] = value
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# Enqueue a background reindex to heal a gap — DEDUPED so a hot gappy field
|
|
143
|
+
# doesn't enqueue one per request. In-process dedup here (single-process
|
|
144
|
+
# prototype); a DB/cache marker is the multi-process path.
|
|
145
|
+
def schedule_reindex(model)
|
|
146
|
+
key = [ Loam.tenant!.id, model.base_class.name ]
|
|
147
|
+
return if pending_reindex.include?(key)
|
|
148
|
+
|
|
149
|
+
pending_reindex << key
|
|
150
|
+
Loam::CustomFieldReindexJob.perform_later(Loam.tenant!.id, model.base_class.name)
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def clear_pending(tenant_id, model_name)
|
|
154
|
+
pending_reindex.delete([ tenant_id, model_name ])
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def reset_pending!
|
|
158
|
+
@pending_reindex = Set.new
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def pending_reindex
|
|
162
|
+
@pending_reindex ||= Set.new
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
# Authoritative correct filter over the JSON column — DB-agnostic (reads each
|
|
166
|
+
# record's cast custom_field), used only as the fallback when the index is
|
|
167
|
+
# incomplete. Slow (a scan), which is exactly why the heal runs.
|
|
168
|
+
def json_filter(model, field_key, op, value)
|
|
169
|
+
ids = model.find_each.select { |record| json_match?(record, field_key, op, value) }.map(&:id)
|
|
170
|
+
model.where(id: ids)
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def json_match?(record, field_key, op, value)
|
|
174
|
+
actual = (record.custom_field(field_key) rescue nil)
|
|
175
|
+
case op.to_s
|
|
176
|
+
when "eq" then actual.to_s == value.to_s
|
|
177
|
+
when "neq" then actual.to_s != value.to_s
|
|
178
|
+
when "present" then actual.to_s.strip != ""
|
|
179
|
+
when "blank" then actual.to_s.strip == ""
|
|
180
|
+
when "contains" then actual.to_s.include?(value.to_s)
|
|
181
|
+
when "gt" then actual.to_f > value.to_f
|
|
182
|
+
when "gte" then actual.to_f >= value.to_f
|
|
183
|
+
when "lt" then actual.to_f < value.to_f
|
|
184
|
+
when "lte" then actual.to_f <= value.to_f
|
|
185
|
+
end
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
# --- internals ---
|
|
189
|
+
|
|
190
|
+
def base(model)
|
|
191
|
+
Loam::CustomFieldValue.where(indexable_type: index_type(model)) # tenant-scoped by default_scope
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
def index_type(model_or_class)
|
|
195
|
+
klass = model_or_class.is_a?(Class) ? model_or_class : model_or_class.class
|
|
196
|
+
klass.base_class.name
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
def predicate(rows, op, value)
|
|
200
|
+
case op
|
|
201
|
+
when "eq" then rows.where(value_text: value.to_s)
|
|
202
|
+
when "neq" then rows.where.not(value_text: value.to_s)
|
|
203
|
+
when "contains" then rows.where("value_text LIKE ?", "%#{sanitize_like(value)}%")
|
|
204
|
+
when "present" then rows.where.not(value_text: [ nil, "" ])
|
|
205
|
+
when "gt" then numeric(rows, value, ">")
|
|
206
|
+
when "gte" then numeric(rows, value, ">=")
|
|
207
|
+
when "lt" then numeric(rows, value, "<")
|
|
208
|
+
when "lte" then numeric(rows, value, "<=")
|
|
209
|
+
end
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
# Numeric comparisons hit the indexed value_number column.
|
|
213
|
+
def numeric(rows, value, operator)
|
|
214
|
+
rows.where("value_number #{operator} ?", value.to_f)
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
def row_for(record, definition, type)
|
|
218
|
+
value = (record.custom_field(definition.name) rescue nil)
|
|
219
|
+
return nil if value.nil?
|
|
220
|
+
|
|
221
|
+
# ALL value columns are present (nil when unused) so insert_all sees a
|
|
222
|
+
# uniform key set across every row.
|
|
223
|
+
row = {
|
|
224
|
+
tenant_id: record.tenant_id, indexable_type: type, indexable_id: record.id,
|
|
225
|
+
field_key: definition.name, value_text: value.to_s,
|
|
226
|
+
value_number: nil, value_boolean: nil, value_datetime: nil
|
|
227
|
+
}
|
|
228
|
+
case definition.field_type
|
|
229
|
+
when "integer", "decimal" then row[:value_number] = value.to_f
|
|
230
|
+
when "boolean" then row[:value_boolean] = ActiveModel::Type::Boolean.new.cast(value)
|
|
231
|
+
when "date", "datetime" then row[:value_datetime] = (Time.zone.parse(value.to_s) rescue nil)
|
|
232
|
+
end
|
|
233
|
+
row
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
def refuse_unknown_field!(model, field_key)
|
|
237
|
+
known = model.respond_to?(:custom_field_definitions) && model.custom_field_definitions.exists?(name: field_key)
|
|
238
|
+
raise Loam::Error, "no custom field #{field_key.inspect} on #{model.name}" unless known
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
# THE oracle guard: filtering or sorting on a custom field the current role
|
|
242
|
+
# may not read would let a user infer restricted values by observing which
|
|
243
|
+
# records match. Enforced against the current actor's role; a system/background
|
|
244
|
+
# context (no actor — reindex, a business rule) is trusted and not gated.
|
|
245
|
+
def refuse_unreadable_field!(model, field_key)
|
|
246
|
+
actor = Loam::Current.actor
|
|
247
|
+
return if actor.nil?
|
|
248
|
+
|
|
249
|
+
definition = model.custom_field_definitions.find_by(name: field_key.to_s)
|
|
250
|
+
return if definition.nil? # unknown field is refuse_unknown_field!'s job
|
|
251
|
+
|
|
252
|
+
role = Loam::Membership.find_by(user_id: actor.id)&.role
|
|
253
|
+
return if definition.readable_by?(role)
|
|
254
|
+
|
|
255
|
+
raise Loam::FieldAccessError, "custom field #{field_key.inspect} is not readable by #{role.inspect}"
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
def sanitize_like(value)
|
|
259
|
+
value.to_s.gsub(/[\\%_]/) { |c| "\\#{c}" }
|
|
260
|
+
end
|
|
261
|
+
end
|
|
262
|
+
end
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
module Loam
|
|
2
|
+
# Runtime, migration-free fields — stored in the `custom_fields` json
|
|
3
|
+
# column every generated entity table carries. Included automatically by
|
|
4
|
+
# `rails g loam:entity`. Shape is declared at runtime via a
|
|
5
|
+
# Loam::FieldDefinition (typically through the admin "Field definitions"
|
|
6
|
+
# screen), not in code — that's what makes it migration-free.
|
|
7
|
+
#
|
|
8
|
+
# equipment.custom_field(:serial_number) # => "SN-123" (cast)
|
|
9
|
+
# equipment.set_custom_field(:serial_number, "SN-123")
|
|
10
|
+
#
|
|
11
|
+
# Reading/writing a name with no matching Loam::FieldDefinition for this
|
|
12
|
+
# tenant + entity raises Loam::UnknownCustomFieldError immediately — the
|
|
13
|
+
# same "fail loudly at the access site" philosophy as Loam.tenant!.
|
|
14
|
+
module CustomFields
|
|
15
|
+
extend ActiveSupport::Concern
|
|
16
|
+
|
|
17
|
+
included do
|
|
18
|
+
# Keep the read-model index (Loam::CustomFieldIndex) current so custom-field
|
|
19
|
+
# filter/sort is index-backed. Re-project only when the custom_fields column
|
|
20
|
+
# actually changed (a soft-delete touches only deleted_at, so the rows stay
|
|
21
|
+
# and the base scope hides the record — the L-912 posture). A no-op for a
|
|
22
|
+
# model with no custom_fields column. Guarded by respond_to? so Loam::
|
|
23
|
+
# CustomFields stays includable in a plain class for a DSL-only test.
|
|
24
|
+
if respond_to?(:after_save)
|
|
25
|
+
after_save :loam_reindex_custom_fields, if: :loam_custom_fields_dirty?
|
|
26
|
+
after_destroy :loam_deindex_custom_fields
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
class_methods do
|
|
31
|
+
def custom_field_definitions
|
|
32
|
+
Loam::FieldDefinition.where(entity_type: name)
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def custom_field(name)
|
|
37
|
+
definition = custom_field_definition!(name)
|
|
38
|
+
cast_custom_field(definition.field_type, custom_fields[definition.name])
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def set_custom_field(name, value)
|
|
42
|
+
definition = custom_field_definition!(name)
|
|
43
|
+
custom_fields[definition.name] = serialize_custom_field(definition.field_type, value)
|
|
44
|
+
value
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# The display label for a custom field. For a dictionary-typed field this is
|
|
48
|
+
# the matching entry's label; for every other type it is just the value. A
|
|
49
|
+
# dictionary value is stored as its plain code, so read/write is unchanged —
|
|
50
|
+
# only the human-facing rendering differs.
|
|
51
|
+
def custom_field_label(name)
|
|
52
|
+
definition = custom_field_definition!(name)
|
|
53
|
+
value = custom_field(name)
|
|
54
|
+
return value unless definition.field_type == "dictionary"
|
|
55
|
+
|
|
56
|
+
Loam::Dictionaries.label_for(definition.dictionary_key, value)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
private
|
|
60
|
+
|
|
61
|
+
def loam_reindex_custom_fields = Loam::CustomFieldIndex.project(self)
|
|
62
|
+
def loam_deindex_custom_fields = Loam::CustomFieldIndex.remove(self)
|
|
63
|
+
|
|
64
|
+
def loam_custom_fields_dirty?
|
|
65
|
+
return false unless self.class.respond_to?(:custom_field_definitions)
|
|
66
|
+
|
|
67
|
+
# On create every column counts as changed; on update, only re-project when
|
|
68
|
+
# the custom_fields json actually moved.
|
|
69
|
+
respond_to?(:saved_change_to_custom_fields?) ? saved_change_to_custom_fields? : true
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def custom_field_definition!(name)
|
|
73
|
+
custom_field_definitions_by_name.fetch(name.to_s) do
|
|
74
|
+
raise UnknownCustomFieldError, "No Loam::FieldDefinition named #{name.inspect} for #{self.class.name} " \
|
|
75
|
+
"(tenant #{Loam.tenant&.id.inspect})"
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Memoized per-instance: several fields are typically read/written per
|
|
80
|
+
# request, and definitions vary per tenant so they can't be cached on the
|
|
81
|
+
# class itself.
|
|
82
|
+
def custom_field_definitions_by_name
|
|
83
|
+
@custom_field_definitions_by_name ||= self.class.custom_field_definitions.index_by(&:name)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def cast_custom_field(type, raw)
|
|
87
|
+
return nil if raw.nil?
|
|
88
|
+
|
|
89
|
+
case type.to_s
|
|
90
|
+
when "integer" then raw.to_i
|
|
91
|
+
when "decimal" then BigDecimal(raw.to_s)
|
|
92
|
+
when "boolean" then ActiveModel::Type::Boolean.new.cast(raw)
|
|
93
|
+
when "date" then Date.parse(raw.to_s)
|
|
94
|
+
when "datetime" then Time.zone.parse(raw.to_s)
|
|
95
|
+
else raw.to_s
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def serialize_custom_field(type, value)
|
|
100
|
+
return nil if value.nil? || value == ""
|
|
101
|
+
|
|
102
|
+
case type.to_s
|
|
103
|
+
when "integer" then value.to_i
|
|
104
|
+
when "decimal" then value.to_s
|
|
105
|
+
when "boolean" then ActiveModel::Type::Boolean.new.cast(value)
|
|
106
|
+
when "date" then value.is_a?(Date) ? value.iso8601 : Date.parse(value.to_s).iso8601
|
|
107
|
+
when "datetime"
|
|
108
|
+
value.is_a?(Time) || value.is_a?(DateTime) ? value.utc.iso8601 : Time.zone.parse(value.to_s).utc.iso8601
|
|
109
|
+
else
|
|
110
|
+
value.to_s
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
module Loam
|
|
2
|
+
# Resolves the admin dashboard for the current tenant/actor: the ordered,
|
|
3
|
+
# role-visible, computed widgets to show. Uses the tenant's configured
|
|
4
|
+
# Loam::DashboardWidget rows (a manager arranges them); falls back to every
|
|
5
|
+
# registered widget in registration order when the tenant has configured none,
|
|
6
|
+
# so the dashboard is useful out of the box.
|
|
7
|
+
module Dashboard
|
|
8
|
+
module_function
|
|
9
|
+
|
|
10
|
+
def for(actor:, role:)
|
|
11
|
+
configured = Loam::DashboardWidget.active.ordered.pluck(:widget_key)
|
|
12
|
+
keys = configured.presence || Loam::Widgets.keys
|
|
13
|
+
|
|
14
|
+
keys.filter_map { |key| Loam::Widgets.resolve(key, actor: actor, role: role) }
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
module Loam
|
|
2
|
+
# Read API for per-tenant managed lookup lists (Loam::Dictionary /
|
|
3
|
+
# Loam::DictionaryEntry). Everything is tenant-scoped automatically and
|
|
4
|
+
# memoized per request in Loam::Current.dictionary_cache (keyed with the tenant
|
|
5
|
+
# id, so an as_tenant switch mid-request can't read another tenant's list) —
|
|
6
|
+
# the same simple prototype cache as Loam::Configs.
|
|
7
|
+
#
|
|
8
|
+
# Loam::Dictionaries.entries("damage_severity") # active, ordered
|
|
9
|
+
# Loam::Dictionaries.default("damage_severity") # the default entry
|
|
10
|
+
# Loam::Dictionaries.label_for("damage_severity", "critical") # => "Critical"
|
|
11
|
+
module Dictionaries
|
|
12
|
+
class << self
|
|
13
|
+
# The dictionary for the current tenant, or nil.
|
|
14
|
+
def get(key)
|
|
15
|
+
memo([:dict, key.to_s]) { Loam::Dictionary.find_by(key: key.to_s) }
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# Active entries, ordered by position. Empty for an unknown key.
|
|
19
|
+
def entries(key)
|
|
20
|
+
dictionary = get(key)
|
|
21
|
+
return [] unless dictionary
|
|
22
|
+
|
|
23
|
+
memo([:entries, key.to_s]) { dictionary.entries.active.ordered.to_a }
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# The default entry (the first flagged one), or nil.
|
|
27
|
+
def default(key)
|
|
28
|
+
entries(key).find(&:is_default?)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# The label for a stored value — or the raw value itself when the key or
|
|
32
|
+
# value is unknown, so an old/foreign value still renders as something.
|
|
33
|
+
def label_for(key, value)
|
|
34
|
+
return value if value.blank?
|
|
35
|
+
|
|
36
|
+
entry = entries(key).find { |e| e.value == value.to_s }
|
|
37
|
+
entry ? entry.label : value
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def clear_cache
|
|
41
|
+
Loam::Current.dictionary_cache = {}
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
private
|
|
45
|
+
|
|
46
|
+
def memo(subkey)
|
|
47
|
+
store = (Loam::Current.dictionary_cache ||= {})
|
|
48
|
+
cache_key = subkey + [ Loam.tenant&.id ]
|
|
49
|
+
return store[cache_key] if store.key?(cache_key)
|
|
50
|
+
|
|
51
|
+
store[cache_key] = yield
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
module Loam
|
|
2
|
+
# Durable (persistent) event subscribers — the twin of Loam::Events.subscribe,
|
|
3
|
+
# and the formal contract L-706 pins down.
|
|
4
|
+
#
|
|
5
|
+
# THE CONTRACT
|
|
6
|
+
# * Ephemeral — Loam::Events.subscribe(&block): runs INLINE in the publisher's
|
|
7
|
+
# thread, synchronously, best-effort. An exception in the block PROPAGATES
|
|
8
|
+
# into whatever published the event. No persistence, no retry. Right for
|
|
9
|
+
# cheap in-process fan-out (the webhook dispatcher enqueues its own jobs
|
|
10
|
+
# this way).
|
|
11
|
+
# * Persistent — Loam::DurableEvents.register(...): publishing commits a
|
|
12
|
+
# Loam::EventDelivery row in the event's tenant, then hands the handler to a
|
|
13
|
+
# background job. The handler's exception is CONTAINED in the job; delivery
|
|
14
|
+
# is retried with backoff and, past MAX_ATTEMPTS, parked as `dead` for an
|
|
15
|
+
# operator (Admin::EventDeliveriesController).
|
|
16
|
+
#
|
|
17
|
+
# GUARANTEE: at-least-once, UNORDERED. Handlers MUST be idempotent — a retry or
|
|
18
|
+
# the sweep may deliver the same event twice. Durability is of DELIVERY, not
|
|
19
|
+
# CAPTURE: an event whose process dies between the after_commit and the publish
|
|
20
|
+
# leaves no row and is lost, exactly as today. This feature makes what WAS
|
|
21
|
+
# published arrive; it does not resurrect what was never published.
|
|
22
|
+
#
|
|
23
|
+
# SECURITY: a handler is resolved from THIS in-memory registry by key, populated
|
|
24
|
+
# at boot from trusted code — never constantized from the stored row. If the key
|
|
25
|
+
# is unknown at delivery time (handler removed since enqueue) the row is parked
|
|
26
|
+
# `dead`; an arbitrary class is never executed off a DB value. (Same posture as
|
|
27
|
+
# the scheduler's job_class allowlist.)
|
|
28
|
+
module DurableEvents
|
|
29
|
+
MAX_ATTEMPTS = 5
|
|
30
|
+
# Backoff (seconds) indexed by attempt number; the last value is the cap.
|
|
31
|
+
BACKOFF = [ 0, 60, 300, 1800, 7200 ].freeze
|
|
32
|
+
SWEEP_KEY = "loam_event_redelivery_sweep".freeze
|
|
33
|
+
|
|
34
|
+
class << self
|
|
35
|
+
# --- declarative registry (mirrors Loam::Scheduler.register) ---
|
|
36
|
+
|
|
37
|
+
# key: a stable identifier stored on every delivery row it produces.
|
|
38
|
+
# to: an event name ("billing.invoice.paid") or a domain prefix
|
|
39
|
+
# ("billing.") — same matching rule as Events/webhooks.
|
|
40
|
+
# call: a class (or anything) responding to .call(event_name, payload),
|
|
41
|
+
# or its name as a String. Resolved from this registry, never the row.
|
|
42
|
+
def register(key:, to:, call:)
|
|
43
|
+
registry[key.to_s] = { key: key.to_s, pattern: to.to_s, handler: call }
|
|
44
|
+
key.to_s
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def registered = registry.values
|
|
48
|
+
|
|
49
|
+
def reset_registry!
|
|
50
|
+
@registry = {}
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def subscribers_for(event_name)
|
|
54
|
+
registered.select { |entry| Loam::Events.pattern_matches?(entry[:pattern], event_name) }
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def handler_for(key)
|
|
58
|
+
entry = registry[key.to_s]
|
|
59
|
+
entry && resolve(entry[:handler])
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# --- wiring (mirrors Loam::Webhooks.subscribe!) ---
|
|
63
|
+
|
|
64
|
+
# Wired once from Loam::Engine. Idempotent: subscribing twice would persist
|
|
65
|
+
# every event twice.
|
|
66
|
+
def subscribe!
|
|
67
|
+
@subscription ||= Loam::Events.subscribe_all { |event_name, payload| capture(event_name, payload) }
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Persist a delivery row per matching durable subscriber, in the event's
|
|
71
|
+
# tenant, then nudge a job per row. The ROW is the durable record; the job
|
|
72
|
+
# is only the accelerator (the sweep redelivers rows whose job was lost).
|
|
73
|
+
def capture(event_name, payload)
|
|
74
|
+
tenant = Loam::Tenant.find_by(id: payload[:tenant_id])
|
|
75
|
+
return if tenant.nil? # nil-tenant events are not durably delivered (as Webhooks.dispatch)
|
|
76
|
+
|
|
77
|
+
matches = subscribers_for(event_name)
|
|
78
|
+
return if matches.empty?
|
|
79
|
+
|
|
80
|
+
# Only JSON-safe primitives cross into the row/job — payloads are ids and
|
|
81
|
+
# scalars by convention, never records (same rule as the webhook path).
|
|
82
|
+
deliverable = payload.transform_keys(&:to_s)
|
|
83
|
+
|
|
84
|
+
Loam.as_tenant(tenant) do
|
|
85
|
+
matches.each do |sub|
|
|
86
|
+
delivery = Loam::EventDelivery.create!(
|
|
87
|
+
subscriber_key: sub[:key], event_name: event_name.to_s,
|
|
88
|
+
payload: deliverable, status: "pending", attempts: 0
|
|
89
|
+
)
|
|
90
|
+
Loam::EventDeliveryJob.perform_later(tenant.id, delivery.id)
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# --- delivery (called by Loam::EventDeliveryJob) ---
|
|
96
|
+
|
|
97
|
+
# Run one delivery and advance the row's state. Manual, ROW-STATE retries —
|
|
98
|
+
# deliberately NOT ActiveJob retry_on, which keeps retry state in the queue
|
|
99
|
+
# (the very thing this feature stops trusting). Safe to call twice on one
|
|
100
|
+
# row (at-least-once): a delivered/dead/not-yet-due row is a no-op.
|
|
101
|
+
def deliver(delivery, now: Time.current)
|
|
102
|
+
return unless delivery.status == "pending"
|
|
103
|
+
return if delivery.next_attempt_at && delivery.next_attempt_at > now # duplicate nudge, backoff not elapsed
|
|
104
|
+
|
|
105
|
+
Loam::Telemetry.span("durable_event_delivery",
|
|
106
|
+
subscriber_key: delivery.subscriber_key, event_name: delivery.event_name) do
|
|
107
|
+
run_delivery(delivery, now)
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def run_delivery(delivery, now)
|
|
112
|
+
handler = handler_for(delivery.subscriber_key)
|
|
113
|
+
if handler.nil?
|
|
114
|
+
delivery.update!(status: "dead",
|
|
115
|
+
last_error: "no registered subscriber #{delivery.subscriber_key.inspect}")
|
|
116
|
+
return
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
begin
|
|
120
|
+
handler.call(delivery.event_name, delivery.payload_hash)
|
|
121
|
+
delivery.update!(status: "delivered", delivered_at: now, last_error: nil)
|
|
122
|
+
rescue StandardError => error
|
|
123
|
+
attempts = delivery.attempts + 1
|
|
124
|
+
if attempts >= MAX_ATTEMPTS
|
|
125
|
+
delivery.update!(status: "dead", attempts: attempts, last_error: format_error(error))
|
|
126
|
+
else
|
|
127
|
+
delivery.update!(attempts: attempts, next_attempt_at: now + backoff_for(attempts),
|
|
128
|
+
last_error: format_error(error))
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# THE durability guarantee: re-enqueue due-but-undelivered rows whose job
|
|
134
|
+
# was lost (worker crash, dropped message, async adapter racing the txn).
|
|
135
|
+
# Tenant-scoped — called from the per-tenant sweep the engine registers, so
|
|
136
|
+
# no cross-tenant scan is needed.
|
|
137
|
+
def redeliver_stuck(now: Time.current, limit: 500)
|
|
138
|
+
count = 0
|
|
139
|
+
Loam::EventDelivery.due(now).limit(limit).find_each do |delivery|
|
|
140
|
+
Loam::EventDeliveryJob.perform_later(delivery.tenant_id, delivery.id)
|
|
141
|
+
count += 1
|
|
142
|
+
end
|
|
143
|
+
count
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
private
|
|
147
|
+
|
|
148
|
+
def registry
|
|
149
|
+
@registry ||= {}
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# A callable passed directly (class with .call, Proc) is used as-is; a
|
|
153
|
+
# String/Symbol is constantized HERE, from the trusted registry value.
|
|
154
|
+
def resolve(handler)
|
|
155
|
+
if handler.is_a?(String) || handler.is_a?(Symbol)
|
|
156
|
+
klass = handler.to_s.safe_constantize
|
|
157
|
+
return klass if klass.respond_to?(:call)
|
|
158
|
+
|
|
159
|
+
nil
|
|
160
|
+
elsif handler.respond_to?(:call)
|
|
161
|
+
handler
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def backoff_for(attempts)
|
|
166
|
+
BACKOFF[attempts] || BACKOFF.last
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def format_error(error)
|
|
170
|
+
"#{error.class}: #{error.message}"
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
end
|