sequent 7.2.0 → 8.0.1

Sign up to get free protection for your applications and to get access to all the features.
Files changed (78) hide show
  1. checksums.yaml +4 -4
  2. data/bin/sequent +6 -107
  3. data/db/sequent_8_migration.sql +120 -0
  4. data/db/sequent_pgsql.sql +416 -0
  5. data/db/sequent_schema.rb +11 -57
  6. data/db/sequent_schema_indexes.sql +37 -0
  7. data/db/sequent_schema_partitions.sql +34 -0
  8. data/db/sequent_schema_tables.sql +74 -0
  9. data/lib/sequent/cli/app.rb +132 -0
  10. data/lib/sequent/cli/sequent_8_migration.rb +180 -0
  11. data/lib/sequent/configuration.rb +11 -8
  12. data/lib/sequent/core/aggregate_repository.rb +2 -2
  13. data/lib/sequent/core/aggregate_root.rb +32 -9
  14. data/lib/sequent/core/aggregate_snapshotter.rb +8 -6
  15. data/lib/sequent/core/command_record.rb +27 -18
  16. data/lib/sequent/core/command_service.rb +2 -2
  17. data/lib/sequent/core/event_publisher.rb +1 -1
  18. data/lib/sequent/core/event_record.rb +37 -17
  19. data/lib/sequent/core/event_store.rb +101 -119
  20. data/lib/sequent/core/helpers/array_with_type.rb +1 -1
  21. data/lib/sequent/core/helpers/association_validator.rb +2 -2
  22. data/lib/sequent/core/helpers/attribute_support.rb +8 -8
  23. data/lib/sequent/core/helpers/equal_support.rb +3 -3
  24. data/lib/sequent/core/helpers/message_matchers/has_attrs.rb +2 -0
  25. data/lib/sequent/core/helpers/message_router.rb +2 -2
  26. data/lib/sequent/core/helpers/param_support.rb +1 -3
  27. data/lib/sequent/core/helpers/pgsql_helpers.rb +32 -0
  28. data/lib/sequent/core/helpers/string_support.rb +1 -1
  29. data/lib/sequent/core/helpers/string_to_value_parsers.rb +1 -1
  30. data/lib/sequent/core/persistors/active_record_persistor.rb +1 -1
  31. data/lib/sequent/core/persistors/replay_optimized_postgres_persistor.rb +3 -4
  32. data/lib/sequent/core/projector.rb +1 -1
  33. data/lib/sequent/core/snapshot_record.rb +44 -0
  34. data/lib/sequent/core/snapshot_store.rb +105 -0
  35. data/lib/sequent/core/stream_record.rb +10 -15
  36. data/lib/sequent/dry_run/read_only_replay_optimized_postgres_persistor.rb +1 -1
  37. data/lib/sequent/dry_run/view_schema.rb +2 -3
  38. data/lib/sequent/generator/project.rb +5 -7
  39. data/lib/sequent/generator/template_aggregate/template_aggregate/commands.rb +2 -0
  40. data/lib/sequent/generator/template_aggregate/template_aggregate/events.rb +2 -0
  41. data/lib/sequent/generator/template_aggregate/template_aggregate/template_aggregate.rb +2 -0
  42. data/lib/sequent/generator/template_aggregate/template_aggregate/template_aggregate_command_handler.rb +2 -0
  43. data/lib/sequent/generator/template_aggregate/template_aggregate.rb +2 -0
  44. data/lib/sequent/generator/template_project/Gemfile +7 -4
  45. data/lib/sequent/generator/template_project/Rakefile +4 -2
  46. data/lib/sequent/generator/template_project/app/projectors/post_projector.rb +2 -0
  47. data/lib/sequent/generator/template_project/app/records/post_record.rb +2 -0
  48. data/lib/sequent/generator/template_project/config/initializers/sequent.rb +2 -0
  49. data/lib/sequent/generator/template_project/db/migrations.rb +3 -3
  50. data/lib/sequent/generator/template_project/lib/post/commands.rb +2 -0
  51. data/lib/sequent/generator/template_project/lib/post/events.rb +2 -0
  52. data/lib/sequent/generator/template_project/lib/post/post.rb +2 -0
  53. data/lib/sequent/generator/template_project/lib/post/post_command_handler.rb +2 -0
  54. data/lib/sequent/generator/template_project/lib/post.rb +2 -0
  55. data/lib/sequent/generator/template_project/my_app.rb +2 -1
  56. data/lib/sequent/generator/template_project/spec/app/projectors/post_projector_spec.rb +2 -0
  57. data/lib/sequent/generator/template_project/spec/lib/post/post_command_handler_spec.rb +9 -2
  58. data/lib/sequent/generator/template_project/spec/spec_helper.rb +3 -1
  59. data/lib/sequent/generator.rb +1 -1
  60. data/lib/sequent/internal/aggregate_type.rb +12 -0
  61. data/lib/sequent/internal/command_type.rb +12 -0
  62. data/lib/sequent/internal/event_type.rb +12 -0
  63. data/lib/sequent/internal/internal.rb +14 -0
  64. data/lib/sequent/internal/partitioned_aggregate.rb +26 -0
  65. data/lib/sequent/internal/partitioned_command.rb +16 -0
  66. data/lib/sequent/internal/partitioned_event.rb +29 -0
  67. data/lib/sequent/migrations/grouper.rb +90 -0
  68. data/lib/sequent/migrations/sequent_schema.rb +2 -1
  69. data/lib/sequent/migrations/view_schema.rb +76 -77
  70. data/lib/sequent/rake/migration_tasks.rb +49 -24
  71. data/lib/sequent/sequent.rb +1 -0
  72. data/lib/sequent/support/database.rb +20 -16
  73. data/lib/sequent/test/time_comparison.rb +1 -1
  74. data/lib/sequent/util/timer.rb +1 -1
  75. data/lib/version.rb +1 -1
  76. metadata +71 -18
  77. data/lib/sequent/generator/template_project/db/sequent_schema.rb +0 -52
  78. data/lib/sequent/generator/template_project/ruby-version +0 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 516eaa30164cf31340f225e671c48614fa5201fcd8af4da43082ec8fa594fdbe
