zero_ruby 0.1.0.alpha5 → 0.1.0.alpha6

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 68451ec0c319aab33578ee4ac8aa28f61426ade64f4185047528387c5c95f3e5
4
- data.tar.gz: fc5b90718c5c0aa5ee11020b02e01d422cee0108bb1095c9aac8eb184dd6c050
3
+ metadata.gz: d3eab5f6867bccc496d6bb976fd8d6494c05888f085b53c7c92a62ff6cfcf1a8
4
+ data.tar.gz: dc879e22f7adf86659842f72763ef5ce4fea593a446116f185d4e5c9a72636c8
5
5
  SHA512:
6
- metadata.gz: b60c2a285735db948bf33ebd52f78fc09329ed3c0a012cc000dab2eda668bf37920058dd4444317ff8bde80fcf70713014ed24186037fdf6b9acf35f32f3dc6b
7
- data.tar.gz: bb7c53048a48d3c65c0303be1d67bf59368e3a2eec7ae8ce482c50e94304a8295be05ffb062e4591162a5d2cfb53f66d322b807e9c9c26c0039ec5ac3967e5d9
6
+ metadata.gz: e3c49d57a02dafcd912302ac4205a1a168a52711c6cf33f0ec078867b244ce1f0afca16887bf2da6a65a911efdd3ecbd11ccef526e614dc03062a209ded8e847
7
+ data.tar.gz: 44cfd5b1bc95f58f12de4b24803bdb7f1cf4dc38d85470553c86d5e6f79c4c3271072b83a774ace776282a16982ba068554211c18fb21d68e24c8415ba2166fa
data/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # zero_ruby
2
2
 
