• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

thanos / terminusdb-client-elixir / 4f23525f7f740037ae6700b3cc5d797042bf07ab-PR-58

26 Jun 2026 06:45PM UTC coverage: 92.841% (-1.7%) from 94.588%
4f23525f7f740037ae6700b3cc5d797042bf07ab-PR-58

Pull #58

github

thanos
1. True divergence — Added a commit on main after creating the feature branch (inserting "from-main"), so both branches have diverged from the common ancestor. This avoids the fast-forward edge case that triggers the 500.
2. Retry on 500 — The rebase endpoint in TerminusDB 12.0.6 intermittently returns 500 ("Unexpected failure in request handler"). The test now retries up to 3 times on 500 errors, passing through other errors immediately.
3. Stronger assertions — Verifies both "from-feature" and "from-main" are present after merge, confirming the rebase correctly applied both branches' commits.
Pull Request #58: v0.3.2: GraphQL, temporal/Allen, RDF list, CSV/IO, range queries, API…

482 of 535 new or added lines in 13 files covered. (90.09%)

2 existing lines in 1 file now uncovered.

1206 of 1299 relevant lines covered (92.84%)

59.12 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

95.38
/lib/terminus_db/diff.ex
1
defmodule TerminusDB.Diff do
2
  import Kernel, except: [apply: 2]
3

4
  @moduledoc """
5
  Document diff and patch API for TerminusDB.
6

7
  Wraps the `/api/diff`, `/api/patch`, and `/api/apply` endpoints to compare,
8
  patch, and apply document changes.
9

10
  Diffs can be computed between:
11
  - Two document values (`before` and `after` maps).
12
  - Branch vs branch, commit vs commit, or branch vs commit (by supplying the
13
    appropriate resource refs in the `before`/`after` fields).
14

15
  ## Quick start
16

17
      config =
18
        TerminusDB.Config.new(endpoint: "http://localhost:6363")
19
        |> TerminusDB.Config.with_database("mydb")
20

21
      # Diff two document values
22
      {:ok, patch} = TerminusDB.Diff.diff_object(config,
23
        before: %{"@id" => "Person/Alice", "name" => "Alice"},
24
        after: %{"@id" => "Person/Alice", "name" => "Alicia"}
25
      )
26

27
      # Apply a patch to a branch
28
      {:ok, _} = TerminusDB.Diff.patch_resource(config,
29
        patch: patch, message: "update name", author: "admin"
30
      )
31

32
  """
33

34
  alias TerminusDB.{Client, Config, Error, Patch}
35
  alias TerminusDB.Client.Params
36

37
  @type compare_opt ::
38
          {:before, map()}
39
          | {:after, map()}
40
          | {:keep, map()}
41
          | {:organization, String.t()}
42

43
  defp diff_path(config, opts) do
44
    org = opts[:organization] || config.organization
24✔
45
    db = config.database || raise Error, reason: :http, message: "no database scoped in config"
24✔
46
    "diff/#{org}/#{db}"
21✔
47
  end
48

49
  @doc """
50
  Compares two document states and returns a structured diff patch.
51

52
  The `before` and `after` values can be:
53
  - Document maps (with `@id` and fields) for a value-level diff.
54
  - Resource references (e.g. `"admin/mydb/local/branch/main"`) for a
55
    branch/commit-level diff.
56

57
  ## Options
58

59
  - `:before` (required) - the "before" document or resource ref.
60
  - `:after` (required) - the "after" document or resource ref.
61
  - `:keep` - a map of fields to preserve in the diff (e.g. `%{"@id" => true}`).
62
  - `:organization` - overrides `config.organization`.
63

64
  ## Examples
65

66
  Diff two document values:
67

68
      iex> config = TerminusDB.Config.new(
69
      ...>   endpoint: "http://localhost:6363",
70
      ...>   adapter: fn req ->
71
      ...>     {req, Req.Response.new(status: 200, body: %{"name" => %{"@op" => "ValueSwap", "@before" => "Alice", "@after" => "Alicia"}})}
72
      ...>   end
73
      ...> ) |> TerminusDB.Config.with_database("mydb")
74
      iex> {:ok, patch} = TerminusDB.Diff.compare(config,
75
      ...>   before: %{"@id" => "Person/Alice", "name" => "Alice"},
76
      ...>   after: %{"@id" => "Person/Alice", "name" => "Alicia"}
77
      ...> )
78
      iex> patch["name"]["@op"]
79
      "ValueSwap"
80

81
  Diff two branches:
82

83
      iex> config = TerminusDB.Config.new(
84
      ...>   endpoint: "http://localhost:6363",
85
      ...>   adapter: fn req -> {req, Req.Response.new(status: 200, body: %{})} end
86
      ...> ) |> TerminusDB.Config.with_database("mydb")
87
      iex> {:ok, _} = TerminusDB.Diff.compare(config,
88
      ...>   before: "admin/mydb/local/branch/main",
89
      ...>   after: "admin/mydb/local/branch/feature"
90
      ...> )
91
      :ok
92

93
  """