4
- data.tar.gz: b744ba69e6f339d77343d0a52fa42453545c0ac3056a3c85ab51dae2d6aa6332
3
+ metadata.gz: 0d84095b5eda544d3dbfdfd38abf47cea0b8ae4ac4fdbd71607ce16956761609
4
+ data.tar.gz: 5e39298de05259431cfeca107fd1f338c2122fae340b90b368ea4f2a7513d836
5
5
  SHA512:
6
- metadata.gz: c9030ef57d0369dcb184609394f88ca165907a7b0cade5fb8b53109d71be8627c0c01270360ac1b84da5018056aafd13162d6363de42a74082ac2ce38b5a9e58
7
- data.tar.gz: 78b6528040a273ae3fb2cd13c962bc9874157330b9bdb8dafa9997634f27e7861ba3db4871f1065aea45301396f156225b66de33ac0a25b69024b46903ceb676
6
+ metadata.gz: d65e5937db24cb49fd4070bdc5c5bc06034bc94f88af7a44dfc50072aa127db08f128df97471a8cee85e03e3b5f60e25219adba6fd84b25cc8d679d553123479
7
+ data.tar.gz: e43babde3e2348af5a13e080de89fe698acfc8ffadd32522a613742c6e128d297d7af8b5f7ab8e4b96f32fbbeb04954ab449dfb04a7632742623bc5afbb7ec98
data/bin/sequent CHANGED
@@ -1,111 +1,10 @@
1
1
  #!/usr/bin/env ruby
2
2
  # frozen_string_literal: true
3
3
 
4
- require_relative '../lib/sequent/generator'
4
+ require 'gli'
5
+ require 'tty-prompt'
6
+ require_relative '../lib/version'
7
+ require_relative '../lib/sequent/cli/app'
5
8
 
