parse-stack-next 5.1.1 → 5.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.env.sample +12 -0
- data/.env.test +4 -4
- data/CHANGELOG.md +545 -0
- data/Gemfile +3 -0
- data/Gemfile.lock +6 -1
- data/README.md +167 -38
- data/Rakefile +56 -10
- data/docs/atlas_vector_search_guide.md +110 -9
- data/docs/mcp_guide.md +433 -0
- data/docs/mongodb_direct_guide.md +66 -1
- data/docs/mongodb_index_optimization_guide.md +22 -1
- data/docs/usage_guide.md +15 -0
- data/lib/parse/agent/approval_gate.rb +0 -0
- data/lib/parse/agent/constraint_translator.rb +90 -19
- data/lib/parse/agent/describe.rb +1 -0
- data/lib/parse/agent/errors.rb +16 -0
- data/lib/parse/agent/mcp_client.rb +9 -0
- data/lib/parse/agent/mcp_dispatcher.rb +139 -7
- data/lib/parse/agent/mcp_rack_app.rb +621 -17
- data/lib/parse/agent/mcp_subscriptions.rb +607 -0
- data/lib/parse/agent/metadata_dsl.rb +58 -0
- data/lib/parse/agent/metadata_registry.rb +141 -1
- data/lib/parse/agent/prompt_hardening.rb +213 -0
- data/lib/parse/agent/result_formatter.rb +18 -3
- data/lib/parse/agent/tools.rb +167 -24
- data/lib/parse/agent.rb +692 -21
- data/lib/parse/client/request.rb +55 -4
- data/lib/parse/client/response.rb +4 -0
- data/lib/parse/client.rb +205 -7
- data/lib/parse/model/classes/installation.rb +27 -10
- data/lib/parse/model/classes/user.rb +8 -0
- data/lib/parse/model/core/actions.rb +58 -4
- data/lib/parse/model/core/embed_managed.rb +19 -14
- data/lib/parse/model/core/indexing.rb +108 -16
- data/lib/parse/model/core/querying.rb +29 -0
- data/lib/parse/model/model.rb +34 -3
- data/lib/parse/model/object.rb +1 -0
- data/lib/parse/query.rb +90 -24
- data/lib/parse/retrieval/agent_tool.rb +369 -0
- data/lib/parse/retrieval/chunk.rb +74 -0
- data/lib/parse/retrieval/chunker.rb +208 -0
- data/lib/parse/retrieval/retriever.rb +274 -0
- data/lib/parse/retrieval.rb +10 -0
- data/lib/parse/schema.rb +69 -20
- data/lib/parse/stack/version.rb +2 -2
- data/parse-stack-next.gemspec +1 -1
- data/scripts/docker/docker-compose.atlas.yml +14 -10
- data/scripts/docker/docker-compose.test.yml +24 -20
- data/scripts/docker/mongo-init.js +3 -3
- data/scripts/start-parse.sh +10 -0
- data/scripts/start_mcp_server.rb +1 -1
- data/scripts/test_server_connection.rb +1 -1
- data/scripts/vector_prototype/create_vector_index.js +1 -1
- data/scripts/vector_prototype/fetch_embeddings.py +2 -2
- data/scripts/vector_prototype/query_prototype.rb +1 -1
- data/scripts/vector_prototype/run.sh +4 -4
- metadata +10 -2
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
# encoding: UTF-8
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require_relative "chunker"
|
|
5
|
+
require_relative "chunk"
|
|
6
|
+
|
|
7
|
+
module Parse
|
|
8
|
+
# Retrieval-augmented-generation (RAG) helpers. `Parse::RAG` is a
|
|
9
|
+
# discoverability alias for this module.
|
|
10
|
+
#
|
|
11
|
+
# {.retrieve} is the agent-agnostic core: it embeds a natural-language
|
|
12
|
+
# query, runs Atlas `$vectorSearch` through the existing
|
|
13
|
+
# `Class.find_similar` (which enforces ACL/CLP mongo-direct), then
|
|
14
|
+
# splits each retrieved document's text field into scored
|
|
15
|
+
# {Parse::Retrieval::Chunk}s for presentation.
|
|
16
|
+
#
|
|
17
|
+
# The agent-facing `semantic_search` tool (see
|
|
18
|
+
# `lib/parse/retrieval/agent_tool.rb`) wraps {.retrieve} with the
|
|
19
|
+
# agent security envelope (tenant scope, `field_allowlist` projection,
|
|
20
|
+
# score quantization).
|
|
21
|
+
#
|
|
22
|
+
# == ACL model
|
|
23
|
+
#
|
|
24
|
+
# {.retrieve} does NOT implement a REST "two-stage" re-query. The
|
|
25
|
+
# vector path is mongo-direct only (Parse Server's REST `/aggregate`
|
|
26
|
+
# is master-key-only and bypasses ACL — see the project notes), and
|
|
27
|
+
# `acl_user:` / `acl_role:` scopes have no REST equivalent. ACL is
|
|
28
|
+
# enforced inside `find_similar` via a post-`$vectorSearch` `_rperm`
|
|
29
|
+
# `$match`. Scope kwargs (`session_token:` / `acl_user:` /
|
|
30
|
+
# `acl_role:` / `master:`) pass straight through `**scope_opts`.
|
|
31
|
+
module Retrieval
|
|
32
|
+
# Raised when a tenant-scope value conflicts with a caller-supplied
|
|
33
|
+
# `vector_filter` constraint on the same field — a scope-spoofing
|
|
34
|
+
# attempt. Mirrors the agent layer's tenant-scope refusal.
|
|
35
|
+
class TenantScopeConflict < ArgumentError; end
|
|
36
|
+
|
|
37
|
+
# Raised when the text field to chunk cannot be inferred from the
|
|
38
|
+
# class's `embed` declarations and was not passed explicitly.
|
|
39
|
+
class AmbiguousTextField < ArgumentError; end
|
|
40
|
+
|
|
41
|
+
module_function
|
|
42
|
+
|
|
43
|
+
# Recursively refuse any underscore-prefixed key, at any depth, in a
|
|
44
|
+
# caller-supplied filter. This is distinct from (and stricter than)
|
|
45
|
+
# the agent layer's flat `validate_keys!`: a Mongo-style filter is a
|
|
46
|
+
# nested structure, and an underscore key buried inside `$or` /
|
|
47
|
+
# `$elemMatch` / a hash value could clobber tenant scope or reach a
|
|
48
|
+
# reserved column (`_rperm`, `_p_*`, `_auth_data_*`). The walk is
|
|
49
|
+
# unconditional — it does not special-case operators.
|
|
50
|
+
#
|
|
51
|
+
# @param obj [Object] a filter Hash/Array (or anything; scalars pass).
|
|
52
|
+
# @param path [Array<String>] internal — accumulates the key path for
|
|
53
|
+
# the error message.
|
|
54
|
+
# @raise [ArgumentError] on any `_`-prefixed key.
|
|
55
|
+
def assert_no_underscore_keys!(obj, path = [])
|
|
56
|
+
case obj
|
|
57
|
+
when Hash
|
|
58
|
+
obj.each do |k, v|
|
|
59
|
+
ks = k.to_s
|
|
60
|
+
if ks.start_with?("_")
|
|
61
|
+
raise ArgumentError,
|
|
62
|
+
"filter key '#{(path + [ks]).join(".")}' is reserved (underscore-prefixed)."
|
|
63
|
+
end
|
|
64
|
+
assert_no_underscore_keys!(v, path + [ks])
|
|
65
|
+
end
|
|
66
|
+
when Array
|
|
67
|
+
obj.each_with_index { |v, i| assert_no_underscore_keys!(v, path + ["[#{i}]"]) }
|
|
68
|
+
end
|
|
69
|
+
obj
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Retrieve and chunk documents semantically similar to `query`.
|
|
73
|
+
#
|
|
74
|
+
# @param query [String] natural-language query.
|
|
75
|
+
# @param klass [Class, String] a Parse::Object subclass (or its
|
|
76
|
+
# class name) declaring a `:vector` property. `class:` is accepted
|
|
77
|
+
# as an alias.
|
|
78
|
+
# @param field [Symbol, nil] the `:vector` property to search.
|
|
79
|
+
# Auto-resolved by `find_similar` when the class has exactly one.
|
|
80
|
+
# @param text_field [Symbol, nil] the text property to chunk for
|
|
81
|
+
# presentation. Defaults to the sole text source of the class's
|
|
82
|
+
# `embed` declaration; raises {AmbiguousTextField} when it can't be
|
|
83
|
+
# inferred.
|
|
84
|
+
# @param k [Integer] number of documents to retrieve. Default 10.
|
|
85
|
+
# @param filter [Hash, nil] post-`$vectorSearch` `$match` filter.
|
|
86
|
+
# @param vector_filter [Hash, nil] Atlas-native pre-search filter.
|
|
87
|
+
# @param chunker [#chunk_with_meta, #chunk, nil] chunking strategy.
|
|
88
|
+
# Defaults to {Chunker::FixedSizeOverlap}.
|
|
89
|
+
# @param tenant_scope [Hash, nil] `{ field:, value: }` merged into
|
|
90
|
+
# `vector_filter` (closing the cross-tenant existence side
|
|
91
|
+
# channel) — not just a post-stage match.
|
|
92
|
+
# @param score_quantize [Boolean] round scores to 1 decimal (limits
|
|
93
|
+
# membership-inference probing in non-admin contexts).
|
|
94
|
+
# @param source_transform [#call, nil] optional callable applied to
|
|
95
|
+
# each raw source record before it is stored on a Chunk. The agent
|
|
96
|
+
# tool injects tenant-scope assertion + `field_allowlist`
|
|
97
|
+
# projection here; a `StandardError` raised by the callable
|
|
98
|
+
# propagates and aborts the whole call (fail-closed). Kept as an
|
|
99
|
+
# injection point so this model-layer method stays free of any
|
|
100
|
+
# agent-layer dependency.
|
|
101
|
+
# @param hybrid [Object, nil] reserved — raises {NotImplementedError}
|
|
102
|
+
# if truthy. Hybrid (vector + lexical) retrieval lands in a later
|
|
103
|
+
# release; the kwarg locks the API shape now.
|
|
104
|
+
# @param rerank [Object, nil] reserved — raises {NotImplementedError}
|
|
105
|
+
# if non-nil. Cross-encoder rerank lands in a later release.
|
|
106
|
+
# @param scope_opts [Hash] ACL/CLP scope kwargs forwarded verbatim to
|
|
107
|
+
# `find_similar`: `session_token:` / `acl_user:` / `acl_role:` /
|
|
108
|
+
# `master:`.
|
|
109
|
+
# @return [Array<Parse::Retrieval::Chunk>] descending by score; chunk
|
|
110
|
+
# order within a document is positional.
|
|
111
|
+
def retrieve(query:, klass: nil, field: nil, text_field: nil, k: 10,
|
|
112
|
+
filter: nil, vector_filter: nil, chunker: nil,
|
|
113
|
+
tenant_scope: nil, score_quantize: false,
|
|
114
|
+
source_transform: nil, hybrid: nil, rerank: nil,
|
|
115
|
+
**scope_opts)
|
|
116
|
+
raise NotImplementedError,
|
|
117
|
+
"Parse::Retrieval.retrieve: `hybrid:` is reserved for a future release." if hybrid
|
|
118
|
+
raise NotImplementedError,
|
|
119
|
+
"Parse::Retrieval.retrieve: `rerank:` is reserved for a future release." if rerank
|
|
120
|
+
|
|
121
|
+
# `class:` alias (reserved word — arrives via **scope_opts).
|
|
122
|
+
klass ||= scope_opts.delete(:class)
|
|
123
|
+
klass = resolve_class!(klass)
|
|
124
|
+
|
|
125
|
+
unless query.is_a?(String) && !query.strip.empty?
|
|
126
|
+
raise ArgumentError, "Parse::Retrieval.retrieve: `query:` must be a non-empty String."
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
resolved_text_field = (text_field || infer_text_field!(klass)).to_sym
|
|
130
|
+
merged_vector_filter = fold_tenant_scope(klass, vector_filter, tenant_scope)
|
|
131
|
+
chunker ||= default_chunker
|
|
132
|
+
|
|
133
|
+
raw_hits = klass.find_similar(
|
|
134
|
+
text: query,
|
|
135
|
+
k: k,
|
|
136
|
+
field: field,
|
|
137
|
+
filter: filter,
|
|
138
|
+
vector_filter: merged_vector_filter,
|
|
139
|
+
raw: true,
|
|
140
|
+
**scope_opts,
|
|
141
|
+
)
|
|
142
|
+
return [] if raw_hits.nil? || raw_hits.empty?
|
|
143
|
+
|
|
144
|
+
text_wire = wire_name(klass, resolved_text_field)
|
|
145
|
+
|
|
146
|
+
raw_hits.flat_map do |doc|
|
|
147
|
+
build_chunks_for(doc, klass, text_wire, score_quantize, source_transform, chunker)
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# @!visibility private
|
|
152
|
+
def resolve_class!(klass)
|
|
153
|
+
resolved =
|
|
154
|
+
case klass
|
|
155
|
+
when nil
|
|
156
|
+
nil
|
|
157
|
+
when Class
|
|
158
|
+
klass
|
|
159
|
+
else
|
|
160
|
+
Parse::Model.find_class(klass.to_s)
|
|
161
|
+
end
|
|
162
|
+
unless resolved.is_a?(Class) && resolved.respond_to?(:find_similar)
|
|
163
|
+
raise ArgumentError,
|
|
164
|
+
"Parse::Retrieval.retrieve: `klass:`/`class:` must be a Parse::Object " \
|
|
165
|
+
"subclass with a :vector property (got #{klass.inspect})."
|
|
166
|
+
end
|
|
167
|
+
resolved
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
# @!visibility private
|
|
171
|
+
# Infer the text field to chunk from the class's `embed` directives:
|
|
172
|
+
# the sole text (non-image) source field. Raises when zero or more
|
|
173
|
+
# than one candidate exists — the caller must then pass `text_field:`.
|
|
174
|
+
def infer_text_field!(klass)
|
|
175
|
+
directives = klass.respond_to?(:embed_directives) ? klass.embed_directives.values : []
|
|
176
|
+
sources = directives.reject { |d| d.respond_to?(:image?) && d.image? }
|
|
177
|
+
.flat_map(&:sources).uniq
|
|
178
|
+
return sources.first if sources.length == 1
|
|
179
|
+
raise AmbiguousTextField,
|
|
180
|
+
"Parse::Retrieval.retrieve: cannot infer the text field to chunk for " \
|
|
181
|
+
"#{klass} (candidates: #{sources.inspect}); pass `text_field:` explicitly."
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
# @!visibility private
|
|
185
|
+
def default_chunker
|
|
186
|
+
Chunker::FixedSizeOverlap.new(size: 800, overlap: 100)
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
# @!visibility private
|
|
190
|
+
# Merge the tenant scope into the Atlas pre-search filter using the
|
|
191
|
+
# field's wire/storage column name. A pre-existing constraint on the
|
|
192
|
+
# same field with a different value is a spoof attempt and is refused.
|
|
193
|
+
def fold_tenant_scope(klass, vector_filter, tenant_scope)
|
|
194
|
+
return vector_filter if tenant_scope.nil?
|
|
195
|
+
field = tenant_scope[:field] || tenant_scope["field"]
|
|
196
|
+
value = tenant_scope.key?(:value) ? tenant_scope[:value] : tenant_scope["value"]
|
|
197
|
+
return vector_filter if field.nil?
|
|
198
|
+
|
|
199
|
+
wire = wire_name(klass, field)
|
|
200
|
+
base = vector_filter ? vector_filter.dup : {}
|
|
201
|
+
existing_key = base.keys.find { |k| k.to_s == wire }
|
|
202
|
+
if existing_key && base[existing_key] != value
|
|
203
|
+
raise TenantScopeConflict,
|
|
204
|
+
"Parse::Retrieval.retrieve: vector_filter pins #{wire.inspect} to " \
|
|
205
|
+
"#{base[existing_key].inspect} but the tenant scope requires #{value.inspect}."
|
|
206
|
+
end
|
|
207
|
+
base[wire] = value
|
|
208
|
+
base
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
# @!visibility private
|
|
212
|
+
# Ruby property symbol -> wire/storage column name. Prefers the
|
|
213
|
+
# class's explicit field_map alias; falls back to lowerCamelCase
|
|
214
|
+
# columnization. Matches the resolution MetadataRegistry uses.
|
|
215
|
+
def wire_name(klass, field)
|
|
216
|
+
sym = field.to_sym
|
|
217
|
+
fmap = klass.respond_to?(:field_map) ? klass.field_map : {}
|
|
218
|
+
mapped = fmap[sym]
|
|
219
|
+
(mapped || sym.to_s.columnize).to_s
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
# @!visibility private
|
|
223
|
+
def fetch_field(doc, wire, sym)
|
|
224
|
+
return doc[wire] if doc.key?(wire)
|
|
225
|
+
return doc[wire.to_sym] if doc.key?(wire.to_sym)
|
|
226
|
+
return doc[sym.to_s] if doc.key?(sym.to_s)
|
|
227
|
+
doc[sym]
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
# @!visibility private
|
|
231
|
+
def build_chunks_for(doc, klass, text_wire, score_quantize, source_transform, chunker)
|
|
232
|
+
object_id = (doc["_id"] || doc[:_id] || doc["objectId"] || doc[:objectId]).to_s
|
|
233
|
+
raw_score = doc["_vscore"] || doc[:_vscore]
|
|
234
|
+
score = quantize_score(raw_score, score_quantize)
|
|
235
|
+
|
|
236
|
+
text = fetch_field(doc, text_wire, text_wire)
|
|
237
|
+
meta = chunker.respond_to?(:chunk_with_meta) ? chunker.chunk_with_meta(text) : nil
|
|
238
|
+
chunks = meta ? meta[:chunks] : Array(chunker.chunk(text))
|
|
239
|
+
truncated = meta ? meta[:truncated] : false
|
|
240
|
+
# A document that matched on its vector but carries no presentation
|
|
241
|
+
# text yields no chunks (skipped, not an empty-content chunk).
|
|
242
|
+
return [] if chunks.empty?
|
|
243
|
+
|
|
244
|
+
source = source_transform ? source_transform.call(doc) : doc
|
|
245
|
+
count = chunks.length
|
|
246
|
+
chunks.each_with_index.map do |content, idx|
|
|
247
|
+
Chunk.new(
|
|
248
|
+
id: "#{object_id}##{idx}",
|
|
249
|
+
content: content,
|
|
250
|
+
score: score,
|
|
251
|
+
source: source,
|
|
252
|
+
metadata: {
|
|
253
|
+
chunk_index: idx,
|
|
254
|
+
chunk_count: count,
|
|
255
|
+
chunks_truncated: truncated,
|
|
256
|
+
object_id: object_id,
|
|
257
|
+
class: klass.parse_class,
|
|
258
|
+
},
|
|
259
|
+
)
|
|
260
|
+
end
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
# @!visibility private
|
|
264
|
+
def quantize_score(score, quantize)
|
|
265
|
+
return score if score.nil?
|
|
266
|
+
f = score.to_f
|
|
267
|
+
quantize ? ((f * 10).round / 10.0) : f
|
|
268
|
+
end
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
# Discoverability alias. "RAG" ages badly as a term; `Retrieval` is
|
|
272
|
+
# the canonical name.
|
|
273
|
+
RAG = Retrieval
|
|
274
|
+
end
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# encoding: UTF-8
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
# Parse::Retrieval — retrieval-augmented-generation (RAG) helpers.
|
|
5
|
+
#
|
|
6
|
+
# Entry point that loads the chunker, the {Parse::Retrieval::Chunk}
|
|
7
|
+
# value object, and the {Parse::Retrieval.retrieve} core. The
|
|
8
|
+
# `semantic_search` agent tool (which depends on the agent layer) is
|
|
9
|
+
# loaded separately from `lib/parse/agent.rb`.
|
|
10
|
+
require_relative "retrieval/retriever"
|
data/lib/parse/schema.rb
CHANGED
|
@@ -231,16 +231,23 @@ module Parse
|
|
|
231
231
|
end
|
|
232
232
|
|
|
233
233
|
# Fields defined locally but missing on server.
|
|
234
|
-
#
|
|
234
|
+
#
|
|
235
|
+
# Iterates the model's `field_map` (one entry per canonical property,
|
|
236
|
+
# canonical name => wire column name) rather than `fields` (which carries
|
|
237
|
+
# both the snake_case and camelCase keys for every property and therefore
|
|
238
|
+
# double-counts multi-word fields). The wire name resolved from
|
|
239
|
+
# `field_map` is the authoritative server column — including custom
|
|
240
|
+
# `field:` mappings — so this both dedupes and fixes custom-column
|
|
241
|
+
# detection. Result is keyed by the CANONICAL (snake) name with the type
|
|
242
|
+
# taken from `fields[name]`.
|
|
243
|
+
# @return [Hash] canonical field name => type pairs
|
|
235
244
|
def missing_on_server
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
local = local_fields
|
|
239
|
-
server = server_field_names
|
|
245
|
+
server = server_exists? ? server_field_names : []
|
|
240
246
|
missing = {}
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
247
|
+
@model_class.field_map.each do |name, wire|
|
|
248
|
+
next if core_field?(name)
|
|
249
|
+
next if server.include?(wire.to_s)
|
|
250
|
+
missing[name] = @model_class.fields[name]
|
|
244
251
|
end
|
|
245
252
|
missing
|
|
246
253
|
end
|
|
@@ -262,15 +269,25 @@ module Parse
|
|
|
262
269
|
end
|
|
263
270
|
|
|
264
271
|
# Fields with type mismatches.
|
|
265
|
-
#
|
|
272
|
+
#
|
|
273
|
+
# Iterates `field_map` (canonical name => wire column) rather than
|
|
274
|
+
# deriving the server key with `camelize(:lower)`, so a property with a
|
|
275
|
+
# custom `field:` wire column (e.g. `property :post_id, field:
|
|
276
|
+
# "postIdentifier"`) resolves to its real server column instead of a
|
|
277
|
+
# camelized guess. This both dedupes multi-word fields (which appear
|
|
278
|
+
# under two keys in `fields`) and matches the `missing_on_server`
|
|
279
|
+
# resolution path, so type drift on custom-mapped columns is no longer
|
|
280
|
+
# silently skipped.
|
|
281
|
+
# @return [Hash] canonical field name => { local: type, server: type }
|
|
266
282
|
def type_mismatches
|
|
267
283
|
return {} unless server_exists?
|
|
268
284
|
|
|
269
285
|
mismatches = {}
|
|
270
|
-
|
|
286
|
+
@model_class.field_map.each do |name, wire|
|
|
271
287
|
next if core_field?(name)
|
|
272
|
-
|
|
273
|
-
|
|
288
|
+
local_type = @model_class.fields[name]
|
|
289
|
+
next if local_type.nil?
|
|
290
|
+
server_type = @server_schema.field_type(wire.to_s)
|
|
274
291
|
next unless server_type
|
|
275
292
|
|
|
276
293
|
# Normalize types for comparison
|
|
@@ -285,11 +302,30 @@ module Parse
|
|
|
285
302
|
end
|
|
286
303
|
|
|
287
304
|
# Check if schemas are in sync.
|
|
305
|
+
#
|
|
306
|
+
# Strict / bidirectional: requires the local and server schemas to match
|
|
307
|
+
# in BOTH directions — no fields missing on the server, no fields missing
|
|
308
|
+
# locally, and no type mismatches. A server that is a strict superset of
|
|
309
|
+
# the local model is NOT "in sync" by this measure (use
|
|
310
|
+
# {#server_covers_local?} for the one-way local ⊆ server check).
|
|
288
311
|
# @return [Boolean]
|
|
289
312
|
def in_sync?
|
|
290
313
|
missing_on_server.empty? && missing_locally.empty? && type_mismatches.empty?
|
|
291
314
|
end
|
|
292
315
|
|
|
316
|
+
# Check whether the server schema covers every locally-defined field.
|
|
317
|
+
#
|
|
318
|
+
# One-way (local ⊆ server): true when nothing the model declares is
|
|
319
|
+
# missing on the server and there are no type mismatches. Unlike
|
|
320
|
+
# {#in_sync?}, this ignores server-only columns, so a server that is a
|
|
321
|
+
# strict superset of the local model still satisfies it. This is the
|
|
322
|
+
# predicate that determines whether a migration has any work to do —
|
|
323
|
+
# extra server columns are not something the migrator would add.
|
|
324
|
+
# @return [Boolean]
|
|
325
|
+
def server_covers_local?
|
|
326
|
+
missing_on_server.empty? && type_mismatches.empty?
|
|
327
|
+
end
|
|
328
|
+
|
|
293
329
|
# Generate a human-readable summary.
|
|
294
330
|
# @return [String]
|
|
295
331
|
def summary
|
|
@@ -355,9 +391,17 @@ module Parse
|
|
|
355
391
|
end
|
|
356
392
|
|
|
357
393
|
# Check if migration is needed.
|
|
394
|
+
#
|
|
395
|
+
# A migration is needed when the class does not yet exist on the server,
|
|
396
|
+
# or when the server does not already cover every locally-defined field.
|
|
397
|
+
# Defined in terms of the one-way {SchemaDiff#server_covers_local?} rather
|
|
398
|
+
# than the strict bidirectional {SchemaDiff#in_sync?} so that a server
|
|
399
|
+
# which is a strict superset of the local model (extra server-only
|
|
400
|
+
# columns the migrator would never add) does not report a "needed"
|
|
401
|
+
# migration with zero operations.
|
|
358
402
|
# @return [Boolean]
|
|
359
403
|
def needed?
|
|
360
|
-
!@diff.
|
|
404
|
+
!@diff.server_exists? || !@diff.server_covers_local?
|
|
361
405
|
end
|
|
362
406
|
|
|
363
407
|
# Get the operations that would be performed.
|
|
@@ -372,7 +416,7 @@ module Parse
|
|
|
372
416
|
@diff.missing_on_server.each do |name, type|
|
|
373
417
|
ops << {
|
|
374
418
|
action: :add_field,
|
|
375
|
-
field: name.to_s
|
|
419
|
+
field: @model_class.field_map[name].to_s,
|
|
376
420
|
type: REVERSE_TYPE_MAP[type] || "String",
|
|
377
421
|
}
|
|
378
422
|
end
|
|
@@ -424,7 +468,7 @@ module Parse
|
|
|
424
468
|
|
|
425
469
|
# Add missing fields
|
|
426
470
|
@diff.missing_on_server.each do |name, type|
|
|
427
|
-
field_name = name.to_s
|
|
471
|
+
field_name = @model_class.field_map[name].to_s
|
|
428
472
|
field_schema = { "fields" => { field_name => field_definition(type) } }
|
|
429
473
|
|
|
430
474
|
response = @client.update_schema(@model_class.parse_class, field_schema)
|
|
@@ -444,15 +488,20 @@ module Parse
|
|
|
444
488
|
|
|
445
489
|
def build_schema
|
|
446
490
|
fields = {}
|
|
447
|
-
|
|
491
|
+
# Iterate `field_map` (canonical name => wire column) rather than
|
|
492
|
+
# `fields`, which carries both the snake_case and camelCase keys for
|
|
493
|
+
# every property and would emit a duplicate/phantom column for each
|
|
494
|
+
# multi-word or custom-`field:` property.
|
|
495
|
+
@model_class.field_map.each do |name, wire|
|
|
448
496
|
next if %i[id object_id created_at updated_at acl objectId createdAt updatedAt ACL].include?(name)
|
|
449
|
-
|
|
450
|
-
fields[field_name] = field_definition(type)
|
|
497
|
+
fields[wire.to_s] = field_definition(@model_class.fields[name])
|
|
451
498
|
end
|
|
452
499
|
|
|
453
|
-
# Add pointer targets
|
|
500
|
+
# Add pointer targets. `references` is keyed by the wire column name
|
|
501
|
+
# (the `parse_field`), so use it as-is — do not re-camelize, which
|
|
502
|
+
# would corrupt custom `field:` pointer columns.
|
|
454
503
|
@model_class.references.each do |name, target_class|
|
|
455
|
-
field_name = name.to_s
|
|
504
|
+
field_name = name.to_s
|
|
456
505
|
fields[field_name] = {
|
|
457
506
|
"type" => "Pointer",
|
|
458
507
|
"targetClass" => target_class.to_s,
|
data/lib/parse/stack/version.rb
CHANGED
|
@@ -2,10 +2,10 @@
|
|
|
2
2
|
# frozen_string_literal: true
|
|
3
3
|
|
|
4
4
|
module Parse
|
|
5
|
-
# @author Anthony Persaud, Henry Spindell
|
|
5
|
+
# @author Adrian Curtin, Anthony Persaud, Henry Spindell
|
|
6
6
|
# The Parse Server SDK for Ruby
|
|
7
7
|
module Stack
|
|
8
8
|
# The current version.
|
|
9
|
-
VERSION = "5.
|
|
9
|
+
VERSION = "5.2.0"
|
|
10
10
|
end
|
|
11
11
|
end
|
data/parse-stack-next.gemspec
CHANGED
|
@@ -6,7 +6,7 @@ require "parse/stack/version"
|
|
|
6
6
|
Gem::Specification.new do |spec|
|
|
7
7
|
spec.name = "parse-stack-next"
|
|
8
8
|
spec.version = Parse::Stack::VERSION
|
|
9
|
-
spec.authors =
|
|
9
|
+
spec.authors = ["Adrian Curtin", "Anthony Persaud", "Henry Spindell"]
|
|
10
10
|
spec.email = ["adrian+parse-stack@neurosynq.net"]
|
|
11
11
|
|
|
12
12
|
spec.summary = %q{Parse Server SDK for Ruby — ORM, queries, auth, and MongoDB-direct access}
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
# Reset: docker-compose -f scripts/docker/docker-compose.atlas.yml down -v && docker-compose -f scripts/docker/docker-compose.atlas.yml up -d
|
|
9
9
|
#
|
|
10
10
|
# Run tests:
|
|
11
|
-
# ATLAS_URI="mongodb://localhost:
|
|
11
|
+
# ATLAS_URI="mongodb://localhost:29020/parse_atlas_test?directConnection=true" ruby -Ilib:test test/lib/parse/atlas_search_integration_test.rb
|
|
12
12
|
#
|
|
13
13
|
# Stability notes (see commit history for context):
|
|
14
14
|
# The mongodb-atlas-local image runs a supervisor ("runner") that intentionally
|
|
@@ -19,13 +19,17 @@
|
|
|
19
19
|
# which checks both mongod AND mongot. We rely on that built-in healthcheck
|
|
20
20
|
# rather than overriding it with a faster mongosh probe.
|
|
21
21
|
|
|
22
|
+
# Distinct Compose project name — kept separate from the main test stack
|
|
23
|
+
# (psnext-it) so `down` on one never tears down the other.
|
|
24
|
+
name: ${PSNEXT_PREFIX:-psnext-it}-atlas
|
|
25
|
+
|
|
22
26
|
services:
|
|
23
27
|
atlas-local:
|
|
24
28
|
# Pinned patch tag rather than the floating :8.0 — mongodb-atlas-local has
|
|
25
29
|
# known per-patch start-up flakiness (see testcontainers/testcontainers-java#10267
|
|
26
30
|
# and MongoDB community forum 321179). Bump deliberately, not by drift.
|
|
27
31
|
image: mongodb/mongodb-atlas-local:8.0.10
|
|
28
|
-
container_name:
|
|
32
|
+
container_name: ${PSNEXT_PREFIX:-psnext-it}-atlas
|
|
29
33
|
# `hostname` is called out by the official compose example — the embedded
|
|
30
34
|
# single-node replica set advertises this name, and a stable value makes
|
|
31
35
|
# restart-after-down work cleanly.
|
|
@@ -37,15 +41,15 @@ services:
|
|
|
37
41
|
MONGOT_LOG_FILE: /dev/stderr
|
|
38
42
|
# Loopback-only by default — Atlas Local has no auth configured.
|
|
39
43
|
ports:
|
|
40
|
-
- "${ATLAS_BIND:-127.0.0.1}:
|
|
44
|
+
- "${ATLAS_BIND:-127.0.0.1}:${ATLAS_HOST_PORT:-29020}:27017"
|
|
41
45
|
volumes:
|
|
42
46
|
# Persist mongod data, replset config, and — critically — the mongot
|
|
43
47
|
# Lucene index directory. Without /data/mongot persistence, every restart
|
|
44
48
|
# forces a full search re-index, which lengthens "ready" time and is a
|
|
45
49
|
# real source of test flakiness. Matches the official compose example.
|
|
46
|
-
- atlas-data:/data/db
|
|
47
|
-
- atlas-configdb:/data/configdb
|
|
48
|
-
- atlas-mongot:/data/mongot
|
|
50
|
+
- psnext-it-atlas-data:/data/db
|
|
51
|
+
- psnext-it-atlas-configdb:/data/configdb
|
|
52
|
+
- psnext-it-atlas-mongot:/data/mongot
|
|
49
53
|
# Recover from genuine crashes automatically. Test runs `docker compose down`
|
|
50
54
|
# explicitly, so this does not interfere with teardown.
|
|
51
55
|
restart: unless-stopped
|
|
@@ -59,7 +63,7 @@ services:
|
|
|
59
63
|
|
|
60
64
|
atlas-init:
|
|
61
65
|
image: mongodb/mongodb-atlas-local:8.0.10
|
|
62
|
-
container_name:
|
|
66
|
+
container_name: ${PSNEXT_PREFIX:-psnext-it}-atlas-init
|
|
63
67
|
depends_on:
|
|
64
68
|
atlas-local:
|
|
65
69
|
# Uses the image's built-in healthcheck (see above), so this now waits
|
|
@@ -71,6 +75,6 @@ services:
|
|
|
71
75
|
restart: "no"
|
|
72
76
|
|
|
73
77
|
volumes:
|
|
74
|
-
atlas-data:
|
|
75
|
-
atlas-configdb:
|
|
76
|
-
atlas-mongot:
|
|
78
|
+
psnext-it-atlas-data:
|
|
79
|
+
psnext-it-atlas-configdb:
|
|
80
|
+
psnext-it-atlas-mongot:
|
|
@@ -1,31 +1,35 @@
|
|
|
1
1
|
version: '3.8'
|
|
2
2
|
|
|
3
|
+
# Distinct Compose project name so this stack never shares containers,
|
|
4
|
+
# networks, or volumes with another Parse test system on the same host.
|
|
5
|
+
name: ${PSNEXT_PREFIX:-psnext-it}
|
|
6
|
+
|
|
3
7
|
services:
|
|
4
8
|
mongo:
|
|
5
9
|
image: mongo:8
|
|
6
|
-
container_name:
|
|
10
|
+
container_name: ${PSNEXT_PREFIX:-psnext-it}-mongo
|
|
7
11
|
# Bind to loopback so the test database isn't reachable from the LAN
|
|
8
12
|
# when a developer runs `docker-compose up`. Override with
|
|
9
13
|
# `MONGO_BIND=0.0.0.0` if you really want it exposed.
|
|
10
14
|
ports:
|
|
11
|
-
- "${MONGO_BIND:-127.0.0.1}:
|
|
15
|
+
- "${MONGO_BIND:-127.0.0.1}:${MONGO_HOST_PORT:-29017}:27017"
|
|
12
16
|
environment:
|
|
13
17
|
MONGO_INITDB_ROOT_USERNAME: ${MONGO_ROOT_USER:-admin}
|
|
14
18
|
MONGO_INITDB_ROOT_PASSWORD: ${MONGO_ROOT_PASSWORD:-password}
|
|
15
19
|
MONGO_INITDB_DATABASE: admin
|
|
16
20
|
volumes:
|
|
17
|
-
- mongo-data:/data/db
|
|
21
|
+
- psnext-it-mongo-data:/data/db
|
|
18
22
|
- ./mongo-init.js:/docker-entrypoint-initdb.d/mongo-init.js:ro
|
|
19
23
|
|
|
20
24
|
parse:
|
|
21
25
|
build:
|
|
22
26
|
context: ..
|
|
23
27
|
dockerfile: docker/Dockerfile.parse
|
|
24
|
-
container_name:
|
|
28
|
+
container_name: ${PSNEXT_PREFIX:-psnext-it}-server
|
|
25
29
|
# Bind to loopback by default — Parse Server with the test master key
|
|
26
30
|
# has no business being reachable on a developer's LAN.
|
|
27
31
|
ports:
|
|
28
|
-
- "${PARSE_BIND:-127.0.0.1}:
|
|
32
|
+
- "${PARSE_BIND:-127.0.0.1}:${PARSE_HOST_PORT:-29337}:1337"
|
|
29
33
|
# `host.docker.internal` is needed so Parse Server can POST to the
|
|
30
34
|
# in-process WEBrick that backs webhook integration tests. Docker
|
|
31
35
|
# Desktop on Mac/Windows resolves this natively; on Linux we map it
|
|
@@ -45,10 +49,10 @@ services:
|
|
|
45
49
|
# `start-parse.sh` will abort if any of these is unset, so any
|
|
46
50
|
# name drift here surfaces immediately rather than booting Parse
|
|
47
51
|
# Server with whatever placeholder default the SDK README documents.
|
|
48
|
-
PARSE_SERVER_APPLICATION_ID: ${PARSE_APP_ID:-
|
|
49
|
-
PARSE_SERVER_MASTER_KEY: ${PARSE_MASTER_KEY:-
|
|
50
|
-
PARSE_SERVER_REST_API_KEY: ${PARSE_API_KEY:-
|
|
51
|
-
PARSE_SERVER_DATABASE_URI: mongodb://${MONGO_ROOT_USER:-admin}:${MONGO_ROOT_PASSWORD:-password}@mongo:27017/
|
|
52
|
+
PARSE_SERVER_APPLICATION_ID: ${PARSE_APP_ID:-psnextItAppId}
|
|
53
|
+
PARSE_SERVER_MASTER_KEY: ${PARSE_MASTER_KEY:-psnextItMasterKey}
|
|
54
|
+
PARSE_SERVER_REST_API_KEY: ${PARSE_API_KEY:-psnext-it-rest-key}
|
|
55
|
+
PARSE_SERVER_DATABASE_URI: mongodb://${MONGO_ROOT_USER:-admin}:${MONGO_ROOT_PASSWORD:-password}@mongo:27017/parse_stack_next_it?authSource=admin
|
|
52
56
|
# Accept client-supplied objectId on create. Required by the
|
|
53
57
|
# `parse_reference precompute: true` DSL. The SDK only forwards
|
|
54
58
|
# client objectIds under master-key authority; for non-master
|
|
@@ -78,36 +82,36 @@ services:
|
|
|
78
82
|
|
|
79
83
|
redis:
|
|
80
84
|
image: redis:7-alpine
|
|
81
|
-
container_name:
|
|
85
|
+
container_name: ${PSNEXT_PREFIX:-psnext-it}-redis
|
|
82
86
|
# Loopback-only by default. Used by the cache integration test
|
|
83
87
|
# (cache_redis_integration_test.rb) and the synchronize-create lock
|
|
84
88
|
# tests. Override with `REDIS_BIND=0.0.0.0` if you need to point a
|
|
85
89
|
# remote client at it during debugging.
|
|
86
90
|
ports:
|
|
87
|
-
- "${REDIS_BIND:-127.0.0.1}:
|
|
91
|
+
- "${REDIS_BIND:-127.0.0.1}:${REDIS_HOST_PORT:-29379}:6379"
|
|
88
92
|
command: ["redis-server", "--save", "", "--appendonly", "no"]
|
|
89
93
|
|
|
90
94
|
parse-dashboard:
|
|
91
95
|
image: parseplatform/parse-dashboard:9
|
|
92
|
-
container_name:
|
|
96
|
+
container_name: ${PSNEXT_PREFIX:-psnext-it}-dashboard
|
|
93
97
|
# Loopback-only by default. This compose is for `rake test:integration`
|
|
94
98
|
# and local debugging; do not expose to the network.
|
|
95
99
|
ports:
|
|
96
|
-
- "${DASHBOARD_BIND:-127.0.0.1}:4040
|
|
100
|
+
- "${DASHBOARD_BIND:-127.0.0.1}:${DASHBOARD_HOST_PORT:-29040}:4040"
|
|
97
101
|
environment:
|
|
98
102
|
# `allowInsecureHTTP` and `useEncryptedPasswords: false` are kept
|
|
99
103
|
# because this stack runs over plain HTTP on loopback. Do NOT copy
|
|
100
104
|
# these settings to a deployed environment.
|
|
101
105
|
PARSE_DASHBOARD_ALLOW_INSECURE_HTTP: "1"
|
|
102
|
-
PARSE_SERVER_MASTER_KEY: ${PARSE_MASTER_KEY:-
|
|
103
|
-
PARSE_SERVER_APPLICATION_ID: ${PARSE_APP_ID:-
|
|
104
|
-
PARSE_SERVER_URL: http://localhost
|
|
106
|
+
PARSE_SERVER_MASTER_KEY: ${PARSE_MASTER_KEY:-psnextItMasterKey}
|
|
107
|
+
PARSE_SERVER_APPLICATION_ID: ${PARSE_APP_ID:-psnextItAppId}
|
|
108
|
+
PARSE_SERVER_URL: http://localhost:${PARSE_HOST_PORT:-29337}/parse
|
|
105
109
|
PARSE_DASHBOARD_CONFIG: |
|
|
106
110
|
{
|
|
107
111
|
"apps": [{
|
|
108
|
-
"serverURL": "http://localhost
|
|
109
|
-
"appId": "${PARSE_APP_ID:-
|
|
110
|
-
"masterKey": "${PARSE_MASTER_KEY:-
|
|
112
|
+
"serverURL": "http://localhost:${PARSE_HOST_PORT:-29337}/parse",
|
|
113
|
+
"appId": "${PARSE_APP_ID:-psnextItAppId}",
|
|
114
|
+
"masterKey": "${PARSE_MASTER_KEY:-psnextItMasterKey}",
|
|
111
115
|
"appName": "ParseStackTest"
|
|
112
116
|
}],
|
|
113
117
|
"users": [{
|
|
@@ -121,4 +125,4 @@ services:
|
|
|
121
125
|
- parse
|
|
122
126
|
|
|
123
127
|
volumes:
|
|
124
|
-
mongo-data:
|
|
128
|
+
psnext-it-mongo-data:
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// MongoDB initialization script
|
|
2
2
|
// This script runs when the MongoDB container is first created
|
|
3
|
-
// It grants the admin user access to the
|
|
3
|
+
// It grants the admin user access to the parse_stack_next_it database for direct queries
|
|
4
4
|
|
|
5
5
|
// The admin user needs readWriteAnyDatabase role to access all databases
|
|
6
6
|
db = db.getSiblingDB('admin');
|
|
@@ -11,8 +11,8 @@ db.grantRolesToUser('admin', [
|
|
|
11
11
|
{ role: 'dbAdminAnyDatabase', db: 'admin' }
|
|
12
12
|
]);
|
|
13
13
|
|
|
14
|
-
// Initialize the
|
|
15
|
-
db = db.getSiblingDB('
|
|
14
|
+
// Initialize the parse_stack_next_it database
|
|
15
|
+
db = db.getSiblingDB('parse_stack_next_it');
|
|
16
16
|
|
|
17
17
|
// Create a placeholder collection to ensure the database exists
|
|
18
18
|
db.createCollection('_init');
|