94
  @spec compare(Config.t(), [compare_opt()]) :: {:ok, map()} | {:error, Error.t()}
95
  def compare(config, opts \\ []) do
96
    path = diff_path(config, opts)
24✔
97

98
    before_value = Keyword.fetch!(opts, :before)
21✔
99
    after_value = Keyword.fetch!(opts, :after)
18✔
100

101
    body =
18✔
102
      Params.maybe_put(%{"before" => before_value, "after" => after_value}, "keep", opts[:keep])
103

104
    Client.request(config, :post, path, json: body, area: :diff)
18✔
105
  end
106

107
  @doc """
108
  Compares two document states, or raises `TerminusDB.Error`.
109

110
  ## Examples
111

112
      iex> config = TerminusDB.Config.new(
113
      ...>   endpoint: "http://localhost:6363",
114
      ...>   adapter: fn req -> {req, Req.Response.new(status: 200, body: %{"name" => %{"@op" => "ValueSwap"}})} end
115
      ...> ) |> TerminusDB.Config.with_database("mydb")
116
      iex> TerminusDB.Diff.compare!(config,
117
      ...>   before: %{"name" => "Alice"},
118
      ...>   after: %{"name" => "Alicia"}
119
      ...> )
120
      %{"name" => %{"@op" => "ValueSwap"}}
121

122
  """
123
  @spec compare!(Config.t(), [compare_opt()]) :: map()
124
  def compare!(config, opts \\ []) do
125
    case compare(config, opts) do
6✔
126
      {:ok, body} -> body
3✔
127
      {:error, error} -> raise error
3✔
128
    end
129
  end
130

131
  @type diff_object_opt ::
132
          {:before, map()}
133
          | {:after, map()}
134
          | {:keep, map()}
135
          | {:organization, String.t()}
136
          | {:repo, String.t()}
137

138
  @doc """
139
  Diffs two concrete document objects and returns a `TerminusDB.Patch` struct.
140

141
  ## Options
142

143
  - `:before` (required) - the "before" document map.
144
  - `:after` (required) - the "after" document map.
145
  - `:keep` - a map of fields to preserve in the diff.
146
  - `:organization` - overrides `config.organization`.
147

148
  ## Examples
149

150
      iex> config = TerminusDB.Config.new(
151
      ...>   endpoint: "http://localhost:6363",
152
      ...>   adapter: fn req ->
153
      ...>     {req, Req.Response.new(status: 200, body: %{"name" => %{"@op" => "SwapValue", "@before" => "old", "@after" => "new"}})}
154
      ...>   end
155
      ...> ) |> TerminusDB.Config.with_database("mydb")
156
      iex> {:ok, patch} = TerminusDB.Diff.diff_object(config,
157
      ...>   before: %{"@id" => "Person/1", "name" => "old"},
158
      ...>   after: %{"@id" => "Person/1", "name" => "new"}
159
      ...> )
160
      iex> patch.content["name"]["@after"]
161
      "new"
162

163
  """
164
  @spec diff_object(Config.t(), [diff_object_opt()]) ::
165
          {:ok, Patch.t()} | {:error, Error.t()}
166
  def diff_object(config, opts \\ []) do
167
    path = diff_resource_path(config, opts)
18✔
168

169
    before_value = Keyword.fetch!(opts, :before)
18✔
170
    after_value = Keyword.fetch!(opts, :after)
18✔
171

172
    body =
18✔
173
      Params.maybe_put(%{"before" => before_value, "after" => after_value}, "keep", opts[:keep])
174

175
    case Client.request(config, :post, path, json: body, area: :diff) do
18✔
176
      {:ok, patch_content} -> {:ok, %Patch{content: patch_content}}
12✔
177
      {:error, _} = error -> error
6✔
178
    end
179
  end
180

