wowsql-sdk 3.0.2 → 3.9.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.
@@ -0,0 +1,201 @@
1
+ require 'json'
2
+ require 'thread'
3
+ require 'uri'
4
+
5
+ module WOWSQL
6
+ def self.build_realtime_websocket_url(project_url, api_key)
7
+ origin = project_url.to_s.sub(%r{/$}, '')
8
+ ws = origin.sub(/\Ahttp/, 'ws')
9
+ "#{ws}/realtime/v1/websocket?apikey=#{URI.encode_www_form_component(api_key)}"
10
+ end
11
+
12
+ class RealtimeChannel
13
+ attr_reader :name
14
+
15
+ def initialize(rt, name)
16
+ @rt = rt
17
+ @name = name
18
+ @joined = false
19
+ @state = {}
20
+ @broadcast = []
21
+ @presence = []
22
+ @tracked = nil
23
+ end
24
+
25
+ def on(kind, filter = {}, &cb)
26
+ if kind.to_s == 'broadcast'
27
+ ev = (filter[:event] || filter['event'] || '*').to_s
28
+ @broadcast << ->(msg) { cb.call(msg) if ev == '*' || ev == msg['event'].to_s }
29
+ else
30
+ @presence << cb
31
+ end
32
+ self
33
+ end
34
+
35
+ def subscribe(&on_status)
36
+ @rt.ensure_connected
37
+ @rt.send_json(type: 'join', channel: @name)
38
+ @joined = true
39
+ on_status&.call('SUBSCRIBED')
40
+ self
41
+ end
42
+
43
+ def send(event:, payload: {})
44
+ @rt.ensure_connected
45
+ unless @joined
46
+ @rt.send_json(type: 'join', channel: @name)
47
+ @joined = true
48
+ end
49
+ @rt.send_json(type: 'broadcast', channel: @name, event: event, payload: payload || {})
50
+ end
51
+
52
+ def track(payload)
53
+ @tracked = payload
54
+ @rt.ensure_connected
55
+ unless @joined
56
+ @rt.send_json(type: 'join', channel: @name)
57
+ @joined = true
58
+ end
59
+ @rt.send_json(type: 'presence', event: 'track', channel: @name, payload: payload)
60
+ end
61
+
62
+ def presence_state
63
+ @state.dup
64
+ end
65
+
66
+ def unsubscribe
67
+ @rt.send_json(type: 'leave', channel: @name)
68
+ @joined = false
69
+ @rt.drop_channel(@name)
70
+ end
71
+
72
+ def rejoin
73
+ @rt.send_json(type: 'join', channel: @name)
74
+ @rt.send_json(type: 'presence', event: 'track', channel: @name, payload: @tracked) if @tracked
75
+ end
76
+
77
+ def handle_server(message)
78
+ case message['type']
79
+ when 'joined' then @joined = true
80
+ when 'presence'
81
+ ev = message['event']
82
+ if ev == 'sync' && message['state'].is_a?(Hash)
83
+ @state = message['state']
84
+ elsif ev == 'join' && message['key']
85
+ @state[message['key']] = message['payload']
86
+ elsif ev == 'leave' && message['key']
87
+ @state.delete(message['key'])
88
+ end
89
+ @presence.each { |cb| cb.call(message) }
90
+ when 'broadcast'
91
+ wrapped = { 'event' => message['event'], 'payload' => message['payload'] || {}, 'channel' => @name }
92
+ @broadcast.each { |cb| cb.call(wrapped) }
93
+ end
94
+ end
95
+ end
96
+
97
+ class WowSQLRealtime
98
+ def initialize(project_url, api_key)
99
+ @project_url = project_url.to_s.sub(%r{/$}, '')
100
+ @api_key = api_key
101
+ @ws = nil
102
+ @manual = false
103
+ @subs = []
104
+ @channels = {}
105
+ @mutex = Mutex.new
106
+ end
107
+
108
+ def url
109
+ WOWSQL.build_realtime_websocket_url(@project_url, @api_key)
110
+ end
111
+
112
+ def channel(name)
113
+ @channels[name] ||= RealtimeChannel.new(self, name)
114
+ end
115
+
116
+ def subscribe(table, schema: 'public', event: '*', &callback)
117
+ sub = { schema: schema, table: table, event: event, callback: callback }
118
+ @subs << sub
119
+ Thread.new do
120
+ ensure_connected
121
+ send_json(type: 'subscribe', schema: schema, table: table, event: event)
122
+ end
123
+ -> { unsubscribe(sub) }
124
+ end
125
+
126
+ def disconnect
127
+ @manual = true
128
+ @ws&.close
129
+ @ws = nil
130
+ end
131
+
132
+ def drop_channel(name)
133
+ @channels.delete(name)
134
+ end
135
+
136
+ def send_json(obj)
137
+ @ws&.send(JSON.dump(obj))
138
+ end
139
+
140
+ def ensure_connected
141
+ return if @ws
142
+ begin
143
+ require 'websocket-client-simple'
144
+ rescue LoadError
145
+ raise LoadError, 'Realtime requires the websocket-client-simple gem'
146
+ end
147
+ @manual = false
148
+ rt = self
149
+ @ws = WebSocket::Client::Simple.connect(url) do |ws|
150
+ ws.on :message do |msg|
151
+ rt.send(:handle, msg.data)
152
+ end
153
+ ws.on :open do
154
+ rt.send(:on_open)
155
+ end
156
+ ws.on :close do |_e|
157
+ rt.send(:on_close)
158
+ end
159
+ end
160
+ sleep 0.2
161
+ end
162
+
163
+ private
164
+
165
+ def on_open
166
+ @subs.each { |s| send_json(type: 'subscribe', schema: s[:schema], table: s[:table], event: s[:event]) }
167
+ @channels.each_value(&:rejoin)
168
+ end
169
+
170
+ def on_close
171
+ @ws = nil
172
+ ensure_connected if !@manual && (!@subs.empty? || !@channels.empty?)
173
+ end
174
+
175
+ def unsubscribe(sub)
176
+ @subs.delete(sub)
177
+ send_json(type: 'unsubscribe', schema: sub[:schema], table: sub[:table])
178
+ end
179
+
180
+ def handle(raw)
181
+ message = JSON.parse(raw)
182
+ name = message['channel']
183
+ @channels[name]&.handle_server(message) if name
184
+ return unless message['type'] == 'broadcast'
185
+ return if message['channel'] && !message['table']
186
+ nested = message['payload'].is_a?(Hash) ? message['payload'] : {}
187
+ event = (message['event'] || nested['type'] || '').to_s.upcase
188
+ schema = (message['schema'] || nested['schema'] || 'public').to_s
189
+ table = (message['table'] || nested['table'] || '').to_s
190
+ return if table.empty? || !%w[INSERT UPDATE DELETE].include?(event)
191
+ change = { 'event' => event, 'schema' => schema, 'table' => table, 'payload' => nested }
192
+ change['new'] = nested['new'] if nested.key?('new')
193
+ change['old'] = nested['old'] if nested.key?('old')
194
+ @subs.each do |s|
195
+ next unless s[:schema] == schema && s[:table] == table
196
+ next unless s[:event] == '*' || s[:event].to_s.upcase == event
197
+ s[:callback].call(change)
198
+ end
199
+ end
200
+ end
201
+ end
data/lib/wowsql/schema.rb CHANGED
@@ -24,11 +24,11 @@ module WOWSQL
24
24
 
25
25
  # @param project_url [String] Project subdomain or full URL
26
26
  # @param service_key [String] Service role key (wowsql_service_...)
27
- # @param base_domain [String] Base domain (default: "wowsql.com")
27
+ # @param base_domain [String] Base domain (default: "wowsqlconnect.com")
28
28
  # @param secure [Boolean] Use HTTPS (default: true)
29
29
  # @param timeout [Integer] Request timeout in seconds (default: 30)
30
30
  # @param verify_ssl [Boolean] Verify SSL certificates (default: true)
31
- def initialize(project_url, service_key, base_domain: 'wowsql.com', secure: true,
31
+ def initialize(project_url, service_key, base_domain: 'wowsqlconnect.com', secure: true,
32
32
  timeout: 30, verify_ssl: true)
33
33
  if project_url.start_with?('http://') || project_url.start_with?('https://')
34
34
  base = project_url.chomp('/')
@@ -98,12 +98,12 @@ module WOWSQL
98
98
  # @param api_key [String] API key for authentication
99
99
  # @param project_slug [String] Explicit slug (used with base_url)
100
100
  # @param base_url [String] Explicit base URL (used with project_slug)
101
- # @param base_domain [String] Base domain (default: "wowsql.com")
101
+ # @param base_domain [String] Base domain (default: "wowsqlconnect.com")
102
102
  # @param secure [Boolean] Use HTTPS (default: true)
103
103
  # @param timeout [Integer] Request timeout in seconds (default: 60)
104
104
  # @param verify_ssl [Boolean] Verify SSL certificates (default: true)
105
105
  def initialize(project_url = '', api_key = '', project_slug: '', base_url: '',
106
- base_domain: 'wowsql.com', secure: true, timeout: 60, verify_ssl: true)
106
+ base_domain: 'wowsqlconnect.com', secure: true, timeout: 60, verify_ssl: true)
107
107
  if !project_slug.empty? && !base_url.empty?
