parse-stack-next 5.2.1 → 5.4.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.
Files changed (69) hide show
  1. checksums.yaml +4 -4
  2. data/.bundle/config +1 -0
  3. data/.gitignore +2 -0
  4. data/CHANGELOG.md +616 -0
  5. data/Gemfile +7 -0
  6. data/Gemfile.lock +12 -4
  7. data/README.md +296 -3
  8. data/Rakefile +243 -41
  9. data/docs/atlas_vector_search_guide.md +86 -2
  10. data/docs/client_sdk_guide.md +38 -0
  11. data/docs/mcp_guide.md +119 -4
  12. data/docs/mongodb_direct_guide.md +93 -1
  13. data/docs/usage_guide.md +11 -1
  14. data/docs/webhooks_guide.md +418 -0
  15. data/examples/README.md +46 -0
  16. data/examples/basic_client.rb +93 -0
  17. data/examples/basic_server.rb +109 -0
  18. data/examples/live_query_listener.rb +98 -0
  19. data/examples/rag_chatbot.rb +221 -0
  20. data/examples/webhook_server.rb +111 -0
  21. data/lib/parse/agent/mcp_rack_app.rb +285 -62
  22. data/lib/parse/agent/tools.rb +45 -5
  23. data/lib/parse/api/aggregate.rb +7 -1
  24. data/lib/parse/api/cloud_functions.rb +12 -4
  25. data/lib/parse/api/hooks.rb +46 -9
  26. data/lib/parse/api/objects.rb +16 -2
  27. data/lib/parse/api/path_segment.rb +33 -0
  28. data/lib/parse/api/server.rb +94 -0
  29. data/lib/parse/api/users.rb +58 -2
  30. data/lib/parse/atlas_search.rb +7 -7
  31. data/lib/parse/client/body_builder.rb +5 -0
  32. data/lib/parse/client/protocol.rb +4 -0
  33. data/lib/parse/client.rb +174 -9
  34. data/lib/parse/embeddings/spend_cap.rb +255 -0
  35. data/lib/parse/embeddings.rb +1 -0
  36. data/lib/parse/live_query/client.rb +3 -1
  37. data/lib/parse/live_query/subscription.rb +32 -5
  38. data/lib/parse/model/acl.rb +4 -2
  39. data/lib/parse/model/associations/belongs_to.rb +47 -0
  40. data/lib/parse/model/classes/audience.rb +52 -4
  41. data/lib/parse/model/classes/user.rb +200 -3
  42. data/lib/parse/model/core/embed_managed.rb +113 -0
  43. data/lib/parse/model/core/pluralized_aliases.rb +30 -0
  44. data/lib/parse/model/core/properties.rb +27 -0
  45. data/lib/parse/model/core/querying.rb +73 -1
  46. data/lib/parse/model/core/vector_searchable.rb +161 -0
  47. data/lib/parse/model/file.rb +35 -2
  48. data/lib/parse/model/object.rb +28 -5
  49. data/lib/parse/mongodb.rb +7 -1
  50. data/lib/parse/pipeline_security.rb +5 -3
  51. data/lib/parse/query/constraints.rb +29 -0
  52. data/lib/parse/query.rb +265 -27
  53. data/lib/parse/retrieval/agent_tool.rb +49 -0
  54. data/lib/parse/retrieval/reranker/cohere.rb +218 -0
  55. data/lib/parse/retrieval/reranker.rb +157 -0
  56. data/lib/parse/retrieval/retriever.rb +110 -23
  57. data/lib/parse/stack/version.rb +1 -1
  58. data/lib/parse/stack.rb +173 -1
  59. data/lib/parse/two_factor_auth/user_extension.rb +123 -31
  60. data/lib/parse/vector_search/hybrid.rb +578 -0
  61. data/lib/parse/webhooks/payload.rb +399 -11
  62. data/lib/parse/webhooks/trigger_audit.rb +502 -0
  63. data/lib/parse/webhooks.rb +215 -3
  64. data/scripts/docker/Dockerfile.parse +5 -1
  65. data/scripts/docker/docker-compose.test.yml +31 -0
  66. data/scripts/docker/docker-compose.verifyemail.yml +4 -0
  67. data/scripts/docker/preflight.sh +76 -0
  68. data/scripts/start-parse.sh +52 -4
  69. metadata +16 -1