3
- A Ruby gem for handling [Zero](https://zero.rocicorp.dev/) mutations with type safety, validation, and full protocol support.
3
+ A Ruby gem for handling [Zero](https://zero.rocicorp.dev/) mutations with type safety, validation, and full protocol support. Compatible with Zero 0.25.12.
4
4
 
5
5
  ## Features
6
6
 
@@ -39,5 +39,25 @@ module ZeroRuby
39
39
  def transaction(&block)
40
40
  raise NotImplementedError, "#{self.class}#transaction must be implemented"
41
41
  end
42
+
43
+ # Persist a mutation result so clients can read it via replication.
44
+ # Used to surface error results back to the client.
45
+ #
46
+ # @param client_group_id [String] The client group ID
47
+ # @param client_id [String] The client ID
48
+ # @param mutation_id [Integer] The mutation ID
49
+ # @param result [Hash, String] The mutation result to persist
50
+ def write_mutation_result(client_group_id, client_id, mutation_id, result)
51
+ raise NotImplementedError, "#{self.class}#write_mutation_result must be implemented"
52
+ end
53
+
54
+ # Delete mutation results, called by _zero_cleanupResults to remove acknowledged results.
55
+ #
56
+ # @param args [Hash] Cleanup arguments from the _zero_cleanupResults mutation.
57
+ # For single/legacy format: { "clientGroupID" => String, "clientID" => String, "upToMutationID" => Integer }
58
+ # For bulk format: { "type" => "bulk", "clientGroupID" => String, "clientIDs" => Array<String> }
59
+ def delete_mutation_results(args)
60
+ raise NotImplementedError, "#{self.class}#delete_mutation_results must be implemented"
61
+ end
42
62
  end
43
63
  end
@@ -52,6 +52,53 @@ module ZeroRuby
52
52
  model_class.transaction(&block)
53
53
  end
54
54
 
55
+ # Write a mutation result to the zero_0.mutations table.
56
+ #
57
+ # @param client_group_id [String] The client group ID
58
+ # @param client_id [String] The client ID
59
+ # @param mutation_id [Integer] The mutation ID
60
+ # @param result [Hash, String] The mutation result. Hashes are serialized to JSON for storage.
61
+ def write_mutation_result(client_group_id, client_id, mutation_id, result)
62
+ result_json = begin
63
+ result.is_a?(String) ? result : result.to_json
64
+ rescue JSON::GeneratorError, Encoding::UndefinedConversionError
65
+ {error: "app", message: "Error result could not be serialized"}.to_json
66
+ end
67
+ sql = model_class.sanitize_sql_array([<<~SQL.squish, {client_group_id:, client_id:, mutation_id:, result: result_json}])
68
+ INSERT INTO zero_0.mutations ("clientGroupID", "clientID", "mutationID", "result")
69
+ VALUES (:client_group_id, :client_id, :mutation_id, :result::text::json)
70
+ SQL
71
+
72
+ model_class.connection.execute(sql)
73
+ end
74
+
75
+ # Delete mutation results from the zero_0.mutations table.
76
+ #
77
+ # @param args [Hash] Cleanup arguments
78
+ def delete_mutation_results(args)
79
+ client_group_id = args["clientGroupID"]
80
+
81
+ sql = if args["type"] == "bulk"
82
+ client_ids = args["clientIDs"]
83
+ model_class.sanitize_sql_array([<<~SQL.squish, {client_group_id:}])
84
+ DELETE FROM zero_0.mutations
85
+ WHERE "clientGroupID" = :client_group_id
86
+ AND "clientID" = ANY(ARRAY[#{client_ids.map { |id| model_class.connection.quote(id) }.join(",")}])
87
+ SQL
88
+ else
89
+ client_id = args["clientID"]
90
+ up_to_mutation_id = args["upToMutationID"]
91
+ model_class.sanitize_sql_array([<<~SQL.squish, {client_group_id:, client_id:, up_to_mutation_id:}])
92
+ DELETE FROM zero_0.mutations
93
+ WHERE "clientGroupID" = :client_group_id
94
+ AND "clientID" = :client_id
95
+ AND "mutationID" <= :up_to_mutation_id
96
+ SQL
97
+ end
98
+
99
+ model_class.connection.execute(sql)
100
+ end
101
+
55
102
  private
56
103
 
57
104
  def default_model_class
@@ -35,6 +35,11 @@ module ZeroRuby
35
35
  results = []
36
36
 
37
37
  mutations.each_with_index do |mutation_data, index|
38
+ if mutation_data["name"] == "_zero_cleanupResults"
39
+ handle_cleanup_results(mutation_data)
40
+ next
41
+ end
42
+
38
43
  result = process_mutation_with_lmid(mutation_data, client_group_id, context)
39
44
  results << result
40
45
  rescue OutOfOrderMutationError => e
@@ -89,7 +94,13 @@ module ZeroRuby
89
94
  result = lmid_store.transaction do
90
95
  last_mutation_id = lmid_store.fetch_and_increment(client_group_id, client_id)
91
96
  check_lmid!(client_id, mutation_id, last_mutation_id)
92
- user_block.call
97
+ begin
98
+ user_block.call
99
+ rescue ZeroRuby::Error
100
+ raise
101
+ rescue => e
102
+ raise ZeroRuby::Error.new(e.message)
103
+ end
93
104
  end
94
105
  phase = :post_commit
95
106
  result
@@ -107,23 +118,55 @@ module ZeroRuby
107
118
  # Application errors - advance LMID based on phase, return error response
108
119
  # Pre-transaction/transaction: LMID advanced separately
109
120
  # Post-commit: LMID already committed with transaction
121
+ error_response = format_error_response(e)
110
122
  if phase != :post_commit
111
- persist_lmid_on_application_error(client_group_id, client_id)
123
+ persist_mutation_failure(client_group_id, client_id, mutation_id, error_response)
112
124
  end
113
- {id: mutation_id_obj, result: format_error_response(e)}
125
+ {id: mutation_id_obj, result: error_response}
114
126
  rescue => e
115
- # Unexpected errors - wrap and bubble up as batch-terminating
116
- raise TransactionError.new("Transaction failed: #{e.message}")
127
+ if phase == :transaction
128
+ # Infrastructure error (user code errors already wrapped by transact_proc)
129
+ raise TransactionError.new("Transaction failed: #{e.message}")
130
+ else
131
+ # User code error in pre-transaction or post-commit phase
132
+ error_response = {error: "app", message: e.message}
133
+ if phase != :post_commit
134
+ persist_mutation_failure(client_group_id, client_id, mutation_id, error_response)
135
+ end
136
+ {id: mutation_id_obj, result: error_response}
137
+ end
117
138
  end
118
139
 
119
- # Persist LMID advancement after an application error.
120
- # Called for pre-transaction and transaction errors to prevent replay attacks.
121
- def persist_lmid_on_application_error(client_group_id, client_id)
140
+ # Persist LMID advancement and mutation error result after a failure.
141
+ # Advances the LMID so the failed mutation is not re-executed on retry,
142
+ # and writes the error result so clients can read it via replication.
143
+ def persist_mutation_failure(client_group_id, client_id, mutation_id, error_result)
122
144
  lmid_store.transaction do
123
145
  lmid_store.fetch_and_increment(client_group_id, client_id)
146
+ lmid_store.write_mutation_result(client_group_id, client_id, mutation_id, error_result)
147
+ end
148
+ rescue => e
149
+ warn "[ZeroRuby] Failed to persist mutation failure for " \
150
+ "client_group=#{client_group_id} client=#{client_id} mutation=#{mutation_id}: " \
151
+ "#{e.class}: #{e.message}"
152
+ end
153
+
154
+ # Handle _zero_cleanupResults mutations by deleting acknowledged results.
155
+ # Errors are caught and logged as warnings without propagating, matching
156
+ # the Zero protocol behavior where cleanup failures must not abort the push batch.
157
+ def handle_cleanup_results(mutation_data)
158
+ args = mutation_data["args"]
159
+ args = args.first if args.is_a?(Array)
160
+ unless args.is_a?(Hash) && args["clientGroupID"]
161
+ warn "[ZeroRuby] _zero_cleanupResults: invalid args: #{args.inspect}"
162
+ return
163
+ end
164
+ lmid_store.transaction do
165
+ lmid_store.delete_mutation_results(args)
124
166
  end
125
167
  rescue => e
126
- warn "Failed to persist LMID after application error: #{e.message}"
168
+ warn "[ZeroRuby] _zero_cleanupResults failed for " \
169
+ "clientGroupID=#{args&.dig("clientGroupID")}: #{e.class}: #{e.message}"
127
170
  end
128
171
 
129
172
  # Validate LMID against the post-increment value.
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ZeroRuby
4
- VERSION = "0.1.0.alpha5"
4
+ VERSION = "0.1.0.alpha6"
5
5
  end
metadata CHANGED
@@ -1,13 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: zero_ruby
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0.alpha5
4
+ version: 0.1.0.alpha6
5
5
  platform: ruby
6
6
  authors:
7
7
  - Alex Serban
8
8
  bindir: bin
9
9
  cert_chain: []
10
- date: 2026-01-01 00:00:00.000000000 Z
10
+ date: 2026-02-16 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: dry-struct