108
108
  @base_url = base_url.chomp('/')
109
109
  @project_slug = project_slug
data/lib/wowsql/table.rb CHANGED
@@ -1,169 +1,182 @@
1
1
  require_relative 'query_builder'
2
2
 
3
3
  module WOWSQL
4
- # Table interface for database operations.
4
+ # Table interface for direct PostgREST operations.
5
+ #
6
+ # All mutations use PostgREST native query parameters (?id=eq.val) and
7
+ # Prefer headers (return=representation, resolution=merge-duplicates).
8
+ #
9
+ # @example
10
+ # user = client.table("users").get_by_id("uuid-here")
11
+ # result = client.table("users").create({ email: "a@b.com", name: "Alice" })
5
12
  class Table
6
13
  def initialize(client, table_name)
7
- @client = client
14
+ @client = client
8
15
  @table_name = table_name
9
16
  end
10
17
 
11
- # Start a query with column selection.
12
- #
13
- # @param columns [Array<String>] Column(s) to select
14
- # @return [QueryBuilder] QueryBuilder for chaining
18
+ # ── Query builder shortcuts ───────────────────────────────────
19
+
15
20
  def select(*columns)
16
21
  QueryBuilder.new(@client, @table_name).select(*columns)
17
22
  end
18
23
 
19
- # Start a query with a filter.
20
- #
21
- # @param column [String, Hash] Column name or filter hash
22
- # @param operator [String, nil] Operator
23
- # @param value [Object] Filter value
24
- # @param logical_op [String] "AND" or "OR"
25
- # @return [QueryBuilder] QueryBuilder for chaining
26
24
  def filter(column, operator = nil, value = nil, logical_op: 'AND')
27
25
  QueryBuilder.new(@client, @table_name).filter(column, operator, value, logical_op: logical_op)
28
26
  end
29
27
 
30
- # Get all records with optional filters.
28
+ def eq(column, value)
29
+ QueryBuilder.new(@client, @table_name).eq(column, value)
30
+ end
31
+
32
+ def neq(column, value)
33
+ QueryBuilder.new(@client, @table_name).neq(column, value)
34
+ end
35
+
36
+ def gt(column, value)
37
+ QueryBuilder.new(@client, @table_name).gt(column, value)
38
+ end
39
+
40
+ def gte(column, value)
41
+ QueryBuilder.new(@client, @table_name).gte(column, value)
42
+ end
43
+
44
+ def lt(column, value)
45
+ QueryBuilder.new(@client, @table_name).lt(column, value)
46
+ end
47
+
48
+ def lte(column, value)
49
+ QueryBuilder.new(@client, @table_name).lte(column, value)
50
+ end
51
+
52
+ def order_by(column, direction = 'asc')
53
+ QueryBuilder.new(@client, @table_name).order_by(column, direction)
54
+ end
55
+
56
+ def limit(n)
57
+ QueryBuilder.new(@client, @table_name).limit(n)
58
+ end
59
+
60
+ def offset(n)
61
+ QueryBuilder.new(@client, @table_name).offset(n)
62
+ end
63
+
64
+ # ── Read ─────────────────────────────────────────────────────
65
+
66
+ # Retrieve all rows (with optional filter options forwarded to QueryBuilder#get).
31
67
  #
32
- # @param options [Hash, nil] Query options
33
- # @return [Hash] Query response
68
+ # @param options [Hash, nil] Reserved for compatibility; use chained methods instead
69
+ # @return [Hash] { data: Array, count: Integer, total: Integer, limit: Integer, offset: Integer }
34
70
  def get(options = nil)
35
71
  QueryBuilder.new(@client, @table_name).get(options)
36
72
  end
37
73
 
38
- # Get a single record by ID.
74
+ # Retrieve a single row by primary key.
39
75
  #
40
- # @param record_id [Object] Record ID
41
- # @return [Hash] Record data
76
+ # @param record_id [String] Row UUID
77
+ # @return [Hash] Row data
42
78
  def get_by_id(record_id)
43
- @client.request('GET', "/#{@table_name}/#{record_id}", nil, nil)
79
+ result = @client.request(
80
+ 'GET', "/#{@table_name}",
81
+ { 'id' => "eq.#{record_id}" },
82
+ nil,
83
+ 'Prefer' => 'return=representation'
84
+ )
85
+ data = normalise_data(result)
86
+ data.first
44
87
  end
45
88
 
46
- # Create a new record.
89
+ # ── Write ────────────────────────────────────────────────────
90
+
91
+ # Insert a single row and return the created record.
47
92
  #
48
- # @param data [Hash] Record data
49
- # @return [Hash] Create response with new record ID
93
+ # @param data [Hash] Column→value map
94
+ # @return [Hash] Created row
50
95
  def create(data)
51
- @client.request('POST', "/#{@table_name}", nil, data)
96
+ result = @client.request(
97
+ 'POST', "/#{@table_name}", nil, data,
98
+ 'Prefer' => 'return=representation'
99
+ )
100
+ normalise_data(result).first || {}
52
101
  end
53
102
 
54
- # Insert a new record (alias for create).
55
- #
56
- # @param data [Hash] Record data
57
- # @return [Hash] Create response with new record ID
58
- def insert(data)
59
- create(data)
60
- end
103
+ alias insert create
61
104
 
62
- # Insert multiple records.
105
+ # Insert multiple rows.
63
106
  #
64
- # Attempts a single batch POST first. Falls back to individual
65
- # inserts if the server does not support batch creation.
66
- #
67
- # @param records [Array<Hash>] List of record hashes
68
- # @return [Array<Hash>] List of create responses
107
+ # @param records [Array<Hash>] List of row hashes
108
+ # @return [Array<Hash>] Created rows
69
109
  def bulk_insert(records)
70
110
  return [] if records.nil? || records.empty?
71
111
 
72
- begin
73
- result = @client.request('POST', "/#{@table_name}", nil, records)
74
- result.is_a?(Array) ? result : [result]
75
- rescue StandardError
76
- records.map { |record| create(record) }
77
- end
112
+ result = @client.request(
113
+ 'POST', "/#{@table_name}", nil, records,
114
+ 'Prefer' => 'return=representation'
115
+ )
116
+ normalise_data(result)
78
117
  end
79
118
 
80
119
  # Insert or update based on conflict column.
81
120
  #
82
- # Uses PostgreSQL ON CONFLICT (upsert). Falls back to
83
- # get-then-insert/update when the backend doesn't expose
84
- # a native upsert endpoint.
85
- #
86
- # @param data [Hash] Record data (must include the conflict column)
87
- # @param on_conflict [String] Column to check for conflicts (default: "id")
88
- # @return [Hash] Create or update response
121
+ # @param data [Hash] Row data (must include the conflict column value)
122
+ # @param on_conflict [String] Column to detect conflicts on (default: "id")
123
+ # @return [Hash] Upserted row
89
124
  def upsert(data, on_conflict: 'id')
90
- conflict_value = data[on_conflict] || data[on_conflict.to_sym]
91
- return create(data) if conflict_value.nil?
92
-
93
- existing = QueryBuilder.new(@client, @table_name).eq(on_conflict, conflict_value).first
94
- if existing
95
- update_data = data.reject { |k, _| k.to_s == on_conflict }
96
- if update_data.empty?
97
- { 'message' => 'No changes', 'affected_rows' => 0 }
98
- else
99
- update(conflict_value, update_data)
100
- end
101
- else
102
- create(data)
103
- end
125
+ result = @client.request(
126
+ 'POST', "/#{@table_name}", nil, data,
127
+ 'Prefer' => 'return=representation,resolution=merge-duplicates',
128
+ 'on-conflict-column' => on_conflict
129
+ )
130
+ normalise_data(result).first || {}
104
131
  end
105
132
 
106
- # Update a record by ID.
133
+ # Update a row by ID.
107
134
  #
108
- # @param record_id [Object] Record ID
109
- # @param data [Hash] Data to update
110
- # @return [Hash] Update response
135
+ # @param record_id [String] Row UUID
136
+ # @param data [Hash] Columns to update
137
+ # @return [Hash] Updated row
111
138
  def update(record_id, data)
112
- @client.request('PATCH', "/#{@table_name}/#{record_id}", nil, data)
139
+ result = @client.request(
140
+ 'PATCH', "/#{@table_name}",
141
+ { 'id' => "eq.#{record_id}" },
142
+ data,
143
+ 'Prefer' => 'return=representation'
144
+ )
145
+ normalise_data(result).first || {}
113
146
  end
114
147
 
115
- # Delete a record by ID.
148
+ # Delete a row by ID.
116
149
  #
117
- # @param record_id [Object] Record ID
118
- # @return [Hash] Delete response
150
+ # @param record_id [String] Row UUID
151
+ # @return [Hash] Deleted row
119
152
  def delete(record_id)
120
- @client.request('DELETE', "/#{@table_name}/#{record_id}", nil, nil)
121
- end
122
-
123
- # ── Convenience shortcuts ──────────────────────────────────
124
-
125
- def eq(column, value)
126
- QueryBuilder.new(@client, @table_name).eq(column, value)
127
- end
128
-
129
- def neq(column, value)
130
- QueryBuilder.new(@client, @table_name).neq(column, value)
131
- end
132
-
133
- def gt(column, value)
134
- QueryBuilder.new(@client, @table_name).gt(column, value)
135
- end
136
-
137
- def gte(column, value)
138
- QueryBuilder.new(@client, @table_name).gte(column, value)
139
- end
140
-
141
- def lt(column, value)
142
- QueryBuilder.new(@client, @table_name).lt(column, value)
153
+ result = @client.request(
154
+ 'DELETE', "/#{@table_name}",
155
+ { 'id' => "eq.#{record_id}" },
156
+ nil,
157
+ 'Prefer' => 'return=representation'
158
+ )
159
+ normalise_data(result).first || {}
143
160
  end
144
161
 
145
- def lte(column, value)
146
- QueryBuilder.new(@client, @table_name).lte(column, value)
147
- end
162
+ # ── Aggregates / pagination ───────────────────────────────────
148
163
 
149
- def order_by(column, direction = 'asc')
150
- QueryBuilder.new(@client, @table_name).order_by(column, direction)
151
- end
152
-
153
- # Get total record count for this table.
154
- #
155
- # @return [Integer]
156
164
  def count
157
165
  QueryBuilder.new(@client, @table_name).count
158
166
  end
159
167
 
160
- # Paginate all records in this table.
161
- #
162
- # @param page [Integer] Page number (1-indexed)
163
- # @param per_page [Integer] Records per page
164
- # @return [Hash]
165
168
  def paginate(page: 1, per_page: 20)
166
169
  QueryBuilder.new(@client, @table_name).paginate(page: page, per_page: per_page)
167
170
  end
171
+
172
+ private
173
+
174
+ def normalise_data(result)
175
+ case result
176
+ when Array then result
177
+ when Hash then result['data'] || result['rows'] || (result.key?('id') ? [result] : [])
178
+ else []
179
+ end
180
+ end
168
181
  end
169
182
  end
@@ -1,4 +1,4 @@
1
1
  module WOWSQL
2
- VERSION = "3.0.1"
2
+ VERSION = "3.9.0"
3
3
  end
4
4
 
data/lib/wowsql.rb CHANGED
@@ -6,6 +6,7 @@ require_relative 'wowsql/query_builder'
6
6
  require_relative 'wowsql/auth'
7
7
  require_relative 'wowsql/storage'
8
8
  require_relative 'wowsql/schema'
9
+ require_relative 'wowsql/realtime'
9
10
 
10
11
  module WOWSQL
11
12
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: wowsql-sdk
3
3
  version: !ruby/object:Gem::Version
4
- version: 3.0.2
4
+ version: 3.9.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - WOWSQL Team
@@ -51,6 +51,20 @@ dependencies:
51
51
  - - "~>"
52
52
  - !ruby/object:Gem::Version
53
53
  version: '2.0'
54
+ - !ruby/object:Gem::Dependency
55
+ name: websocket-client-simple
56
+ requirement: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - "~>"
59
+ - !ruby/object:Gem::Version
60
+ version: '0.3'
61
+ type: :runtime
62
+ prerelease: false
63
+ version_requirements: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - "~>"
66
+ - !ruby/object:Gem::Version
67
+ version: '0.3'
54
68
  - !ruby/object:Gem::Dependency
55
69
  name: rspec
56
70
  requirement: !ruby/object:Gem::Requirement
@@ -87,6 +101,7 @@ executables: []
87
101
  extensions: []
88
102
  extra_rdoc_files: []
89
103
  files:
104
+ - CHANGELOG.md
90
105
  - LICENSE
91
106
  - README.md
92
107
  - lib/wowmysql.rb
@@ -95,6 +110,7 @@ files:
95
110
  - lib/wowsql/client.rb
96
111
  - lib/wowsql/exceptions.rb
97
112
  - lib/wowsql/query_builder.rb
113
+ - lib/wowsql/realtime.rb
98
114
  - lib/wowsql/schema.rb
99
115
  - lib/wowsql/storage.rb
100
116
  - lib/wowsql/table.rb