moku6 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/LICENSE.txt +21 -0
- data/README.md +131 -0
- data/exe/moku6 +6 -0
- data/lib/moku6/catalog.rb +23 -0
- data/lib/moku6/cli.rb +118 -0
- data/lib/moku6/config.rb +61 -0
- data/lib/moku6/differ.rb +74 -0
- data/lib/moku6/envelope_schema.rb +56 -0
- data/lib/moku6/event.rb +54 -0
- data/lib/moku6/generate.rb +95 -0
- data/lib/moku6/generators/base_generator.rb +31 -0
- data/lib/moku6/generators/bigquery_generator.rb +29 -0
- data/lib/moku6/generators/cloud_events_generator.rb +47 -0
- data/lib/moku6/generators/docs_generator.rb +49 -0
- data/lib/moku6/generators/event_catalog_generator.rb +57 -0
- data/lib/moku6/generators/example_generator.rb +66 -0
- data/lib/moku6/generators/json_schema_generator.rb +23 -0
- data/lib/moku6/generators/open_api_generator.rb +60 -0
- data/lib/moku6/generators/outbox_generator.rb +96 -0
- data/lib/moku6/generators/rails_generator.rb +84 -0
- data/lib/moku6/generators/ruby_generator.rb +27 -0
- data/lib/moku6/generators/typescript_generator.rb +59 -0
- data/lib/moku6/generators.rb +29 -0
- data/lib/moku6/initializer.rb +44 -0
- data/lib/moku6/linter.rb +60 -0
- data/lib/moku6/loader.rb +31 -0
- data/lib/moku6/offense.rb +13 -0
- data/lib/moku6/reporter.rb +71 -0
- data/lib/moku6/result.rb +23 -0
- data/lib/moku6/rules/action_naming_rule.rb +18 -0
- data/lib/moku6/rules/base_rule.rb +37 -0
- data/lib/moku6/rules/example_consistency_rule.rb +53 -0
- data/lib/moku6/rules/label_description_rule.rb +21 -0
- data/lib/moku6/rules/pii_field_name_heuristic_rule.rb +45 -0
- data/lib/moku6/rules/privacy_masking_rule.rb +21 -0
- data/lib/moku6/rules/retention_rule.rb +19 -0
- data/lib/moku6/rules/schema_rule.rb +31 -0
- data/lib/moku6/rules/uniqueness_rule.rb +22 -0
- data/lib/moku6/rules/visibility_rule.rb +27 -0
- data/lib/moku6/version.rb +6 -0
- data/lib/moku6.rb +55 -0
- data/schemas/audit-event.schema.json +85 -0
- data/sig/generated/moku6/catalog.rbs +22 -0
- data/sig/generated/moku6/config.rbs +43 -0
- data/sig/generated/moku6/differ.rbs +28 -0
- data/sig/generated/moku6/envelope_schema.rbs +19 -0
- data/sig/generated/moku6/event.rbs +51 -0
- data/sig/generated/moku6/generators/base_generator.rbs +22 -0
- data/sig/generated/moku6/generators/bigquery_generator.rbs +12 -0
- data/sig/generated/moku6/generators/cloud_events_generator.rbs +19 -0
- data/sig/generated/moku6/generators/docs_generator.rbs +18 -0
- data/sig/generated/moku6/generators/event_catalog_generator.rbs +16 -0
- data/sig/generated/moku6/generators/example_generator.rbs +23 -0
- data/sig/generated/moku6/generators/json_schema_generator.rbs +12 -0
- data/sig/generated/moku6/generators/open_api_generator.rbs +20 -0
- data/sig/generated/moku6/generators/outbox_generator.rbs +23 -0
- data/sig/generated/moku6/generators/rails_generator.rbs +23 -0
- data/sig/generated/moku6/generators/ruby_generator.rbs +15 -0
- data/sig/generated/moku6/generators/typescript_generator.rbs +20 -0
- data/sig/generated/moku6/generators.rbs +13 -0
- data/sig/generated/moku6/initializer.rbs +23 -0
- data/sig/generated/moku6/linter.rbs +31 -0
- data/sig/generated/moku6/loader.rbs +15 -0
- data/sig/generated/moku6/reporter.rbs +30 -0
- data/sig/generated/moku6/result.rbs +22 -0
- data/sig/generated/moku6/rules/action_naming_rule.rbs +10 -0
- data/sig/generated/moku6/rules/base_rule.rbs +23 -0
- data/sig/generated/moku6/rules/example_consistency_rule.rbs +18 -0
- data/sig/generated/moku6/rules/label_description_rule.rbs +15 -0
- data/sig/generated/moku6/rules/pii_field_name_heuristic_rule.rbs +21 -0
- data/sig/generated/moku6/rules/privacy_masking_rule.rbs +10 -0
- data/sig/generated/moku6/rules/retention_rule.rbs +10 -0
- data/sig/generated/moku6/rules/schema_rule.rbs +15 -0
- data/sig/generated/moku6/rules/uniqueness_rule.rbs +11 -0
- data/sig/generated/moku6/rules/visibility_rule.rbs +10 -0
- data/sig/generated/moku6/version.rbs +5 -0
- data/sig/generated/moku6.rbs +9 -0
- data/sig/manual/dependencies.rbs +13 -0
- data/sig/manual/offense.rbs +13 -0
- data/templates/init/.moku6.yml +18 -0
- data/templates/init/catalog/employee.updated.yaml +36 -0
- metadata +141 -0
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# rbs_inline: enabled
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module Moku6
|
|
5
|
+
module Generators
|
|
6
|
+
# Emits BigQuery DDL for the shared envelope (design section 12.5).
|
|
7
|
+
# Fields live in the `metadata JSON` column, consistent with section 8.
|
|
8
|
+
class BigQueryGenerator < BaseGenerator
|
|
9
|
+
#: () -> String
|
|
10
|
+
def render
|
|
11
|
+
<<~SQL
|
|
12
|
+
-- #{AUTOGEN_NOTE}
|
|
13
|
+
CREATE TABLE audit_events (
|
|
14
|
+
event_id STRING NOT NULL,
|
|
15
|
+
action STRING NOT NULL,
|
|
16
|
+
occurred_at TIMESTAMP NOT NULL,
|
|
17
|
+
actor JSON,
|
|
18
|
+
target JSON,
|
|
19
|
+
metadata JSON,
|
|
20
|
+
privacy JSON,
|
|
21
|
+
created_at TIMESTAMP NOT NULL
|
|
22
|
+
)
|
|
23
|
+
PARTITION BY DATE(occurred_at)
|
|
24
|
+
CLUSTER BY action;
|
|
25
|
+
SQL
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# rbs_inline: enabled
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "fileutils"
|
|
5
|
+
require "json"
|
|
6
|
+
|
|
7
|
+
module Moku6
|
|
8
|
+
module Generators
|
|
9
|
+
# Emits a CloudEvents (v1.0, structured JSON mode) validation JSON Schema per
|
|
10
|
+
# action. The CloudEvents `type` maps to the action and `data` carries the
|
|
11
|
+
# definition's metadata (design section 13: CloudEvents conversion).
|
|
12
|
+
class CloudEventsGenerator < BaseGenerator
|
|
13
|
+
SPEC_VERSION = "1.0" #: String
|
|
14
|
+
|
|
15
|
+
#: (String dir) -> String
|
|
16
|
+
def write(dir)
|
|
17
|
+
FileUtils.mkdir_p(dir)
|
|
18
|
+
@catalog.sorted.each do |e|
|
|
19
|
+
File.write(File.join(dir, "#{e.action}.json"),
|
|
20
|
+
JSON.pretty_generate(schema_for(e)) + "\n")
|
|
21
|
+
end
|
|
22
|
+
dir
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
private
|
|
26
|
+
|
|
27
|
+
def schema_for(e)
|
|
28
|
+
{
|
|
29
|
+
"$schema" => "https://json-schema.org/draft/2020-12/schema",
|
|
30
|
+
"title" => "#{e.action} (CloudEvents)",
|
|
31
|
+
"type" => "object",
|
|
32
|
+
"required" => %w[specversion id source type data],
|
|
33
|
+
"properties" => {
|
|
34
|
+
"specversion" => {"const" => SPEC_VERSION},
|
|
35
|
+
"id" => {"type" => "string"},
|
|
36
|
+
"source" => {"type" => "string"},
|
|
37
|
+
"type" => {"const" => e.action},
|
|
38
|
+
"subject" => {"type" => "string"},
|
|
39
|
+
"time" => {"type" => "string", "format" => "date-time"},
|
|
40
|
+
"datacontenttype" => {"const" => "application/json"},
|
|
41
|
+
"data" => EnvelopeSchema.metadata_schema(e)
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# rbs_inline: enabled
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "fileutils"
|
|
5
|
+
|
|
6
|
+
module Moku6
|
|
7
|
+
module Generators
|
|
8
|
+
class DocsGenerator < BaseGenerator
|
|
9
|
+
#: () -> String
|
|
10
|
+
def render
|
|
11
|
+
lines = ["# Audit Events", "", summary_table, ""]
|
|
12
|
+
@catalog.sorted.each { |e| lines.concat(detail(e)) }
|
|
13
|
+
lines.join("\n")
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
private
|
|
17
|
+
|
|
18
|
+
def summary_table
|
|
19
|
+
rows = @catalog.sorted.map do |e|
|
|
20
|
+
[
|
|
21
|
+
"`#{e.action}`", e.label,
|
|
22
|
+
yn(e.required?),
|
|
23
|
+
yn(e.visibility&.dig("customer_visible")),
|
|
24
|
+
(e.privacy&.dig("contains_personal_data") ? "Yes" : "No"),
|
|
25
|
+
"#{e.retention&.dig("years")} years"
|
|
26
|
+
]
|
|
27
|
+
end
|
|
28
|
+
header = "| Event | Label | Required | Customer visible | Personal data | Retention |"
|
|
29
|
+
sep = "|---|---|---|---|---|---|"
|
|
30
|
+
([header, sep] + rows.map { |r| "| #{r.join(" | ")} |" }).join("\n")
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def detail(e)
|
|
34
|
+
out = ["## #{e.action} — #{e.label}", "", e.description.to_s, ""]
|
|
35
|
+
unless e.fields.empty?
|
|
36
|
+
out << "| Field | Type | Required | Description |"
|
|
37
|
+
out << "|---|---|---|---|"
|
|
38
|
+
e.fields.each do |name, f|
|
|
39
|
+
out << "| #{name} | #{f["type"]} | #{yn(f["required"])} | #{f["description"]} |"
|
|
40
|
+
end
|
|
41
|
+
out << ""
|
|
42
|
+
end
|
|
43
|
+
out
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def yn(v) = v ? "Yes" : "No"
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# rbs_inline: enabled
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "fileutils"
|
|
5
|
+
|
|
6
|
+
module Moku6
|
|
7
|
+
module Generators
|
|
8
|
+
# Emits EventCatalog (eventcatalog.dev) compatible event documents, one per
|
|
9
|
+
# action at <action>/index.md (design section 13: EventCatalog integration).
|
|
10
|
+
class EventCatalogGenerator < BaseGenerator
|
|
11
|
+
#: (String dir) -> String
|
|
12
|
+
def write(dir)
|
|
13
|
+
FileUtils.mkdir_p(dir)
|
|
14
|
+
@catalog.sorted.each do |e|
|
|
15
|
+
event_dir = File.join(dir, e.action.to_s)
|
|
16
|
+
FileUtils.mkdir_p(event_dir)
|
|
17
|
+
File.write(File.join(event_dir, "index.md"), document(e))
|
|
18
|
+
end
|
|
19
|
+
dir
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
private
|
|
23
|
+
|
|
24
|
+
def document(e)
|
|
25
|
+
out = []
|
|
26
|
+
out << "---"
|
|
27
|
+
out << "id: #{e.action}"
|
|
28
|
+
out << "name: #{e.label}"
|
|
29
|
+
out << "version: #{Moku6::VERSION}"
|
|
30
|
+
out << "summary: #{e.description.to_s.inspect}"
|
|
31
|
+
out << "badges:"
|
|
32
|
+
out << " - content: #{e.category}"
|
|
33
|
+
out << " textColor: blue"
|
|
34
|
+
out << " backgroundColor: blue"
|
|
35
|
+
out << "---"
|
|
36
|
+
out << ""
|
|
37
|
+
out << "<!-- #{AUTOGEN_NOTE} -->"
|
|
38
|
+
out << ""
|
|
39
|
+
out << "## Overview"
|
|
40
|
+
out << ""
|
|
41
|
+
out << e.description.to_s
|
|
42
|
+
out << ""
|
|
43
|
+
unless e.fields.empty?
|
|
44
|
+
out << "## Schema"
|
|
45
|
+
out << ""
|
|
46
|
+
out << "| Field | Type | Required | Description |"
|
|
47
|
+
out << "|---|---|---|---|"
|
|
48
|
+
e.fields.each do |name, f|
|
|
49
|
+
out << "| #{name} | #{f["type"]} | #{f["required"] ? "Yes" : "No"} | #{f["description"]} |"
|
|
50
|
+
end
|
|
51
|
+
out << ""
|
|
52
|
+
end
|
|
53
|
+
out.join("\n")
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# rbs_inline: enabled
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "fileutils"
|
|
5
|
+
require "json"
|
|
6
|
+
|
|
7
|
+
module Moku6
|
|
8
|
+
module Generators
|
|
9
|
+
# Generates a minimal sample event (envelope) satisfying required fields per definition.
|
|
10
|
+
class ExampleGenerator < BaseGenerator
|
|
11
|
+
#: (String dir) -> String
|
|
12
|
+
def write(dir)
|
|
13
|
+
FileUtils.mkdir_p(dir)
|
|
14
|
+
@catalog.sorted.each do |e|
|
|
15
|
+
File.write(File.join(dir, "#{e.action}.json"),
|
|
16
|
+
JSON.pretty_generate(example_for(e)) + "\n")
|
|
17
|
+
end
|
|
18
|
+
dir
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
private
|
|
22
|
+
|
|
23
|
+
def example_for(e)
|
|
24
|
+
{
|
|
25
|
+
"event_id" => "00000000-0000-0000-0000-000000000000",
|
|
26
|
+
"action" => e.action,
|
|
27
|
+
"occurred_at" => "2026-01-01T00:00:00Z",
|
|
28
|
+
"actor" => actor_sample(e),
|
|
29
|
+
"target" => target_sample(e),
|
|
30
|
+
"metadata" => metadata_sample(e),
|
|
31
|
+
"privacy" => {"masked_fields" => e.privacy&.dig("masked_fields") || []},
|
|
32
|
+
"created_at" => "2026-01-01T00:00:00Z"
|
|
33
|
+
}
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def actor_sample(e)
|
|
37
|
+
type = (e.actor.is_a?(Hash) && e.actor["type"]) || "user"
|
|
38
|
+
{"type" => type, "id" => "actor_1"}
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def target_sample(e)
|
|
42
|
+
type = (e.target.is_a?(Hash) && e.target["type"]) || "target"
|
|
43
|
+
{"type" => type, "id" => "target_1"}
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def metadata_sample(e)
|
|
47
|
+
e.fields.each_with_object({}) do |(name, f), h|
|
|
48
|
+
next unless f["required"]
|
|
49
|
+
|
|
50
|
+
h[name] = sample_value(f["type"])
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def sample_value(type)
|
|
55
|
+
case type
|
|
56
|
+
when "integer", "number" then 0
|
|
57
|
+
when "boolean" then false
|
|
58
|
+
when "array" then []
|
|
59
|
+
when "object" then {}
|
|
60
|
+
when "timestamp" then "2026-01-01T00:00:00Z"
|
|
61
|
+
else "example"
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# rbs_inline: enabled
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "fileutils"
|
|
5
|
+
require "json"
|
|
6
|
+
|
|
7
|
+
module Moku6
|
|
8
|
+
module Generators
|
|
9
|
+
# Emits a runtime event (envelope) validation JSON Schema per action.
|
|
10
|
+
# write(dir) takes a directory and writes multiple files.
|
|
11
|
+
class JsonSchemaGenerator < BaseGenerator
|
|
12
|
+
#: (String dir) -> String
|
|
13
|
+
def write(dir)
|
|
14
|
+
FileUtils.mkdir_p(dir)
|
|
15
|
+
@catalog.sorted.each do |e|
|
|
16
|
+
File.write(File.join(dir, "#{e.action}.json"),
|
|
17
|
+
JSON.pretty_generate(EnvelopeSchema.for(e)) + "\n")
|
|
18
|
+
end
|
|
19
|
+
dir
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# rbs_inline: enabled
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "yaml"
|
|
5
|
+
|
|
6
|
+
module Moku6
|
|
7
|
+
module Generators
|
|
8
|
+
# Emits a single OpenAPI 3.1 document describing audit events as webhooks,
|
|
9
|
+
# with one component schema per action (design section 13: OpenAPI output).
|
|
10
|
+
class OpenApiGenerator < BaseGenerator
|
|
11
|
+
#: () -> String
|
|
12
|
+
def render
|
|
13
|
+
"# #{AUTOGEN_NOTE}\n" + YAML.dump(document)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
private
|
|
17
|
+
|
|
18
|
+
def document
|
|
19
|
+
{
|
|
20
|
+
"openapi" => "3.1.0",
|
|
21
|
+
"info" => {
|
|
22
|
+
"title" => "Audit Events",
|
|
23
|
+
"version" => Moku6::VERSION,
|
|
24
|
+
"description" => "Audit event webhooks generated by moku6."
|
|
25
|
+
},
|
|
26
|
+
"webhooks" => webhooks,
|
|
27
|
+
"components" => {"schemas" => schemas}
|
|
28
|
+
}
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def webhooks
|
|
32
|
+
@catalog.sorted.each_with_object({}) do |e, h|
|
|
33
|
+
h[e.action] = {
|
|
34
|
+
"post" => {
|
|
35
|
+
"summary" => e.label.to_s,
|
|
36
|
+
"description" => e.description.to_s,
|
|
37
|
+
"requestBody" => {
|
|
38
|
+
"required" => true,
|
|
39
|
+
"content" => {
|
|
40
|
+
"application/json" => {
|
|
41
|
+
"schema" => {"$ref" => "#/components/schemas/#{e.action}"}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
},
|
|
45
|
+
"responses" => {"200" => {"description" => "Event accepted"}}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def schemas
|
|
52
|
+
@catalog.sorted.each_with_object({}) do |e, h|
|
|
53
|
+
schema = EnvelopeSchema.for(e)
|
|
54
|
+
schema.delete("$schema")
|
|
55
|
+
h[e.action] = schema
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
# rbs_inline: enabled
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "fileutils"
|
|
5
|
+
|
|
6
|
+
module Moku6
|
|
7
|
+
module Generators
|
|
8
|
+
# Emits a sample Rails transactional outbox for audit events: a migration,
|
|
9
|
+
# an ActiveRecord model, and a dispatcher (design section 13: Outbox sample).
|
|
10
|
+
# The files are a starting point and are meant to be edited.
|
|
11
|
+
class OutboxGenerator < BaseGenerator
|
|
12
|
+
FILES = {
|
|
13
|
+
"db/migrate/0001_create_audit_event_outbox.rb" => :migration,
|
|
14
|
+
"app/models/audit_event_outbox.rb" => :model,
|
|
15
|
+
"app/services/audit_event_dispatcher.rb" => :dispatcher
|
|
16
|
+
}.freeze #: Hash[String, Symbol]
|
|
17
|
+
|
|
18
|
+
#: (String dir) -> String
|
|
19
|
+
def write(dir)
|
|
20
|
+
FILES.each do |rel, kind|
|
|
21
|
+
path = File.join(dir, rel)
|
|
22
|
+
FileUtils.mkdir_p(File.dirname(path))
|
|
23
|
+
File.write(path, send(kind))
|
|
24
|
+
end
|
|
25
|
+
dir
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
private
|
|
29
|
+
|
|
30
|
+
def migration
|
|
31
|
+
<<~RUBY
|
|
32
|
+
# #{AUTOGEN_NOTE}
|
|
33
|
+
class CreateAuditEventOutbox < ActiveRecord::Migration[7.1]
|
|
34
|
+
def change
|
|
35
|
+
create_table :audit_event_outbox do |t|
|
|
36
|
+
t.string :event_id, null: false
|
|
37
|
+
t.string :action, null: false
|
|
38
|
+
t.jsonb :payload, null: false
|
|
39
|
+
t.datetime :occurred_at, null: false
|
|
40
|
+
t.datetime :dispatched_at
|
|
41
|
+
t.timestamps
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
add_index :audit_event_outbox, :event_id, unique: true
|
|
45
|
+
add_index :audit_event_outbox, :dispatched_at
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
RUBY
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def model
|
|
52
|
+
<<~RUBY
|
|
53
|
+
# frozen_string_literal: true
|
|
54
|
+
# #{AUTOGEN_NOTE}
|
|
55
|
+
class AuditEventOutbox < ApplicationRecord
|
|
56
|
+
self.table_name = "audit_event_outbox"
|
|
57
|
+
|
|
58
|
+
scope :pending, -> { where(dispatched_at: nil) }
|
|
59
|
+
|
|
60
|
+
# Wire this as the AuditEvents sink:
|
|
61
|
+
# AuditEvents.sink = ->(event) { AuditEventOutbox.enqueue(event) }
|
|
62
|
+
def self.enqueue(event)
|
|
63
|
+
create!(
|
|
64
|
+
event_id: event.fetch("event_id"),
|
|
65
|
+
action: event.fetch("action"),
|
|
66
|
+
payload: event,
|
|
67
|
+
occurred_at: event.fetch("occurred_at")
|
|
68
|
+
)
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
RUBY
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def dispatcher
|
|
75
|
+
<<~RUBY
|
|
76
|
+
# frozen_string_literal: true
|
|
77
|
+
# #{AUTOGEN_NOTE}
|
|
78
|
+
# Drains the outbox and forwards each event to a delivery backend
|
|
79
|
+
# (HTTP endpoint, Pub/Sub, Kafka, BigQuery, ...). Run it periodically.
|
|
80
|
+
class AuditEventDispatcher
|
|
81
|
+
def initialize(delivery:)
|
|
82
|
+
@delivery = delivery
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def run(batch_size: 100)
|
|
86
|
+
AuditEventOutbox.pending.order(:id).limit(batch_size).each do |record|
|
|
87
|
+
@delivery.call(record.payload)
|
|
88
|
+
record.update!(dispatched_at: Time.current)
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
RUBY
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# rbs_inline: enabled
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module Moku6
|
|
5
|
+
module Generators
|
|
6
|
+
# Emits a Ruby SDK with one typed emitter method per action. Each method
|
|
7
|
+
# builds the canonical envelope and hands it to a configurable sink, so a
|
|
8
|
+
# Rails app can wire it to an outbox (design section 13: Rails SDK).
|
|
9
|
+
class RailsGenerator < BaseGenerator
|
|
10
|
+
#: () -> String
|
|
11
|
+
def render
|
|
12
|
+
out = ["# frozen_string_literal: true", "# #{AUTOGEN_NOTE}", ""]
|
|
13
|
+
out << 'require "securerandom"'
|
|
14
|
+
out << 'require "time"'
|
|
15
|
+
out << ""
|
|
16
|
+
out << "module AuditEvents"
|
|
17
|
+
out.concat(sink_block)
|
|
18
|
+
out << ""
|
|
19
|
+
out << " module Emitter"
|
|
20
|
+
out << " module_function"
|
|
21
|
+
@catalog.sorted.each do |e|
|
|
22
|
+
out << ""
|
|
23
|
+
out.concat(emitter_method(e))
|
|
24
|
+
end
|
|
25
|
+
out << " end"
|
|
26
|
+
out << "end"
|
|
27
|
+
out << ""
|
|
28
|
+
out.join("\n")
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
private
|
|
32
|
+
|
|
33
|
+
def sink_block
|
|
34
|
+
[
|
|
35
|
+
" # Assign AuditEvents.sink to an object responding to #call(event_hash),",
|
|
36
|
+
" # e.g. an outbox writer. Defaults to returning the event unchanged.",
|
|
37
|
+
" class << self",
|
|
38
|
+
" attr_writer :sink",
|
|
39
|
+
"",
|
|
40
|
+
" def sink",
|
|
41
|
+
" @sink ||= ->(event) { event }",
|
|
42
|
+
" end",
|
|
43
|
+
" end"
|
|
44
|
+
]
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def emitter_method(e)
|
|
48
|
+
required = e.fields.select { |_, f| f["required"] }.keys
|
|
49
|
+
optional = e.fields.reject { |_, f| f["required"] }.keys
|
|
50
|
+
params = ["actor:", "target:"]
|
|
51
|
+
params.concat(required.map { |k| "#{k}:" })
|
|
52
|
+
params.concat(optional.map { |k| "#{k}: nil" })
|
|
53
|
+
params << "occurred_at: Time.now.utc"
|
|
54
|
+
|
|
55
|
+
lines = [" def #{method_name(e)}(#{params.join(", ")})"]
|
|
56
|
+
lines << " metadata = {#{required.map { |k| "#{k.inspect} => #{k}" }.join(", ")}}"
|
|
57
|
+
optional.each do |k|
|
|
58
|
+
lines << " metadata[#{k.inspect}] = #{k} unless #{k}.nil?"
|
|
59
|
+
end
|
|
60
|
+
lines << " event = {"
|
|
61
|
+
lines << ' "event_id" => SecureRandom.uuid,'
|
|
62
|
+
lines << " \"action\" => #{e.action.inspect},"
|
|
63
|
+
lines << ' "occurred_at" => occurred_at.iso8601,'
|
|
64
|
+
lines << ' "actor" => actor,'
|
|
65
|
+
lines << ' "target" => target,'
|
|
66
|
+
lines << ' "metadata" => metadata,'
|
|
67
|
+
lines << " \"privacy\" => {\"masked_fields\" => #{masked_fields(e).inspect}},"
|
|
68
|
+
lines << ' "created_at" => Time.now.utc.iso8601'
|
|
69
|
+
lines << " }"
|
|
70
|
+
lines << " AuditEvents.sink.call(event)"
|
|
71
|
+
lines << " end"
|
|
72
|
+
lines
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def method_name(e)
|
|
76
|
+
e.action.split(/[._]/).join("_")
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def masked_fields(e)
|
|
80
|
+
(e.privacy.is_a?(Hash) && e.privacy["masked_fields"]) || []
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# rbs_inline: enabled
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module Moku6
|
|
5
|
+
module Generators
|
|
6
|
+
# Emits Ruby constants for every action (design section 12.4).
|
|
7
|
+
class RubyGenerator < BaseGenerator
|
|
8
|
+
#: () -> String
|
|
9
|
+
def render
|
|
10
|
+
consts = @catalog.sorted.map { |e| [const_name(e), e.action] }
|
|
11
|
+
out = ["# frozen_string_literal: true", "# #{AUTOGEN_NOTE}", "module AuditEvents"]
|
|
12
|
+
consts.each { |name, action| out << " #{name} = #{action.inspect}" }
|
|
13
|
+
out << ""
|
|
14
|
+
out << " ALL = [#{consts.map(&:first).join(", ")}].freeze"
|
|
15
|
+
out << "end"
|
|
16
|
+
out << ""
|
|
17
|
+
out.join("\n")
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
private
|
|
21
|
+
|
|
22
|
+
def const_name(e)
|
|
23
|
+
e.action.split(/[._]/).join("_").upcase
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# rbs_inline: enabled
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module Moku6
|
|
5
|
+
module Generators
|
|
6
|
+
class TypeScriptGenerator < BaseGenerator
|
|
7
|
+
#: () -> String
|
|
8
|
+
def render
|
|
9
|
+
events = @catalog.sorted
|
|
10
|
+
out = ["// #{AUTOGEN_NOTE}", ""]
|
|
11
|
+
out.concat(union_type(events))
|
|
12
|
+
out << ""
|
|
13
|
+
events.each { |e| out.concat(interface(e)) }
|
|
14
|
+
out << "export interface AuditEventMetadataMap {"
|
|
15
|
+
events.each { |e| out << " \"#{e.action}\": #{iface_name(e)};" }
|
|
16
|
+
out << "}"
|
|
17
|
+
out << ""
|
|
18
|
+
out.join("\n")
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
private
|
|
22
|
+
|
|
23
|
+
def union_type(events)
|
|
24
|
+
return ["export type AuditAction = never;"] if events.empty?
|
|
25
|
+
|
|
26
|
+
lines = ["export type AuditAction ="]
|
|
27
|
+
events.each_with_index do |e, i|
|
|
28
|
+
suffix = (i == events.size - 1) ? ";" : ""
|
|
29
|
+
lines << " | \"#{e.action}\"#{suffix}"
|
|
30
|
+
end
|
|
31
|
+
lines
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def interface(e)
|
|
35
|
+
lines = ["export interface #{iface_name(e)} {"]
|
|
36
|
+
e.fields.each do |name, f|
|
|
37
|
+
opt = f["required"] ? "" : "?"
|
|
38
|
+
lines << " #{name}#{opt}: #{ts(f)};"
|
|
39
|
+
end
|
|
40
|
+
lines << "}" << ""
|
|
41
|
+
lines
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def iface_name(e)
|
|
45
|
+
e.action.split(/[._]/).map(&:capitalize).join + "Metadata"
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def ts(f)
|
|
49
|
+
case f["type"]
|
|
50
|
+
when "integer", "number" then "number"
|
|
51
|
+
when "boolean" then "boolean"
|
|
52
|
+
when "array" then "unknown[]"
|
|
53
|
+
when "object" then "Record<string, unknown>"
|
|
54
|
+
else "string"
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# rbs_inline: enabled
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module Moku6
|
|
5
|
+
module Generators
|
|
6
|
+
REGISTRY = {
|
|
7
|
+
docs: DocsGenerator,
|
|
8
|
+
json_schema: JsonSchemaGenerator,
|
|
9
|
+
typescript: TypeScriptGenerator,
|
|
10
|
+
ruby: RubyGenerator,
|
|
11
|
+
bigquery: BigQueryGenerator,
|
|
12
|
+
cloudevents: CloudEventsGenerator,
|
|
13
|
+
eventcatalog: EventCatalogGenerator,
|
|
14
|
+
openapi: OpenApiGenerator,
|
|
15
|
+
rails: RailsGenerator,
|
|
16
|
+
outbox: OutboxGenerator,
|
|
17
|
+
example: ExampleGenerator
|
|
18
|
+
} #: Hash[Symbol, untyped]
|
|
19
|
+
|
|
20
|
+
#: (String | Symbol name, untyped klass) -> untyped
|
|
21
|
+
def self.register(name, klass) = REGISTRY[name.to_sym] = klass
|
|
22
|
+
|
|
23
|
+
#: (String | Symbol name, Catalog catalog, Config config) -> untyped
|
|
24
|
+
def self.build(name, catalog, config)
|
|
25
|
+
klass = REGISTRY.fetch(name.to_sym) { raise UsageError, "unknown generator: #{name}" }
|
|
26
|
+
klass.new(catalog, config)
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# rbs_inline: enabled
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "fileutils"
|
|
5
|
+
|
|
6
|
+
module Moku6
|
|
7
|
+
# Backs `moku6 init`. Copies templates/init into ./ (never overwrites existing files).
|
|
8
|
+
class Initializer
|
|
9
|
+
TEMPLATE_DIR = File.expand_path("../../templates/init", __dir__.to_s) #: String
|
|
10
|
+
|
|
11
|
+
# @rbs @options: Hash[Symbol, untyped]
|
|
12
|
+
# @rbs @dest_root: String
|
|
13
|
+
|
|
14
|
+
#: (?Hash[Symbol, untyped] options) -> void
|
|
15
|
+
def initialize(options = {})
|
|
16
|
+
@options = options
|
|
17
|
+
@dest_root = Dir.pwd
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
#: () -> void
|
|
21
|
+
def run
|
|
22
|
+
entries.each do |src|
|
|
23
|
+
rel = src.delete_prefix("#{TEMPLATE_DIR}/")
|
|
24
|
+
dest = File.join(@dest_root, rel)
|
|
25
|
+
if File.exist?(dest)
|
|
26
|
+
puts "skip (exists): #{rel}"
|
|
27
|
+
next
|
|
28
|
+
end
|
|
29
|
+
FileUtils.mkdir_p(File.dirname(dest))
|
|
30
|
+
FileUtils.cp(src, dest)
|
|
31
|
+
puts "create: #{rel}"
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
#: () -> Array[String]
|
|
38
|
+
def entries
|
|
39
|
+
Dir.glob(File.join(TEMPLATE_DIR, "**", "*"), File::FNM_DOTMATCH)
|
|
40
|
+
.reject { |p| File.directory?(p) }
|
|
41
|
+
.sort
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|