181
  @doc """
182
  Diffs two concrete document objects, or raises.
183

184
  ## Examples
185

186
      iex> config = TerminusDB.Config.new(
187
      ...>   endpoint: "http://localhost:6363",
188
      ...>   adapter: fn req ->
189
      ...>     {req, Req.Response.new(status: 200, body: %{"name" => %{"@op" => "SwapValue", "@before" => "old", "@after" => "new"}})}
190
      ...>   end
191
      ...> ) |> TerminusDB.Config.with_database("mydb")
192
      iex> patch = TerminusDB.Diff.diff_object!(config,
193
      ...>   before: %{"name" => "old"},
194
      ...>   after: %{"name" => "new"}
195
      ...> )
196
      iex> patch.content["name"]["@after"]
197
      "new"
198

199
  """
200
  @spec diff_object!(Config.t(), [diff_object_opt()]) :: Patch.t()
201
  def diff_object!(config, opts \\ []) do
202
    case diff_object(config, opts) do
6✔
203
      {:ok, patch} -> patch
3✔
204
      {:error, error} -> raise error
3✔
205
    end
206
  end
207

208
  @type diff_version_opt ::
209
          {:before_version, String.t()}
210
          | {:after_version, String.t()}
211
          | {:organization, String.t()}
212
          | {:repo, String.t()}
213

214
  @doc """
215
  Diffs two commit/branch versions and returns a `TerminusDB.Patch` struct.
216

217
  ## Options
218

219
  - `:before_version` (required) - the before commit/branch descriptor.
220
  - `:after_version` (required) - the after commit/branch descriptor.
221
  - `:organization` - overrides `config.organization`.
222

223
  ## Examples
224

225
      iex> config = TerminusDB.Config.new(
226
      ...>   endpoint: "http://localhost:6363",
227
      ...>   adapter: fn req -> {req, Req.Response.new(status: 200, body: %{})} end
228
      ...> ) |> TerminusDB.Config.with_database("mydb")
229
      iex> {:ok, patch} = TerminusDB.Diff.diff_version(config,
230
      ...>   before_version: "admin/mydb/local/branch/main",
231
      ...>   after_version: "admin/mydb/local/branch/feature"
232
      ...> )
233
      iex> patch.content
234
      %{}
235

236
  """
237
  @spec diff_version(Config.t(), [diff_version_opt()]) ::
238
          {:ok, Patch.t()} | {:error, Error.t()}
239
  def diff_version(config, opts \\ []) do
240
    path = diff_resource_path(config, opts)
9✔
241

242
    body = %{
9✔
243
      "before_data_version" => Keyword.fetch!(opts, :before_version),
244
      "after_data_version" => Keyword.fetch!(opts, :after_version)
245
    }
246

247
    case Client.request(config, :post, path, json: body, area: :diff) do
9✔
248
      {:ok, patch_content} -> {:ok, %Patch{content: patch_content}}
9✔
NEW
249
      {:error, _} = error -> error
×
250
    end
251
  end
252

253
  @doc """
254
  Diffs two versions, or raises.
255

256
  ## Examples
257

258
      iex> config = TerminusDB.Config.new(
259
      ...>   endpoint: "http://localhost:6363",
260
      ...>   adapter: fn req -> {req, Req.Response.new(status: 200, body: %{})} end
261
      ...> ) |> TerminusDB.Config.with_database("mydb")
262
      iex> patch = TerminusDB.Diff.diff_version!(config,
263
      ...>   before_version: "admin/mydb/local/branch/main",
264
      ...>   after_version: "admin/mydb/local/branch/feature"
265
      ...> )
266
      iex> patch.content
267
      %{}
268

269
  """
270
  @spec diff_version!(Config.t(), [diff_version_opt()]) :: Patch.t()
271
  def diff_version!(config, opts \\ []) do
272
    case diff_version(config, opts) do
3✔
273
      {:ok, patch} -> patch
3✔
NEW
274
      {:error, error} -> raise error
×
275
    end
276
  end
277

278
  @type patch_opt ::
279
          {:organization, String.t()}
280

281
  @doc """
282
  Applies a patch to a "before" object and returns the "after" object (no
283
  commit).
284

285
  ## Options
286

287
  - `:organization` - overrides `config.organization`.
288

289
  ## Examples
290

291
      iex> config = TerminusDB.Config.new(
292
      ...>   endpoint: "http://localhost:6363",
293
      ...>   adapter: fn req -> {req, Req.Response.new(status: 200, body: %{"@id" => "Person/1", "name" => "new"})} end
294
      ...> ) |> TerminusDB.Config.with_database("mydb")
295
      iex> {:ok, after_obj} = TerminusDB.Diff.patch(config,
296
      ...>   before: %{"@id" => "Person/1", "name" => "old"},
297
      ...>   patch: %{"name" => %{"@op" => "SwapValue", "@before" => "old", "@after" => "new"}}
298
      ...> )
299
      iex> after_obj["name"]
300
      "new"
301

302
  """