6
- command = ARGV[0].to_s.strip
7
- abort('Please specify a command. i.e. `sequent new myapp`') if command.empty?
8
- abort('Please specify a command. i.e. `sequent new myapp`') if ARGV[1..-1].empty?
9
-
10
- args = ARGV[1..-1].map(&:to_s).map(&:strip)
11
-
12
- def new_project(args)
13
- arguments = args.dup
14
- name = arguments.shift
15
- abort('Please specify a directory name. i.e. `sequent new myapp`') if name.empty?
16
-
17
- Sequent::Generator::Project.new(name).execute
18
- puts <<~NEXTSTEPS
19
-
20
- Success!
21
-
22
- Your brand spanking new sequent app is waiting for you in:
23
- #{File.expand_path(name, Dir.pwd)}
24
-
25
- To finish setting up your app:
26
- cd #{name}
27
- bundle install
28
- bundle exec rake sequent:db:create
29
- bundle exec rake sequent:db:create_view_schema
30
- bundle exec rake sequent:migrate:online
31
- bundle exec rake sequent:migrate:offline
32
-
33
- Run the example specs:
34
- SEQUENT_ENV=test bundle exec rake sequent:db:create
35
- bundle exec rspec spec
36
-
37
- To generate new aggregates use:
38
- sequent generate <aggregate_name>. e.g. sequent generate address
39
-
40
- For more information see:
41
- https://www.sequent.io
42
-
43
- Happy coding!
44
-
45
- NEXTSTEPS
46
- end
47
-
48
- def generate_aggregate(args)
49
- arguments = args.dup
50
- aggregate_name = arguments.shift
51
- abort('Please specify an aggregate name. i.e. `sequent g aggregate user`') unless args_valid?(aggregate_name)
52
-
53
- Sequent::Generator::Aggregate.new(aggregate_name).execute
54
- puts "#{aggregate_name} aggregate has been generated"
55
- end
56
-
57
- def generate_command(args)
58
- arguments = args.dup
59
- aggregate_name = arguments.shift
60
- command_name = arguments.shift
61
- attrs = arguments
62
-
63
- unless args_valid?(aggregate_name, command_name)
64
- abort('Please specify an aggregate name and command name. i.e. `sequent g command user AddUser`')
65
- end
66
- Sequent::Generator::Command.new(aggregate_name, command_name, attrs).execute
67
- puts "#{command_name} command has been added to #{aggregate_name}"
68
- end
69
-
70
- def generate_event(args)
71
- arguments = args.dup
72
- aggregate_name = arguments.shift
73
- event_name = arguments.shift
74
- attrs = arguments
75
-
76
- abort('Please specify an aggregate name and event name. i.e. `sequent g event user AddUser`') unless args_valid?(
77
- aggregate_name, event_name
78
- )
79
- Sequent::Generator::Event.new(aggregate_name, event_name, attrs).execute
80
- puts "#{event_name} event has been added to #{aggregate_name}"
81
- end
82
-
83
- def generate(args)
84
- arguments = args.dup
85
- entity = arguments.shift
86
- abort('Please specify a command. i.e. `sequent g aggregate user`') if entity.empty?
87
-
88
- case entity
89
- when 'aggregate'
90
- generate_aggregate(arguments)
91
- when 'command'
92
- generate_command(arguments)
93
- when 'event'
94
- generate_event(arguments)
95
- else
96
- abort("Unknown argument #{entity} for `generate`. Try `sequent g aggregate user`")
97
- end
98
- end
99
-
100
- def args_valid?(*args)
101
- args.all?(&:present?)
102
- end
103
-
104
- case command
105
- when 'new'
106
- new_project(args)
107
- when 'generate', 'g'
108
- generate(args)
109
- else
110
- abort("Unknown command #{command}. Try `sequent new myapp`")
111
- end
9
+ exit_code = Sequent::Cli::App.run(ARGV)
10
+ exit(exit_code)
@@ -0,0 +1,120 @@
1
+ -- This script migrates a pre-sequent 8 database to the sequent 8 schema while preserving the data.
2
+ -- It runs in a single transaction and when completed you can COMMIT or ROLLBACK the results.
3
+ --
4
+ -- To adjust the partitioning setup you can modify `./sequent_schema_partitions.sql`. By default
5
+ -- only a single partition is present for each partitioned table, which works well for smaller
6
+ -- (e.g. less than 10 Gigabytes) databases.
7
+ --
8
+ -- Ensure you test this on a copy of your production system to verify everything works and to
9
+ -- get an indication of the required downtime for your system.
10
+
11
+ \set ECHO all
12
+ \set ON_ERROR_STOP
13
+ \timing on
14
+
15
+ SELECT clock_timestamp() AS migration_started_at \gset
16
+
17
+ \echo Migration started at :migration_started_at
18
+
19
+ SET work_mem TO '8MB';
20
+ SET max_parallel_workers = 8;
21
+ SET max_parallel_workers_per_gather = 8;
22
+ SET max_parallel_maintenance_workers = 8;
23
+
24
+ BEGIN;
25
+
26
+ SET temp_tablespaces = 'pg_default';
27
+ SET search_path TO sequent_schema;
28
+
29
+ ALTER SEQUENCE command_records_id_seq OWNED BY NONE;
30
+ ALTER SEQUENCE command_records_id_seq RENAME TO commands_id_seq;
31
+
32
+ \ir ./sequent_schema_tables.sql
33
+ \ir ./sequent_schema_partitions.sql
34
+
35
+ INSERT INTO aggregate_types (type)
36
+ SELECT DISTINCT aggregate_type
37
+ FROM sequent_schema.stream_records
38
+ ORDER BY 1;
39
+
40
+ INSERT INTO event_types (type)
41
+ SELECT DISTINCT event_type
42
+ FROM sequent_schema.event_records
43
+ WHERE event_type <> 'Sequent::Core::SnapshotEvent'
44
+ ORDER BY 1;
45
+
46
+ INSERT INTO command_types (type)
47
+ SELECT DISTINCT command_type
48
+ FROM sequent_schema.command_records
49
+ ORDER BY 1;
50
+
51
+ ANALYZE aggregate_types, event_types, command_types;
52
+
53
+ INSERT INTO aggregates (aggregate_id, aggregate_type_id, snapshot_threshold, created_at)
54
+ SELECT aggregate_id, (SELECT t.id FROM aggregate_types t WHERE aggregate_type = t.type), snapshot_threshold, created_at AT TIME ZONE 'Europe/Amsterdam'
55
+ FROM stream_records;
56
+
57
+ WITH e AS MATERIALIZED (
58
+ SELECT aggregate_id,
59
+ sequence_number,
60
+ command_record_id,
61
+ t.id AS event_type_id,
62
+ event_json::jsonb - '{aggregate_id,sequence_number}'::text[] AS event_json
63
+ FROM sequent_schema.event_records e
64
+ JOIN event_types t ON e.event_type = t.type
65
+ )
66
+ INSERT INTO events (aggregate_id, sequence_number, created_at, command_id, event_type_id, event_json)
67
+ SELECT aggregate_id,
68
+ sequence_number,
69
+ (event_json->>'created_at')::timestamptz AS created_at,
70
+ command_record_id,
71
+ event_type_id,
72
+ event_json - 'created_at'
73
+ FROM e;
74
+
75
+ WITH command AS MATERIALIZED (
76
+ SELECT c.id, created_at,
77
+ t.id AS command_type_id,
78
+ command_json::jsonb AS json
79
+ FROM sequent_schema.command_records c
80
+ JOIN command_types t ON t.type = c.command_type
81
+ )
82
+ INSERT INTO commands (
83
+ id, created_at, user_id, aggregate_id, command_type_id, command_json,
84
+ event_aggregate_id, event_sequence_number
85
+ )
86
+ SELECT id,
87
+ COALESCE((json->>'created_at')::timestamptz, created_at AT TIME ZONE 'Europe/Amsterdam'),
88
+ (json->>'user_id')::uuid,
89
+ (json->>'aggregate_id')::uuid,
90
+ command_type_id,
91
+ json - '{created_at,user_id,aggregate_id,event_aggregate_id,event_sequence_number}'::text[],
92
+ (json->>'event_aggregate_id')::uuid,
93
+ (json->>'event_sequence_number')::integer
94
+ FROM command;
95
+
96
+ INSERT INTO aggregates_that_need_snapshots (aggregate_id, snapshot_sequence_number_high_water_mark, snapshot_outdated_at)
97
+ SELECT aggregate_id, MAX(sequence_number), NOW()
98
+ FROM event_records
99
+ WHERE event_type = 'Sequent::Core::SnapshotEvent'
100
+ GROUP BY 1
101
+ ORDER BY 1;
102
+
103
+ ALTER TABLE command_records RENAME TO old_command_records;
104
+ ALTER TABLE event_records RENAME TO old_event_records;
105
+ ALTER TABLE stream_records RENAME TO old_stream_records;
106
+
107
+ \ir ./sequent_schema_indexes.sql
108
+
109
+ \set ECHO none
110
+
111
+ \ir ./sequent_pgsql.sql
112
+
113
+ \set ECHO all
114
+
115
+ SELECT clock_timestamp() AS migration_completed_at,
116
+ clock_timestamp() - :'migration_started_at'::timestamptz AS migration_duration \gset
117
+
118
+ \echo Migration complated in :migration_duration (started at :migration_started_at, completed at :migration_completed_at)
119
+
120
+ \echo execute ROLLBACK to abort, COMMIT to commit followed by VACUUM VERBOSE ANALYZE to ensure good performance
@@ -0,0 +1,416 @@
1
+ DROP TYPE IF EXISTS aggregate_event_type CASCADE;
2
+ CREATE TYPE aggregate_event_type AS (
3
+ aggregate_type text,
4
+ aggregate_id uuid,
5
+ events_partition_key text,
6
+ event_type text,
7
+ event_json jsonb
8
+ );
9
+
10
+ CREATE OR REPLACE FUNCTION enrich_command_json(command commands) RETURNS jsonb
11
+ LANGUAGE plpgsql AS $$
12
+ BEGIN
13
+ RETURN jsonb_build_object(
14
+ 'command_type', (SELECT type FROM command_types WHERE command_types.id = command.command_type_id),
15
+ 'created_at', command.created_at,
16
+ 'user_id', command.user_id,
17
+ 'aggregate_id', command.aggregate_id,
18
+ 'event_aggregate_id', command.event_aggregate_id,
19
+ 'event_sequence_number', command.event_sequence_number
20
+ )
21
+ || command.command_json;
22
+ END
23
+ $$;
24
+
25
+ CREATE OR REPLACE FUNCTION enrich_event_json(event events) RETURNS jsonb
26
+ LANGUAGE plpgsql AS $$
27
+ BEGIN
28
+ RETURN jsonb_build_object(
29
+ 'aggregate_id', event.aggregate_id,
30
+ 'sequence_number', event.sequence_number,
31
+ 'created_at', event.created_at
32
+ )
33
+ || event.event_json;
34
+ END
35
+ $$;
36
+
37
+ CREATE OR REPLACE FUNCTION load_event(
38
+ _aggregate_id uuid,
39
+ _sequence_number integer
40
+ ) RETURNS SETOF aggregate_event_type
41
+ LANGUAGE plpgsql AS $$
42
+ BEGIN
43
+ RETURN QUERY SELECT aggregate_types.type,
44
+ a.aggregate_id,
45
+ a.events_partition_key,
46
+ event_types.type,
47
+ enrich_event_json(e)
48
+ FROM aggregates a
49
+ INNER JOIN events e ON (a.events_partition_key, a.aggregate_id) = (e.partition_key, e.aggregate_id)
50
+ INNER JOIN aggregate_types ON a.aggregate_type_id = aggregate_types.id
51
+ INNER JOIN event_types ON e.event_type_id = event_types.id
52
+ WHERE a.aggregate_id = _aggregate_id
53
+ AND e.sequence_number = _sequence_number;
54
+ END;
55
+ $$;
56
+
57
+ CREATE OR REPLACE FUNCTION load_events(
58
+ _aggregate_ids jsonb,
59
+ _use_snapshots boolean DEFAULT TRUE,
60
+ _until timestamptz DEFAULT NULL
61
+ ) RETURNS SETOF aggregate_event_type
62
+ LANGUAGE plpgsql AS $$
63
+ DECLARE
64
+ _aggregate_id aggregates.aggregate_id%TYPE;
65
+ BEGIN
66
+ FOR _aggregate_id IN SELECT * FROM jsonb_array_elements_text(_aggregate_ids) LOOP
67
+ -- Use a single query to avoid race condition with UPDATEs to the events partition key
68
+ -- in case transaction isolation level is lower than repeatable read (the default of
69
+ -- PostgreSQL is read committed).
70
+ RETURN QUERY WITH
71
+ aggregate AS (
72
+ SELECT aggregate_types.type, aggregate_id, events_partition_key
73
+ FROM aggregates
74
+ JOIN aggregate_types ON aggregate_type_id = aggregate_types.id
75
+ WHERE aggregate_id = _aggregate_id
76
+ ),
77
+ snapshot AS (
78
+ SELECT *
79
+ FROM snapshot_records
80
+ WHERE _use_snapshots
81
+ AND aggregate_id = _aggregate_id
82
+ AND (_until IS NULL OR created_at < _until)
83
+ ORDER BY sequence_number DESC LIMIT 1
84
+ )
85
+ (SELECT a.*, s.snapshot_type, s.snapshot_json FROM aggregate a, snapshot s)
86
+ UNION ALL
87
+ (SELECT a.*, event_types.type, enrich_event_json(e)
88
+ FROM aggregate a
89
+ JOIN events e ON (a.events_partition_key, a.aggregate_id) = (e.partition_key, e.aggregate_id)
90
+ JOIN event_types ON e.event_type_id = event_types.id
91
+ WHERE e.sequence_number >= COALESCE((SELECT sequence_number FROM snapshot), 0)
92
+ AND (_until IS NULL OR e.created_at < _until)
93
+ ORDER BY e.sequence_number ASC);
94
+ END LOOP;
95
+ END;
96
+ $$;
97
+
98
+ CREATE OR REPLACE FUNCTION store_command(_command jsonb) RETURNS bigint
99
+ LANGUAGE plpgsql AS $$
100
+ DECLARE
101
+ _id commands.id%TYPE;
102
+ _command_json jsonb = _command->'command_json';
103
+ BEGIN
104
+ IF NOT EXISTS (SELECT 1 FROM command_types t WHERE t.type = _command->>'command_type') THEN
105
+ -- Only try inserting if it doesn't exist to avoid exhausting the id sequence
106
+ INSERT INTO command_types (type)
107
+ VALUES (_command->>'command_type')
108
+ ON CONFLICT DO NOTHING;
109
+ END IF;
110
+
111
+ INSERT INTO commands (
112
+ created_at, user_id, aggregate_id, command_type_id, command_json,
113
+ event_aggregate_id, event_sequence_number
114
+ ) VALUES (
115
+ (_command->>'created_at')::timestamptz,
116
+ (_command_json->>'user_id')::uuid,
117
+ (_command_json->>'aggregate_id')::uuid,
118
+ (SELECT id FROM command_types WHERE type = _command->>'command_type'),
119
+ (_command->'command_json') - '{command_type,created_at,organization_id,user_id,aggregate_id,event_aggregate_id,event_sequence_number}'::text[],
120
+ (_command_json->>'event_aggregate_id')::uuid,
121
+ NULLIF(_command_json->'event_sequence_number', 'null'::jsonb)::integer
122
+ ) RETURNING id INTO STRICT _id;
123
+ RETURN _id;
124
+ END;
125
+ $$;
126
+
127
+ CREATE OR REPLACE PROCEDURE store_events(_command jsonb, _aggregates_with_events jsonb)
128
+ LANGUAGE plpgsql AS $$
129
+ DECLARE
130
+ _command_id commands.id%TYPE;
131
+ _aggregate jsonb;
132
+ _events jsonb;
133
+ _aggregate_id aggregates.aggregate_id%TYPE;
134
+ _aggregate_row aggregates%ROWTYPE;
135
+ _provided_events_partition_key aggregates.events_partition_key%TYPE;
136
+ _events_partition_key aggregates.events_partition_key%TYPE;
137
+ _snapshot_outdated_at aggregates_that_need_snapshots.snapshot_outdated_at%TYPE;
138
+ BEGIN
139
+ _command_id = store_command(_command);
140
+
141
+ WITH types AS (
142
+ SELECT DISTINCT row->0->>'aggregate_type' AS type
143
+ FROM jsonb_array_elements(_aggregates_with_events) AS row
144
+ )
145
+ INSERT INTO aggregate_types (type)
146
+ SELECT type FROM types
147
+ WHERE type NOT IN (SELECT type FROM aggregate_types)
148
+ ORDER BY 1
149
+ ON CONFLICT DO NOTHING;
150
+
151
+ WITH types AS (
152
+ SELECT DISTINCT events->>'event_type' AS type
153
+ FROM jsonb_array_elements(_aggregates_with_events) AS row
154
+ CROSS JOIN LATERAL jsonb_array_elements(row->1) AS events
155
+ )
156
+ INSERT INTO event_types (type)
157
+ SELECT type FROM types
158
+ WHERE type NOT IN (SELECT type FROM event_types)
159
+ ORDER BY 1
160
+ ON CONFLICT DO NOTHING;
161
+
162
+ FOR _aggregate, _events IN SELECT row->0, row->1 FROM jsonb_array_elements(_aggregates_with_events) AS row
163
+ ORDER BY row->0->'aggregate_id', row->1->0->'event_json'->'sequence_number'
164
+ LOOP
165
+ _aggregate_id = _aggregate->>'aggregate_id';
166
+ _provided_events_partition_key = _aggregate->>'events_partition_key';
167
+ _snapshot_outdated_at = _aggregate->>'snapshot_outdated_at';
168
+
169
+ SELECT * INTO _aggregate_row FROM aggregates WHERE aggregate_id = _aggregate_id;
170
+ _events_partition_key = COALESCE(_provided_events_partition_key, _aggregate_row.events_partition_key, '');
171
+
172
+ INSERT INTO aggregates (aggregate_id, created_at, aggregate_type_id, events_partition_key)
173
+ VALUES (
174
+ _aggregate_id,
175
+ (_events->0->>'created_at')::timestamptz,
176
+ (SELECT id FROM aggregate_types WHERE type = _aggregate->>'aggregate_type'),
177
+ _events_partition_key
178
+ ) ON CONFLICT (aggregate_id)
179
+ DO UPDATE SET events_partition_key = EXCLUDED.events_partition_key
180
+ WHERE aggregates.events_partition_key IS DISTINCT FROM EXCLUDED.events_partition_key;
181
+
182
+ INSERT INTO events (partition_key, aggregate_id, sequence_number, created_at, command_id, event_type_id, event_json)
183
+ SELECT _events_partition_key,
184
+ _aggregate_id,
185
+ (event->'event_json'->'sequence_number')::integer,
186
+ (event->>'created_at')::timestamptz,
187
+ _command_id,
188
+ (SELECT id FROM event_types WHERE type = event->>'event_type'),
189
+ (event->'event_json') - '{aggregate_id,created_at,event_type,sequence_number}'::text[]
190
+ FROM jsonb_array_elements(_events) AS event;
191
+
192
+ IF _snapshot_outdated_at IS NOT NULL THEN
193
+ INSERT INTO aggregates_that_need_snapshots AS row (aggregate_id, snapshot_outdated_at)
194
+ VALUES (_aggregate_id, _snapshot_outdated_at)
195
+ ON CONFLICT (aggregate_id) DO UPDATE
196
+ SET snapshot_outdated_at = LEAST(row.snapshot_outdated_at, EXCLUDED.snapshot_outdated_at)
197
+ WHERE row.snapshot_outdated_at IS DISTINCT FROM EXCLUDED.snapshot_outdated_at;
198
+ END IF;
199
+ END LOOP;
200
+ END;
201
+ $$;
202
+
203
+ CREATE OR REPLACE PROCEDURE store_snapshots(_snapshots jsonb)
204
+ LANGUAGE plpgsql AS $$
205
+ DECLARE
206
+ _aggregate_id uuid;
207
+ _snapshot jsonb;
208
+ _sequence_number snapshot_records.sequence_number%TYPE;
209
+ BEGIN
210
+ FOR _snapshot IN SELECT * FROM jsonb_array_elements(_snapshots) LOOP
211
+ _aggregate_id = _snapshot->>'aggregate_id';
212
+ _sequence_number = _snapshot->'sequence_number';
213
+
214
+ INSERT INTO aggregates_that_need_snapshots AS row (aggregate_id, snapshot_sequence_number_high_water_mark)
215
+ VALUES (_aggregate_id, _sequence_number)
216
+ ON CONFLICT (aggregate_id) DO UPDATE
217
+ SET snapshot_sequence_number_high_water_mark =
218
+ GREATEST(row.snapshot_sequence_number_high_water_mark, EXCLUDED.snapshot_sequence_number_high_water_mark),
219
+ snapshot_outdated_at = NULL,
220
+ snapshot_scheduled_at = NULL;
221
+
222
+ INSERT INTO snapshot_records (aggregate_id, sequence_number, created_at, snapshot_type, snapshot_json)
223
+ VALUES (
224
+ _aggregate_id,
225
+ _sequence_number,
226
+ (_snapshot->>'created_at')::timestamptz,
227
+ _snapshot->>'snapshot_type',
228
+ _snapshot->'snapshot_json'
229
+ );
230
+ END LOOP;
231
+ END;
232
+ $$;
233
+
234
+ CREATE OR REPLACE FUNCTION load_latest_snapshot(_aggregate_id uuid) RETURNS aggregate_event_type
235
+ LANGUAGE SQL AS $$
236
+ SELECT (SELECT type FROM aggregate_types WHERE id = a.aggregate_type_id),
237
+ a.aggregate_id,
238
+ a.events_partition_key,
239
+ s.snapshot_type,
240
+ s.snapshot_json
241
+ FROM aggregates a JOIN snapshot_records s ON a.aggregate_id = s.aggregate_id
242
+ WHERE a.aggregate_id = _aggregate_id
243
+ ORDER BY s.sequence_number DESC
244
+ LIMIT 1;
245
+ $$;
246
+
247
+ CREATE OR REPLACE PROCEDURE delete_all_snapshots(_now timestamp with time zone DEFAULT NOW())
248
+ LANGUAGE plpgsql AS $$
249
+ BEGIN
250
+ UPDATE aggregates_that_need_snapshots
251
+ SET snapshot_outdated_at = _now
252
+ WHERE snapshot_outdated_at IS NULL;
253
+ DELETE FROM snapshot_records;
254
+ END;
255
+ $$;
256
+
257
+ CREATE OR REPLACE PROCEDURE delete_snapshots_before(_aggregate_id uuid, _sequence_number integer, _now timestamp with time zone DEFAULT NOW())
258
+ LANGUAGE plpgsql AS $$
259
+ BEGIN
260
+ DELETE FROM snapshot_records
261
+ WHERE aggregate_id = _aggregate_id
262
+ AND sequence_number < _sequence_number;
263
+
264
+ UPDATE aggregates_that_need_snapshots
265
+ SET snapshot_outdated_at = _now
266
+ WHERE aggregate_id = _aggregate_id
267
+ AND snapshot_outdated_at IS NULL
268
+ AND NOT EXISTS (SELECT 1 FROM snapshot_records WHERE aggregate_id = _aggregate_id);
269
+ END;
270
+ $$;
271
+
272
+ CREATE OR REPLACE FUNCTION aggregates_that_need_snapshots(_last_aggregate_id uuid, _limit integer)
273
+ RETURNS TABLE (aggregate_id uuid)
274
+ LANGUAGE plpgsql AS $$
275
+ BEGIN
276
+ RETURN QUERY SELECT a.aggregate_id
277
+ FROM aggregates_that_need_snapshots a
278
+ WHERE a.snapshot_outdated_at IS NOT NULL
279
+ AND (_last_aggregate_id IS NULL OR a.aggregate_id > _last_aggregate_id)
280
+ ORDER BY 1
281
+ LIMIT _limit;
282
+ END;
283
+ $$;
284
+
285
+ CREATE OR REPLACE FUNCTION select_aggregates_for_snapshotting(_limit integer, _reschedule_snapshot_scheduled_before timestamp with time zone, _now timestamp with time zone DEFAULT NOW())
286
+ RETURNS TABLE (aggregate_id uuid)
287
+ LANGUAGE plpgsql AS $$
288
+ BEGIN
289
+ RETURN QUERY WITH scheduled AS MATERIALIZED (
290
+ SELECT a.aggregate_id
291
+ FROM aggregates_that_need_snapshots AS a
292
+ WHERE snapshot_outdated_at IS NOT NULL
293
+ ORDER BY snapshot_outdated_at ASC, snapshot_sequence_number_high_water_mark DESC, aggregate_id ASC
294
+ LIMIT _limit
295
+ FOR UPDATE
296
+ ) UPDATE aggregates_that_need_snapshots AS row
297
+ SET snapshot_scheduled_at = _now
298
+ FROM scheduled
299
+ WHERE row.aggregate_id = scheduled.aggregate_id
300
+ AND (row.snapshot_scheduled_at IS NULL OR row.snapshot_scheduled_at < _reschedule_snapshot_scheduled_before)
301
+ RETURNING row.aggregate_id;
302
+ END;
303
+ $$;
304
+
305
+ CREATE OR REPLACE PROCEDURE permanently_delete_commands_without_events(_aggregate_id uuid, _organization_id uuid)
306
+ LANGUAGE plpgsql AS $$
307
+ BEGIN
308
+ IF _aggregate_id IS NULL AND _organization_id IS NULL THEN
309
+ RAISE EXCEPTION 'aggregate_id or organization_id must be specified to delete commands';
310
+ END IF;
311
+
312
+ DELETE FROM commands
313
+ WHERE (_aggregate_id IS NULL OR aggregate_id = _aggregate_id)
314
+ AND NOT EXISTS (SELECT 1 FROM events WHERE command_id = commands.id);
315
+ END;
316
+ $$;
317
+
318
+ CREATE OR REPLACE PROCEDURE permanently_delete_event_streams(_aggregate_ids jsonb)
319
+ LANGUAGE plpgsql AS $$
320
+ BEGIN
321
+ DELETE FROM events
322
+ USING jsonb_array_elements_text(_aggregate_ids) AS ids (id)
323
+ JOIN aggregates ON ids.id::uuid = aggregates.aggregate_id
324
+ WHERE events.partition_key = aggregates.events_partition_key
325
+ AND events.aggregate_id = aggregates.aggregate_id;
326
+ DELETE FROM aggregates
327
+ USING jsonb_array_elements_text(_aggregate_ids) AS ids (id)
328
+ WHERE aggregates.aggregate_id = ids.id::uuid;
329
+ END;
330
+ $$;
331
+
332
+ DROP VIEW IF EXISTS command_records;
333
+ CREATE VIEW command_records (id, user_id, aggregate_id, command_type, command_json, created_at, event_aggregate_id, event_sequence_number) AS
334
+ SELECT id,
335
+ user_id,
336
+ aggregate_id,
337
+ (SELECT type FROM command_types WHERE command_types.id = command.command_type_id),
338
+ enrich_command_json(command),
339
+ created_at,
340
+ event_aggregate_id,
341
+ event_sequence_number
342
+ FROM commands command;
343
+
344
+ DROP VIEW IF EXISTS event_records;
345
+ CREATE VIEW event_records (aggregate_id, partition_key, sequence_number, created_at, event_type, event_json, command_record_id, xact_id) AS
346
+ SELECT aggregate.aggregate_id,
347
+ event.partition_key,
348
+ event.sequence_number,
349
+ event.created_at,
350
+ type.type,
351
+ enrich_event_json(event) AS event_json,
352
+ command_id,
353
+ event.xact_id
354
+ FROM events event
355
+ JOIN aggregates aggregate ON aggregate.aggregate_id = event.aggregate_id AND aggregate.events_partition_key = event.partition_key
356
+ JOIN event_types type ON event.event_type_id = type.id;
357
+
358
+ DROP VIEW IF EXISTS stream_records;
359
+ CREATE VIEW stream_records (aggregate_id, events_partition_key, aggregate_type, created_at) AS
360
+ SELECT aggregates.aggregate_id,
361
+ aggregates.events_partition_key,
362
+ aggregate_types.type,
363
+ aggregates.created_at
364
+ FROM aggregates JOIN aggregate_types ON aggregates.aggregate_type_id = aggregate_types.id;
365
+
366
+ CREATE OR REPLACE FUNCTION save_events_on_delete_trigger() RETURNS TRIGGER AS $$
367
+ BEGIN
368
+ INSERT INTO saved_event_records (operation, timestamp, "user", aggregate_id, partition_key, sequence_number, created_at, event_type, event_json, command_id, xact_id)
369
+ SELECT 'D',
370
+ statement_timestamp(),
371
+ user,
372
+ o.aggregate_id,
373
+ o.partition_key,
374
+ o.sequence_number,
375
+ o.created_at,
376
+ (SELECT type FROM event_types WHERE event_types.id = o.event_type_id),
377
+ o.event_json,
378
+ o.command_id,
379
+ o.xact_id
380
+ FROM old_table o;
381
+ RETURN NULL;
382
+ END;
383
+ $$ LANGUAGE plpgsql;
384
+
385
+ CREATE OR REPLACE FUNCTION save_events_on_update_trigger() RETURNS TRIGGER AS $$
386
+ BEGIN
387
+ INSERT INTO saved_event_records (operation, timestamp, "user", aggregate_id, partition_key, sequence_number, created_at, event_type, event_json, command_id, xact_id)
388
+ SELECT 'U',
389
+ statement_timestamp(),
390
+ user,
391
+ o.aggregate_id,
392
+ o.partition_key,
393
+ o.sequence_number,
394
+ o.created_at,
395
+ (SELECT type FROM event_types WHERE event_types.id = o.event_type_id),
396
+ o.event_json,
397
+ o.command_id,
398
+ o.xact_id
399
+ FROM old_table o LEFT JOIN new_table n ON o.aggregate_id = n.aggregate_id AND o.sequence_number = n.sequence_number
400
+ WHERE n IS NULL
401
+ -- Only save when event related information changes
402
+ OR o.created_at <> n.created_at
403
+ OR o.event_type_id <> n.event_type_id
404
+ OR o.event_json <> n.event_json;
405
+ RETURN NULL;
406
+ END;
407
+ $$ LANGUAGE plpgsql;
408
+
409
+ CREATE OR REPLACE TRIGGER save_events_on_delete_trigger
410
+ AFTER DELETE ON events
411
+ REFERENCING OLD TABLE AS old_table
412
+ FOR EACH STATEMENT EXECUTE FUNCTION save_events_on_delete_trigger();
413
+ CREATE OR REPLACE TRIGGER save_events_on_update_trigger
414
+ AFTER UPDATE ON events
415
+ REFERENCING OLD TABLE AS old_table NEW TABLE AS new_table
416
+ FOR EACH STATEMENT EXECUTE FUNCTION save_events_on_update_trigger();