@@ -0,0 +1,93 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # Basic Client Setup for parse-stack-next
5
+ #
6
+ # The UNPRIVILEGED side: configure the SDK WITHOUT a master key — the way a
7
+ # mobile app, browser, or untrusted worker uses it. There is no admin escape
8
+ # hatch, so authorization is carried per-call by the user's sessionToken and
9
+ # Parse Server is the enforcement boundary (CLP rejects, ACL filters rows,
10
+ # protectedFields strips columns).
11
+ #
12
+ # This example logs a user in and shows that a row-level ACL actually blocks
13
+ # reads: the owning user can read their object; an anonymous client cannot.
14
+ #
15
+ # See basic_server.rb for the privileged (master-key) counterpart.
16
+ #
17
+ # Prerequisite: the `Post` class must already exist on the server. A no-master
18
+ # client cannot create a class when Parse Server's allowClientClassCreation is
19
+ # false (the default since 5.0), so run examples/basic_server.rb first (it
20
+ # provisions Post with the master key) — or create the class yourself.
21
+ #
22
+ # Run it (REST key only — no master key in this process):
23
+ # export PARSE_SERVER_URL=http://localhost:1337/parse
24
+ # export PARSE_APP_ID=... PARSE_REST_KEY=...
25
+ # ruby examples/basic_client.rb
26
+
27
+ require "parse-stack-next"
28
+
29
+ # ---------------------------------------------------------------------------
30
+ # 1. Configure a no-master-key client
31
+ # ---------------------------------------------------------------------------
32
+ Parse.setup(
33
+ server_url: ENV.fetch("PARSE_SERVER_URL", "http://localhost:1337/parse"),
34
+ app_id: ENV.fetch("PARSE_APP_ID"),
35
+ api_key: ENV.fetch("PARSE_REST_KEY"),
36
+ master_key: nil, # explicit: never set this from env in client builds
37
+ logging: false,
38
+ )
39
+
40
+ # Belt-and-suspenders: prove the master key really is absent.
41
+ raise "master key leaked into a client process!" unless Parse.client.master_key.nil?
42
+
43
+ class Post < Parse::Object
44
+ property :title, :string
45
+ property :body, :string
46
+ end
47
+
48
+ # ---------------------------------------------------------------------------
49
+ # 2. Authenticate (log in, or sign up on first run)
50
+ # ---------------------------------------------------------------------------
51
+ USERNAME = "ada"
52
+ PASSWORD = "p4ssw0rd!"
53
+
54
+ # Parse::User.login returns nil on bad/unknown credentials (it does not raise),
55
+ # so fall back to signup the first time.
56
+ user = Parse::User.login(USERNAME, PASSWORD) ||
57
+ Parse::User.signup(USERNAME, PASSWORD, "ada@example.com")
58
+
59
+ puts "Logged in as #{user.username} (#{user.id})"
60
+ puts "Session token: #{user.session_token[0, 8]}…"
61
+
62
+ # ---------------------------------------------------------------------------
63
+ # 3. Create an owner-only object AS the user
64
+ # ---------------------------------------------------------------------------
65
+ # `with_session` authorizes every REST-routed op in the block as this user.
66
+ post = user.with_session do
67
+ p = Post.new(title: "My private note", body: "Only Ada may read this.")
68
+ # Owner-only ACL: grant read+write to this user, no public access.
69
+ acl = Parse::ACL.new # empty == no public, no one
70
+ acl.apply(user.id, true, true) # this user: read + write
71
+ p.acl = acl
72
+ p.save
73
+ p
74
+ end
75
+ puts "Created Post #{post.id} with an owner-only ACL"
76
+
77
+ # ---------------------------------------------------------------------------
78
+ # 4. Read it back AS the owner — succeeds
79
+ # ---------------------------------------------------------------------------
80
+ as_owner = user.with_session { Post.find(post.id) }
81
+ puts "As owner -> #{as_owner ? "READ OK: #{as_owner.title.inspect}" : "BLOCKED"}"
82
+
83
+ # ---------------------------------------------------------------------------
84
+ # 5. Read it back ANONYMOUSLY (no session token) — blocked by the ACL
85
+ # ---------------------------------------------------------------------------
86
+ # No master key + no session => a plain REST request the ACL filters out.
87
+ # `first` returns nil rather than raising when the row is not visible.
88
+ anon = Post.first(objectId: post.id)
89
+ puts "Anonymous -> #{anon ? "READ OK (unexpected!): #{anon.title.inspect}" : "BLOCKED (nil) — ACL enforced"}"
90
+
91
+ # Takeaway: identical SDK calls return the row for the owner and nil for an
92
+ # unauthorized caller. That difference is Parse Server enforcing the ACL —
93
+ # the client SDK simply threads the auth context and reports the verdict.
@@ -0,0 +1,109 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # Basic Server-Side Setup for parse-stack-next
5
+ #
6
+ # The privileged way an app/server boots the SDK: configure a client WITH the
7
+ # master key, define a model, push its schema, and do CRUD + queries. Because
8
+ # the master key is present, Parse Server treats every request as an admin
9
+ # operation (ACL / CLP / protectedFields are bypassed) — which is exactly what
10
+ # you want for a trusted backend, and exactly what you must NOT do in an
11
+ # untrusted client (see basic_client.rb for that side).
12
+ #
13
+ # Run it:
14
+ # export PARSE_SERVER_URL=http://localhost:1337/parse
15
+ # export PARSE_APP_ID=... PARSE_REST_KEY=... PARSE_MASTER_KEY=...
16
+ # ruby examples/basic_server.rb
17
+
18
+ require "parse-stack-next"
19
+
20
+ # ---------------------------------------------------------------------------
21
+ # 1. Configure the (master-key) client
22
+ # ---------------------------------------------------------------------------
23
+ Parse.setup(
24
+ server_url: ENV.fetch("PARSE_SERVER_URL", "http://localhost:1337/parse"),
25
+ app_id: ENV.fetch("PARSE_APP_ID"),
26
+ api_key: ENV.fetch("PARSE_REST_KEY"),
27
+ master_key: ENV.fetch("PARSE_MASTER_KEY"),
28
+ )
29
+
30
+ # ---------------------------------------------------------------------------
31
+ # 2. Define models
32
+ # ---------------------------------------------------------------------------
33
+ class Artist < Parse::Object
34
+ property :name, :string, required: true
35
+ property :country, :string
36
+ end
37
+
38
+ class Song < Parse::Object
39
+ property :title, :string, required: true
40
+ property :plays, :integer, default: 0
41
+ property :released_on, :date
42
+
43
+ belongs_to :artist # stored as a Pointer<Artist>
44
+ end
45
+
46
+ # Provisioned here for the companion basic_client.rb. A no-master client can't
47
+ # create a class when Parse Server's allowClientClassCreation is false (the
48
+ # default since Parse Server 5.0), so the trusted side defines it up front.
49
+ class Post < Parse::Object
50
+ property :title, :string
51
+ property :body, :string
52
+ end
53
+
54
+ # ---------------------------------------------------------------------------
55
+ # 3. Push the schema (server-side only — needs the master key)
56
+ # ---------------------------------------------------------------------------
57
+ # auto_upgrade! creates the class and any missing columns on Parse Server to
58
+ # match the model definition. Run it at boot / deploy, not on every request.
59
+ Artist.auto_upgrade!
60
+ Song.auto_upgrade!
61
+ Post.auto_upgrade!
62
+
63
+ # ---------------------------------------------------------------------------
64
+ # 4. Create
65
+ # ---------------------------------------------------------------------------
66
+ artist = Artist.create!(name: "Daft Punk", country: "FR")
67
+
68
+ song = Song.new(title: "One More Time", plays: 1_000, artist: artist)
69
+ song.save # => true (returns false + sets .errors on failure)
70
+ puts "Created Song #{song.id}: #{song.title}"
71
+
72
+ # create! is `new(attrs).save!` in one call (raises on failure):
73
+ Song.create!(title: "Harder, Better, Faster, Stronger", plays: 2_500, artist: artist)
74
+
75
+ # ---------------------------------------------------------------------------
76
+ # 5. Read
77
+ # ---------------------------------------------------------------------------
78
+ found = Song.query(:objectId => song.id).include(:artist).first # eager-load the pointer
79
+ puts "Fetched: #{found.title} by #{found.artist.name}"
80
+
81
+ first_hit = Song.first(title: "One More Time")
82
+ puts "First match plays: #{first_hit.plays}"
83
+
84
+ # ---------------------------------------------------------------------------
85
+ # 6. Update
86
+ # ---------------------------------------------------------------------------
87
+ song.plays += 1
88
+ song.save
89
+ puts "Updated plays: #{song.plays}"
90
+
91
+ # ---------------------------------------------------------------------------
92
+ # 7. Query
93
+ # ---------------------------------------------------------------------------
94
+ # DataMapper-style constraints. Symbol operators (:plays.gt) build comparisons;
95
+ # order / limit chain on.
96
+ popular = Song.query(:plays.gt => 1_500)
97
+ .where(artist: artist)
98
+ .order(:plays.desc)
99
+ .limit(10)
100
+ .results
101
+ puts "Popular songs: #{popular.map(&:title).join(', ')}"
102
+
103
+ puts "Total songs by #{artist.name}: #{Song.count(artist: artist)}"
104
+
105
+ # ---------------------------------------------------------------------------
106
+ # 8. Delete
107
+ # ---------------------------------------------------------------------------
108
+ song.destroy
109
+ puts "Destroyed #{song.id}"
@@ -0,0 +1,98 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # Live Query Listener for parse-stack-next
5
+ #
6
+ # An interactive console listener — like a tail / `rails console` that just
7
+ # prints: it logs in as a user, opens a LiveQuery subscription scoped to that
8
+ # user's session token, and prints every event (create / update / delete /
9
+ # enter / leave) until you press Ctrl-C.
10
+ #
11
+ # Because the subscription carries the user's `sessionToken`, Parse Server
12
+ # enforces ACLs on the live stream too: you only receive events for objects
13
+ # that user is allowed to read — "whatever the user can hear." Swap in a
14
+ # master-key subscription (use_master_key: true) to hear everything.
15
+ #
16
+ # Prerequisite: the `Post` class must exist on the server (run
17
+ # examples/basic_server.rb first, which provisions it). You also need a Parse
18
+ # Server with the LiveQuery websocket server enabled for the Post class.
19
+ #
20
+ # Run it, then in another terminal create/update/destroy Posts (e.g. via
21
+ # examples/basic_client.rb or the dashboard) and watch them stream in:
22
+ # export PARSE_SERVER_URL=http://localhost:1337/parse
23
+ # export PARSE_APP_ID=... PARSE_REST_KEY=...
24
+ # export PARSE_LIVE_QUERY_URL=ws://localhost:1337/parse # ws:// or wss://
25
+ # ruby examples/live_query_listener.rb
26
+
27
+ require "parse-stack-next"
28
+ require "parse/live_query"
29
+
30
+ # ---------------------------------------------------------------------------
31
+ # 1. Configure the REST client + the LiveQuery websocket client
32
+ # ---------------------------------------------------------------------------
33
+ Parse.setup(
34
+ server_url: ENV.fetch("PARSE_SERVER_URL", "http://localhost:1337/parse"),
35
+ app_id: ENV.fetch("PARSE_APP_ID"),
36
+ api_key: ENV.fetch("PARSE_REST_KEY"),
37
+ master_key: nil, # a plain client — the session token does the scoping
38
+ logging: false,
39
+ )
40
+
41
+ Parse.live_query_enabled = true
42
+ Parse::LiveQuery.configure do |config|
43
+ config.url = ENV.fetch("PARSE_LIVE_QUERY_URL", "ws://localhost:1337/parse")
44
+ config.application_id = ENV.fetch("PARSE_APP_ID")
45
+ config.client_key = ENV.fetch("PARSE_REST_KEY")
46
+ end
47
+
48
+ class Post < Parse::Object
49
+ property :title, :string
50
+ property :body, :string
51
+ end
52
+
53
+ # ---------------------------------------------------------------------------
54
+ # 2. Authenticate (so the subscription is ACL-scoped to this user)
55
+ # ---------------------------------------------------------------------------
56
+ USERNAME = ENV.fetch("PARSE_USERNAME", "ada")
57
+ PASSWORD = ENV.fetch("PARSE_PASSWORD", "p4ssw0rd!")
58
+
59
+ user = Parse::User.login(USERNAME, PASSWORD) ||
60
+ Parse::User.signup(USERNAME, PASSWORD, "ada@example.com")
61
+ puts "Listening as #{user.username} (#{user.id}) — only Posts this user can read.\n\n"
62
+
63
+ # ---------------------------------------------------------------------------
64
+ # 3. Open the subscription + register handlers
65
+ # ---------------------------------------------------------------------------
66
+ def stamp = Time.now.strftime("%H:%M:%S")
67
+
68
+ # `where:` narrows the live query (omit it to hear every readable Post);
69
+ # `session_token:` is what makes Parse Server apply this user's ACL to the
70
+ # stream. Use `use_master_key: true` instead to listen to everything.
71
+ subscription = Post.subscribe(
72
+ where: {}, # e.g. { :title.exists => true }
73
+ session_token: user.session_token,
74
+ )
75
+
76
+ subscription.on(:subscribe) { puts "[#{stamp}] subscribed — waiting for events…" }
77
+ subscription.on(:create) { |post| puts "[#{stamp}] CREATE #{post.id} #{post.title.inspect}" }
78
+ subscription.on(:update) { |post, _orig| puts "[#{stamp}] UPDATE #{post.id} #{post.title.inspect}" }
79
+ subscription.on(:delete) { |post| puts "[#{stamp}] DELETE #{post.id}" }
80
+ subscription.on(:enter) { |post, _orig| puts "[#{stamp}] ENTER #{post.id} (now matches query)" }
81
+ subscription.on(:leave) { |post, _orig| puts "[#{stamp}] LEAVE #{post.id} (no longer matches)" }
82
+ subscription.on(:error) { |err| warn "[#{stamp}] ERROR #{err}" }
83
+
84
+ # ---------------------------------------------------------------------------
85
+ # 4. Block and print until Ctrl-C
86
+ # ---------------------------------------------------------------------------
87
+ running = true
88
+ trap("INT") do
89
+ running = false # keep the handler tiny — just flip the flag
90
+ end
91
+
92
+ puts "Press Ctrl-C to stop.\n\n"
93
+ sleep 0.2 while running # events arrive on the websocket thread
94
+
95
+ puts "\nStopping…"
96
+ subscription.unsubscribe
97
+ Parse::LiveQuery.reset! # closes the websocket connection
98
+ puts "Done."
@@ -0,0 +1,221 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # RAG Chatbot Example for parse-stack-next
5
+ #
6
+ # A retrieval-augmented-generation (RAG) chatbot in a single file:
7
+ #
8
+ # 1. Store documents in Parse; the SDK manages their embeddings on save.
9
+ # 2. Retrieve the most relevant passages for a question with the
10
+ # `semantic_search` agent tool.
11
+ # 3. Hand those passages to an LLM to write the answer.
12
+ #
13
+ # The SDK owns the **R** (retrieval) and the embedding lifecycle. The **G**
14
+ # (generation) is a thin add-in over the OpenAI or Anthropic HTTP API, shown
15
+ # here with zero extra gems (`net/http` only).
16
+ #
17
+ # Retrieval (`Parse::Retrieval` / `semantic_search`) shipped in v5.2; managed
18
+ # `embed` and the embeddings provider registry shipped in v5.1. Vector search
19
+ # runs against MongoDB Atlas (`$vectorSearch`), so point the SDK at an
20
+ # Atlas-backed Parse Server / Mongo URI.
21
+ #
22
+ # Run it:
23
+ # export OPENAI_API_KEY=sk-... # embeddings + (optionally) generation
24
+ # export ANTHROPIC_API_KEY=sk-ant-... # if using the Anthropic backend
25
+ # export PARSE_SERVER_URL=http://localhost:1337/parse
26
+ # export PARSE_APP_ID=... PARSE_REST_KEY=... PARSE_MASTER_KEY=...
27
+ # ruby examples/rag_chatbot.rb
28
+
29
+ require "parse-stack-next"
30
+ require "net/http"
31
+ require "json"
32
+
33
+ # ---------------------------------------------------------------------------
34
+ # 1. Embedding provider + Parse connection
35
+ # ---------------------------------------------------------------------------
36
+
37
+ # text-embedding-3-small is 1536-dim. Register it under :openai so the model's
38
+ # :vector property can resolve it by name at save / query time.
39
+ Parse::Embeddings.register(
40
+ :openai,
41
+ Parse::Embeddings::OpenAI.new(api_key: ENV.fetch("OPENAI_API_KEY")),
42
+ )
43
+
44
+ Parse.setup(
45
+ server_url: ENV.fetch("PARSE_SERVER_URL", "http://localhost:1337/parse"),
46
+ app_id: ENV.fetch("PARSE_APP_ID"),
47
+ api_key: ENV.fetch("PARSE_REST_KEY"),
48
+ master_key: ENV.fetch("PARSE_MASTER_KEY"),
49
+ )
50
+
51
+ # ---------------------------------------------------------------------------
52
+ # 2. The model
53
+ # ---------------------------------------------------------------------------
54
+ #
55
+ # `embed` declares a MANAGED embedding: list the source fields, name the
56
+ # :vector property they feed, and the SDK recomputes the vector on `save`
57
+ # whenever those fields change (digest-tracked, so a no-op save makes zero
58
+ # provider calls). You never assign `embedding` yourself — it is
59
+ # write-protected. Set title/body and save.
60
+ #
61
+ # `agent_searchable` opts the class into the `semantic_search` agent tool and
62
+ # declares which fields an agent may filter on.
63
+ class KnowledgeArticle < Parse::Object
64
+ property :title, :string
65
+ property :body, :string
66
+ property :category, :string
67
+
68
+ property :embedding, :vector, dimensions: 1536, provider: :openai
69
+
70
+ # title + body feed :embedding, recomputed on save.
71
+ embed :title, :body, into: :embedding
72
+
73
+ # Opt into semantic_search; allow filtering on :category.
74
+ agent_searchable field: :embedding, filter_fields: %i[category]
75
+
76
+ # Declare the Atlas $vectorSearch index that retrieval needs.
77
+ mongo_search_index "knowledge_embedding",
78
+ { fields: [{ type: "vector", path: "embedding",
79
+ numDimensions: 1536, similarity: "cosine" }] },
80
+ type: "vectorSearch"
81
+ end
82
+
83
+ # ---------------------------------------------------------------------------
84
+ # 3. The LLM generation add-in (NOT part of the SDK)
85
+ # ---------------------------------------------------------------------------
86
+ #
87
+ # ~15 lines of HTTP per backend. Both take the retrieved chunks as context and
88
+ # return an answer grounded in them.
89
+ module ChatAnswerer
90
+ PROMPT = <<~SYS
91
+ You are a support assistant. Answer ONLY from the context below.
92
+ If the context does not contain the answer, say you don't know.
93
+ SYS
94
+
95
+ module_function
96
+
97
+ def context(chunks)
98
+ chunks.map { |c| "- #{c[:content]}" }.join("\n")
99
+ end
100
+
101
+ # --- OpenAI backend ---
102
+ def openai(question, chunks, model: "gpt-4o-mini")
103
+ post("https://api.openai.com/v1/chat/completions",
104
+ { "Authorization" => "Bearer #{ENV.fetch('OPENAI_API_KEY')}" },
105
+ { model: model,
106
+ messages: [
107
+ { role: "system", content: PROMPT },
108
+ { role: "user",
109
+ content: "Context:\n#{context(chunks)}\n\nQuestion: #{question}" },
110
+ ] })
111
+ .dig("choices", 0, "message", "content")
112
+ end
113
+
114
+ # --- Anthropic backend ---
115
+ def anthropic(question, chunks, model: "claude-opus-4-8")
116
+ post("https://api.anthropic.com/v1/messages",
117
+ { "x-api-key" => ENV.fetch("ANTHROPIC_API_KEY"),
118
+ "anthropic-version" => "2023-06-01" },
119
+ { model: model, max_tokens: 1024, system: PROMPT,
120
+ messages: [
121
+ { role: "user",
122
+ content: "Context:\n#{context(chunks)}\n\nQuestion: #{question}" },
123
+ ] })
124
+ .dig("content", 0, "text")
125
+ end
126
+
127
+ def post(url, headers, body)
128
+ uri = URI(url)
129
+ req = Net::HTTP::Post.new(uri, { "Content-Type" => "application/json" }.merge(headers))
130
+ req.body = JSON.generate(body)
131
+ res = Net::HTTP.start(uri.host, uri.port, use_ssl: true) { |http| http.request(req) }
132
+ JSON.parse(res.body)
133
+ end
134
+ end
135
+
136
+ # ---------------------------------------------------------------------------
137
+ # 4. Retrieval helper
138
+ # ---------------------------------------------------------------------------
139
+ #
140
+ # An unscoped Parse::Agent (no session_token: / acl_user: / acl_role:) runs in
141
+ # master posture using the master key already on the default client from
142
+ # Parse.setup — there is NO `master_key:` constructor argument.
143
+ #
144
+ # To scope retrieval to a signed-in user (so the bot only ever sees documents
145
+ # that user may read), construct it as:
146
+ # Parse::Agent.new(session_token: user.session_token)
147
+ #
148
+ # `execute(:semantic_search, ...)` returns
149
+ # { success:, data: { chunks:, documents:, count: } }.
150
+ def retrieve(agent, question, k: 4)
151
+ result = agent.execute(:semantic_search,
152
+ class_name: "KnowledgeArticle",
153
+ query: question,
154
+ k: k)
155
+ raise result[:error].to_s unless result[:success]
156
+
157
+ # Each chunk: { id:, score:, content:, metadata: { object_id:, ... } }.
158
+ # The parent record lives once in data[:documents], keyed by objectId.
159
+ result[:data][:chunks]
160
+ end
161
+
162
+ # ---------------------------------------------------------------------------
163
+ # 5. Seed a corpus + chat loop (runs when executed directly)
164
+ # ---------------------------------------------------------------------------
165
+
166
+ CORPUS = [
167
+ { title: "Resetting your password",
168
+ body: "Open Settings, choose Security, then Reset Password. A link is emailed to you.",
169
+ category: "account" },
170
+ { title: "Exporting your data",
171
+ body: "Use Settings > Export to download a ZIP of all your documents as JSON.",
172
+ category: "data" },
173
+ { title: "Billing cycles",
174
+ body: "Plans renew monthly on the date you subscribed. Cancel anytime before renewal.",
175
+ category: "billing" },
176
+ ].freeze
177
+
178
+ # Bulk back-fill / precompute outside the managed-save path: call the provider
179
+ # directly. `embed_text_batched` splits into the provider's recommended batch
180
+ # size (OpenAI: 100) and returns one vector per string, in order. (Not used by
181
+ # the demo below — `save` handles embedding — but shown for completeness.)
182
+ def precompute_vectors(texts)
183
+ provider = Parse::Embeddings.provider(:openai)
184
+ provider.embed_text_batched(texts, input_type: :search_document)
185
+ end
186
+
187
+ def seed_corpus!
188
+ # $vectorSearch needs an Atlas vector index on `embedding`, or retrieval
189
+ # raises IndexNotResolved. The model declares it; apply it once. This uses
190
+ # the mongo-direct writer (requires Parse::MongoDB configured against Atlas)
191
+ # — or create the same index in the Atlas UI.
192
+ KnowledgeArticle.apply_search_indexes!(wait: true)
193
+
194
+ # Managed embedding means ingestion is just `save`: each save that changes a
195
+ # source field makes one embedding call; unchanged re-saves make none.
196
+ CORPUS.each { |attrs| KnowledgeArticle.new(attrs).save }
197
+ end
198
+
199
+ def chat_loop(backend: :anthropic)
200
+ # Master posture (reads everything). Emits a one-time master-key warning to
201
+ # stderr; silence it with Parse::Agent.suppress_master_key_warning = true.
202
+ agent = Parse::Agent.new
203
+
204
+ puts "Ask a question (Ctrl-D to quit):"
205
+ while (question = $stdin.gets&.strip)
206
+ next if question.empty?
207
+
208
+ chunks = retrieve(agent, question)
209
+ answer = ChatAnswerer.public_send(backend, question, chunks)
210
+
211
+ puts "\n#{answer}\n"
212
+ sources = chunks.map { |c| c.dig(:metadata, :object_id) }.uniq.join(", ")
213
+ puts " (sources: #{sources})\n\n"
214
+ end
215
+ end
216
+
217
+ if __FILE__ == $PROGRAM_NAME
218
+ seed_corpus!
219
+ # Pick :openai or :anthropic for the generation step.
220
+ chat_loop(backend: :anthropic)
221
+ end
@@ -0,0 +1,111 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # Cloud Code Webhooks for parse-stack-next
5
+ #
6
+ # Webhooks are how a Ruby backend runs SERVER-SIDE trigger logic. Without them,
7
+ # a Parse::Object's ActiveModel callbacks (before_save, after_create, …) run
8
+ # ONLY in the Ruby process that initiated the save. A write that comes from a
9
+ # JS/Swift/REST client — or the Parse Dashboard — never touches your Ruby code,
10
+ # so that logic is silently skipped server-side.
11
+ #
12
+ # Registering a webhook flips that: Parse Server calls back into this Ruby app
13
+ # on the matching trigger, and your ActiveModel callbacks + webhook blocks run
14
+ # for EVERY client, not just Ruby ones.
15
+ #
16
+ # This file is a Rack app. Mount it (config.ru):
17
+ #
18
+ # require_relative "examples/webhook_server"
19
+ # run Parse::Webhooks
20
+ #
21
+ # and point Parse Server's `webhookKey` at the same value as Parse::Webhooks.key.
22
+ #
23
+ # See docs/webhooks_guide.md for the full picture (trigger types, the
24
+ # ActiveModel↔Parse hook relationship, latency, and replay protection).
25
+
26
+ require "parse-stack-next"
27
+
28
+ # ---------------------------------------------------------------------------
29
+ # 1. Configure the (master-key) client + the webhook key
30
+ # ---------------------------------------------------------------------------
31
+ Parse.setup(
32
+ server_url: ENV.fetch("PARSE_SERVER_URL", "http://localhost:1337/parse"),
33
+ app_id: ENV.fetch("PARSE_APP_ID"),
34
+ api_key: ENV.fetch("PARSE_REST_KEY"),
35
+ master_key: ENV.fetch("PARSE_MASTER_KEY"),
36
+ )
37
+
38
+ # Shared secret Parse Server sends as `X-Parse-Webhook-Key`. Set the same value
39
+ # in Parse Server's `webhookKey` option. Requests without it are rejected.
40
+ Parse::Webhooks.key = ENV.fetch("PARSE_WEBHOOK_KEY")
41
+
42
+ # Optional: server-initiated replay/freshness protection (see the guide). The
43
+ # body+request-id dedup is always on; an HMAC signing secret adds freshness
44
+ # verification of X-Parse-Webhook-Timestamp / X-Parse-Webhook-Signature.
45
+ Parse::Webhooks::ReplayProtection.signing_secret = ENV["PARSE_WEBHOOK_SIGNING_SECRET"]
46
+
47
+ # ---------------------------------------------------------------------------
48
+ # 2. A model with both ActiveModel callbacks AND webhook blocks
49
+ # ---------------------------------------------------------------------------
50
+ class Post < Parse::Object
51
+ property :title, :string, required: true
52
+ property :slug, :string
53
+ property :published, :boolean, default: false
54
+
55
+ # ActiveModel callbacks. For a Ruby-initiated save these run locally; for a
56
+ # NON-Ruby client they run here only if a beforeSave/afterSave webhook is
57
+ # registered for Post. Registering beforeSave enables BOTH before_save and
58
+ # before_create; afterSave enables both after_save and after_create.
59
+ before_save :normalize_slug
60
+ before_create { self.published = false } # created drafts start unpublished
61
+ after_create :enqueue_welcome # see note on afterSave below
62
+
63
+ def normalize_slug
64
+ self.slug = title.to_s.downcase.strip.gsub(/[^a-z0-9]+/, "-") if title_changed?
65
+ end
66
+
67
+ def enqueue_welcome
68
+ # AFTER-SAVE BEST PRACTICE: enqueue, don't execute. Parse Server blocks the
69
+ # client's write until this webhook returns — even though afterSave's return
70
+ # is a no-op — so long work here adds latency to every save. And do NOT save
71
+ # another object here if you can avoid it: each cascading save fires more
72
+ # webhooks (a latency cascade). Hand off to a background job instead.
73
+ # BackgroundJobs.enqueue(:index_post, id)
74
+ end
75
+ end
76
+
77
+ Post.auto_upgrade!
78
+
79
+ # ---------------------------------------------------------------------------
80
+ # 3. Webhook blocks (optional) — server-side logic without a Ruby model callback
81
+ # ---------------------------------------------------------------------------
82
+ # A block runs in the scope of a Parse::Webhooks::Payload. A beforeSave block
83
+ # returns `parse_object` (the SDK turns it into the changes Parse Server wants);
84
+ # returning `false` halts the save.
85
+ class Post
86
+ webhook :before_save do
87
+ # `parse_object` is the incoming object; mutate it, then return it.
88
+ parse_object
89
+ end
90
+
91
+ # afterSave/afterDelete blocks may register more than one handler.
92
+ webhook :after_save do
93
+ # keep this short or enqueue — see enqueue_welcome above.
94
+ true
95
+ end
96
+ end
97
+
98
+ # NOTE: there is no `webhook :before_create` / `:after_create`. Parse Server has
99
+ # no such trigger — register beforeSave/afterSave and your create callbacks fire
100
+ # within them for new objects. Asking for a create webhook raises with guidance.
101
+
102
+ # ---------------------------------------------------------------------------
103
+ # 4. Register the webhooks with Parse Server (run once at deploy, needs master key)
104
+ # ---------------------------------------------------------------------------
105
+ # `endpoint` is the public HTTPS URL where THIS Rack app is reachable.
106
+ if ENV["PARSE_WEBHOOK_ENDPOINT"]
107
+ endpoint = ENV.fetch("PARSE_WEBHOOK_ENDPOINT") # e.g. https://hooks.example.com/webhooks
108
+ Parse::Webhooks.register_functions!(endpoint)
109
+ Parse::Webhooks.register_triggers!(endpoint)
110
+ puts "Registered webhooks at #{endpoint}"
111
+ end