303
  @spec patch(Config.t(), [patch_opt() | {:before, map()} | {:patch, map()}]) ::
304
          {:ok, map()} | {:error, Error.t()}
305
  def patch(config, opts \\ []) do
306
    before_value = Keyword.fetch!(opts, :before)
9✔
307
    patch_value = Keyword.fetch!(opts, :patch)
9✔
308

309
    body = %{"before" => before_value, "patch" => patch_value}
9✔
310

311
    Client.request(config, :post, "patch", json: body, area: :diff)
9✔
312
  end
313

314
  @doc """
315
  Applies a patch, or raises.
316

317
  ## Examples
318

319
      iex> config = TerminusDB.Config.new(
320
      ...>   endpoint: "http://localhost:6363",
321
      ...>   adapter: fn req -> {req, Req.Response.new(status: 200, body: %{"name" => "new"})} end
322
      ...> )
323
      iex> TerminusDB.Diff.patch!(config,
324
      ...>   before: %{"name" => "old"},
325
      ...>   patch: %{"name" => %{"@op" => "SwapValue", "@after" => "new"}}
326
      ...> )
327
      %{"name" => "new"}
328

329
  """
330
  @spec patch!(Config.t(), [patch_opt() | {:before, map()} | {:patch, map()}]) :: map()
331
  def patch!(config, opts \\ []) do
332
    case patch(config, opts) do
6✔
333
      {:ok, body} -> body
3✔
334
      {:error, error} -> raise error
3✔
335
    end
336
  end
337

338
  @type patch_resource_opt ::
339
          {:patch, map()}
340
          | {:message, String.t()}
341
          | {:author, String.t()}
342
          | {:match_final_state, boolean()}
343
          | {:organization, String.t()}
344
          | {:repo, String.t()}
345

346
  @doc """
347
  Applies a patch to a branch resource (commits the change).
348

349
  ## Options
350

351
  - `:patch` (required) - the patch content.
352
  - `:message` - commit message.
353
  - `:author` - commit author.
354
  - `:match_final_state` - boolean.
355
  - `:organization` - overrides `config.organization`.
356

357
  ## Examples
358

359
      iex> config = TerminusDB.Config.new(
360
      ...>   endpoint: "http://localhost:6363",
361
      ...>   adapter: fn req -> {req, Req.Response.new(status: 200, body: %{"api:status" => "api:success"})} end
362
      ...> ) |> TerminusDB.Config.with_database("mydb")
363
      iex> {:ok, resp} = TerminusDB.Diff.patch_resource(config,
364
      ...>   patch: %{"name" => %{"@op" => "SwapValue", "@after" => "new"}},
365
      ...>   author: "admin", message: "update"
366
      ...> )
367
      iex> resp["api:status"]
368
      "api:success"
369

370
  """
371
  @spec patch_resource(Config.t(), [patch_resource_opt()]) ::
372
          {:ok, map()} | {:error, Error.t()}
373
  def patch_resource(config, opts \\ []) do
374
    path = patch_resource_path(config, opts)
9✔
375
    patch_value = Keyword.fetch!(opts, :patch)
9✔
376

377
    body =
9✔
378
      %{"patch" => patch_value}
379
      |> Params.maybe_put("message", opts[:message])
380
      |> Params.maybe_put("author", opts[:author])
381
      |> Params.maybe_put("match_final_state", opts[:match_final_state])
382

383
    Client.request(config, :post, path, json: body, area: :diff)
9✔
384
  end
385

386
  @doc """
387
  Applies a patch to a branch resource, or raises.
388

389
  ## Examples
390

391
      iex> config = TerminusDB.Config.new(
392
      ...>   endpoint: "http://localhost:6363",
393
      ...>   adapter: fn req -> {req, Req.Response.new(status: 200, body: %{"api:status" => "api:success"})} end
394
      ...> ) |> TerminusDB.Config.with_database("mydb")
395
      iex> TerminusDB.Diff.patch_resource!(config, patch: %{})
396
      %{"api:status" => "api:success"}
397

398
  """
399
  @spec patch_resource!(Config.t(), [patch_resource_opt()]) :: map()
400
  def patch_resource!(config, opts \\ []) do
401
    case patch_resource(config, opts) do
3✔
402
      {:ok, body} -> body
3✔
NEW
403
      {:error, error} -> raise error
×
404
    end
405
  end
406

407
  @type apply_opt ::
408
          {:before_version, String.t()}
409
          | {:after_version, String.t()}
410
          | {:message, String.t()}
411
          | {:author, String.t()}
412
          | {:organization, String.t()}
413
          | {:repo, String.t()}
414

415
  @doc """
416
  Diffs two commits and applies the changes onto a branch.
417

418
  ## Options
419

420
  - `:before_version` (required) - the before commit descriptor.
421
  - `:after_version` (required) - the after commit descriptor.
422
  - `:message` - commit message.
423
  - `:author` - commit author.
424
  - `:organization` - overrides `config.organization`.
425

426
  ## Examples
427

428
      iex> config = TerminusDB.Config.new(
429
      ...>   endpoint: "http://localhost:6363",
430
      ...>   adapter: fn req -> {req, Req.Response.new(status: 200, body: %{"api:status" => "api:success"})} end
431
      ...> ) |> TerminusDB.Config.with_database("mydb")
432
      iex> {:ok, resp} = TerminusDB.Diff.apply(config,
433
      ...>   before_version: "admin/mydb/local/commit/abc",
434
      ...>   after_version: "admin/mydb/local/commit/def",
435
      ...>   author: "admin", message: "apply"
436
      ...> )
437
      iex> resp["api:status"]
438
      "api:success"
439

440
  """
441
  @spec apply(Config.t(), [apply_opt()]) ::
442
          {:ok, map()} | {:error, Error.t()}
443
  def apply(config, opts \\ []) do
444
    path = apply_path(config, opts)
9✔
445

446
    commit_info =
9✔
447
      %{}
448
      |> Params.maybe_put("author", opts[:author])
449
      |> Params.maybe_put("message", opts[:message])
450

451
    body =
9✔
452
      Params.maybe_put(
453
        %{
454
          "before_commit" => Keyword.fetch!(opts, :before_version),
455
          "after_commit" => Keyword.fetch!(opts, :after_version)
456
        },
457
        "commit_info",
458
        commit_info
459
      )
460

461
    Client.request(config, :post, path, json: body, area: :diff)
9✔
462
  end
463

464
  @doc """
465
  Applies a diff to a branch, or raises.
466

467
  ## Examples
468

469
      iex> config = TerminusDB.Config.new(
470
      ...>   endpoint: "http://localhost:6363",
471
      ...>   adapter: fn req -> {req, Req.Response.new(status: 200, body: %{"api:status" => "api:success"})} end
472
      ...> ) |> TerminusDB.Config.with_database("mydb")
473
      iex> TerminusDB.Diff.apply!(config,
474
      ...>   before_version: "admin/mydb/local/commit/abc",
475
      ...>   after_version: "admin/mydb/local/commit/def"
476
      ...> )
477
      %{"api:status" => "api:success"}
478

479
  """
480
  @spec apply!(Config.t(), [apply_opt()]) :: map()
481
  def apply!(config, opts \\ []) do
482
    case apply(config, opts) do
6✔
483
      {:ok, body} -> body
3✔
484
      {:error, error} -> raise error
3✔
485
    end
486
  end
487

488
  defp diff_resource_path(config, opts) do
489
    org = opts[:organization] || config.organization
27✔
490
    db = config.database || raise Error, reason: :http, message: "no database scoped in config"
27✔
491
    repo = opts[:repo] || config.repo
27✔
492
    branch = config.branch
27✔
493
    "diff/#{org}/#{db}/#{repo}/branch/#{branch}"
27✔
494
  end
495

496
  defp patch_resource_path(config, opts) do
497
    org = opts[:organization] || config.organization
9✔
498
    db = config.database || raise Error, reason: :http, message: "no database scoped in config"
9✔
499
    repo = opts[:repo] || config.repo
9✔
500
    branch = config.branch
9✔
501
    "patch/#{org}/#{db}/#{repo}/branch/#{branch}"
9✔
502
  end
503

504
  defp apply_path(config, opts) do
505
    org = opts[:organization] || config.organization
9✔
506
    db = config.database || raise Error, reason: :http, message: "no database scoped in config"
9✔
507
    repo = opts[:repo] || config.repo
9✔
508
    branch = config.branch
9✔
509
    "apply/#{org}/#{db}/#{repo}/branch/#{branch}"
9✔
510
  end
511
end
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc