Install

CRUD ​

Insert a document, read it, change it, delete it, count the rest. It shows if_version, the optimistic lock of ndb.

When to use ​

Your app owns the data and writes it through ndb (engine mode). Use if_version when two writers can change the same document.

Collection ​

Declared in examples/ndb.yml:

yaml
users:
  id: user_id
  indexes: [plan]

Connect ​

Each SDK reads NDB_URL and NDB_TOKEN. The Go and TypeScript code declares the collection from its type; Ruby declares it with ensure_collection. The declaration agrees with ndb.yml, and the indexes are united.

go
db, err := ndb.Open(addr) // NDB_TOKEN is read by the SDK
if err != nil {
	return err
}

defer db.Close()

users, err := ndb.Collection[User](ctx, db, "users")
if err != nil {
	return err
}
rb
ndb = Ndb::Client.new(url: ENV.fetch("NDB_URL", "unix:///tmp/ndb-examples.sock")) # NDB_TOKEN is read by the SDK

ndb.ensure_collection("users", id: "user_id", indexes: ["plan"])
rb
Ndb::Rails.configure do |c|
  c.url = ENV.fetch("NDB_URL", "unix:///tmp/ndb-examples.sock") # NDB_TOKEN is read by the SDK
end
ts
const ndb = connect(process.env.NDB_URL ?? 'unix:///tmp/ndb-examples.sock') // NDB_TOKEN is read by the SDK

const users = ndb.model('users', {
  id: 'user_id',
  fields: {
    user_id: { type: 'string' },
    email: { type: 'string' },
    plan: { type: 'string', index: true },
  },
})

Model (Go and Rails) ​

Go reads the ndb struct tags. Ndb::Record reads attribute lines. _v is the document version.

go
type User struct {
	UserID  string `ndb:"user_id,id"`
	Email   string `ndb:"email"`
	Plan    string `ndb:"plan,index"`
	Version uint64 `ndb:"_v,version"`
}
rb
class User < Ndb::Record
  primary_key :user_id

  attribute :email, :string
  attribute :plan, :string, index: true

  validates :email, presence: true
end

Insert ​

One call inserts both users in one transaction. Every new document has version 1.

go
results, err := users.Insert(ctx,
	User{UserID: "u1", Email: "ada@example.com", Plan: "free"},
	User{UserID: "u2", Email: "grace@example.com", Plan: "free"},
)
if err != nil {
	return err
}

fmt.Printf("insert u1, u2 -> v%d, v%d\n", results[0].V, results[1].V)
rb
results = ndb.insert("users", [
  { user_id: "u1", email: "ada@example.com", plan: "free" },
  { user_id: "u2", email: "grace@example.com", plan: "free" }
])

puts "insert u1, u2 -> v#{results[0]['v']}, v#{results[1]['v']}"
rb
User.create!([
  { user_id: "u1", email: "ada@example.com", plan: "free" },
  { user_id: "u2", email: "grace@example.com", plan: "free" }
])

puts "insert u1, u2 -> v#{User.find('u1').version}, v#{User.find('u2').version}"
ts
const results = await users.insert([
  { user_id: 'u1', email: 'ada@example.com', plan: 'free' },
  { user_id: 'u2', email: 'grace@example.com', plan: 'free' },
])

console.log(`insert u1, u2 -> v${results[0]?.v}, v${results[1]?.v}`)
sh
results=$(ndb POST /v1/c/users/insert '{"insert":[
  {"user_id":"u1","email":"ada@example.com","plan":"free"},
  {"user_id":"u2","email":"grace@example.com","plan":"free"}
]}')

echo "insert u1, u2 -> $(jq -r '"v\(.results[0].v), v\(.results[1].v)"' <<<"$results")"

Get by id ​

Ask for the version (versions: true in the query) so you can send it back.

go
ada, err := users.Get(ctx, "u1")
if err != nil {
	return err
}

fmt.Printf("get u1: %s plan=%s v%d\n", ada.Email, ada.Plan, ada.Version)
rb
ada = ndb.find("users", where: { user_id: "u1" }, versions: true).rows.first

puts "get u1: #{ada['email']} plan=#{ada['plan']} v#{ada['_v']}"
rb
ada = User.find("u1")

puts "get u1: #{ada.email} plan=#{ada.plan} v#{ada.version}"
ts
const ada = await users.find('u1')

console.log(`get u1: ${ada?.email} plan=${ada?.plan} v${ada?._v}`)
sh
ada=$(ndb POST /v1/c/users/find '{"where":{"user_id":"u1"},"versions":true}')
version=$(jq -r '._v' <<<"$ada")

echo "get u1: $(jq -r '"\(.email) plan=\(.plan) v\(._v)"' <<<"$ada")"

Update with if_version ​

The write succeeds only if the stored version is the one you read.

go
v, err := users.Update(ctx, "u1", map[string]any{"plan": "pro"}, ndb.IfVersion(ada.Version))
if err != nil {
	return err
}

fmt.Printf("update u1 plan=pro (if_version %d) -> v%d\n", ada.Version, v)
rb
saved = ndb.update("users", "u1", set: { plan: "pro" }, if_version: ada["_v"])

puts "update u1 plan=pro (if_version #{ada['_v']}) -> v#{saved['v']}"
rb
copy = User.find("u1")

ada.update!(plan: "pro")

puts "update u1 plan=pro (if_version 1) -> v#{ada.version}"
ts
const saved = await users.update('u1', { plan: 'pro' }, { ifVersion: ada?._v })

console.log(`update u1 plan=pro (if_version ${ada?._v}) -> v${saved.v}`)
sh
saved=$(ndb POST /v1/c/users/update "{\"update\":{\"id\":\"u1\",\"set\":{\"plan\":\"pro\"},\"if_version\":$version}}")

echo "update u1 plan=pro (if_version $version) -> v$(jq -r '.v' <<<"$saved")"

Handle a version conflict ​

A second writer that still holds version 1 gets version_conflict (HTTP 409). Reload and try again. In Rails the error is Ndb::StaleObjectError, as in ActiveRecord.

go
_, err = users.Update(ctx, "u1", map[string]any{"plan": "team"}, ndb.IfVersion(ada.Version))

if errors.Is(err, ndb.ErrVersionConflict) {
	fmt.Println("update u1 plan=team (if_version 1) -> version_conflict")

	fresh, err := users.Get(ctx, "u1")
	if err != nil {
		return err
	}

	v, err = users.Update(ctx, "u1", map[string]any{"plan": "team"}, ndb.IfVersion(fresh.Version))
	if err != nil {
		return err
	}

	fmt.Printf("reload u1: v%d, retry with if_version %d -> v%d\n", fresh.Version, fresh.Version, v)
} else if err != nil {
	return err
}
rb
begin
  ndb.update("users", "u1", set: { plan: "team" }, if_version: ada["_v"])
rescue Ndb::VersionConflict
  puts "update u1 plan=team (if_version #{ada['_v']}) -> version_conflict"

  fresh = ndb.find("users", where: { user_id: "u1" }, versions: true).rows.first
  saved = ndb.update("users", "u1", set: { plan: "team" }, if_version: fresh["_v"])

  puts "reload u1: v#{fresh['_v']}, retry with if_version #{fresh['_v']} -> v#{saved['v']}"
end
rb
begin
  copy.update!(plan: "team")
rescue Ndb::StaleObjectError
  puts "update u1 plan=team (if_version 1) -> version_conflict"

  fresh = User.find("u1")
  stale_version = fresh.version

  fresh.update!(plan: "team")

  puts "reload u1: v#{stale_version}, retry with if_version #{stale_version} -> v#{fresh.version}"
end
ts
try {
  await users.update('u1', { plan: 'team' }, { ifVersion: ada?._v })
} catch (err) {
  if (!(err instanceof VersionConflictError)) {
    throw err
  }

  console.log(`update u1 plan=team (if_version ${ada?._v}) -> version_conflict`)

  const fresh = await users.find('u1')
  const retried = await users.update('u1', { plan: 'team' }, { ifVersion: fresh?._v })

  console.log(`reload u1: v${fresh?._v}, retry with if_version ${fresh?._v} -> v${retried.v}`)
}
sh
status=$(ndb_status POST /v1/c/users/update "{\"update\":{\"id\":\"u1\",\"set\":{\"plan\":\"team\"},\"if_version\":$version}}")

if [[ "$status" == "409" ]]; then
	echo "update u1 plan=team (if_version $version) -> $(jq -r '.error.code' "$NDB_BODY")"

	fresh=$(ndb POST /v1/c/users/find '{"where":{"user_id":"u1"},"versions":true}' | jq -r '._v')
	retried=$(ndb POST /v1/c/users/update "{\"update\":{\"id\":\"u1\",\"set\":{\"plan\":\"team\"},\"if_version\":$fresh}}")

	echo "reload u1: v$fresh, retry with if_version $fresh -> v$(jq -r '.v' <<<"$retried")"
fi

Delete ​

Deleting a missing id is not_found. The programs ignore it at the start, so a second run works.

go
if err := users.Delete(ctx, "u2"); err != nil {
	return err
}

fmt.Println("delete u2")
rb
ndb.delete("users", "u2")

puts "delete u2"
rb
User.find("u2").destroy

puts "delete u2"
ts
await users.delete('u2')

console.log('delete u2')
sh
ndb POST /v1/c/users/delete '{"delete":{"id":"u2"}}' >/dev/null

echo "delete u2"

Count ​

count is the total of the filter, whatever the page size.

go
total, err := users.Where().Count(ctx)
if err != nil {
	return err
}

onTeam, err := users.Where(ndb.Eq("plan", "team")).Count(ctx)
if err != nil {
	return err
}

fmt.Printf("count: %d user(s), %d on plan team\n", total, onTeam)
rb
total = ndb.find("users", count: true, limit: 1).total
on_team = ndb.find("users", where: { plan: "team" }, count: true, limit: 1).total

puts "count: #{total} user(s), #{on_team} on plan team"
rb
puts "count: #{User.count} user(s), #{User.where(plan: 'team').count} on plan team"
ts
const total = await users.count()
const onTeam = await users.where({ plan: 'team' }).count()

console.log(`count: ${total} user(s), ${onTeam} on plan team`)
sh
total=$(ndb_header X-Ndb-Total POST /v1/c/users/find '{"count":true,"limit":1}')
on_team=$(ndb_header X-Ndb-Total POST /v1/c/users/find '{"where":{"plan":"team"},"count":true,"limit":1}')

echo "count: $total user(s), $on_team on plan team"

Output ​

The four programs print the same text.

text
insert u1, u2 -> v1, v1
get u1: ada@example.com plan=free v1
update u1 plan=pro (if_version 1) -> v2
update u1 plan=team (if_version 1) -> version_conflict
reload u1: v2, retry with if_version 2 -> v3
delete u2
count: 1 user(s), 1 on plan team

Notes ​

  • The Rails tab runs with ndb-rails as a plain Ruby script. In a Rails app, config/ndb.yml does the connect step. See Clients.
  • Every SDK supports every step of this example.
  • Errors and codes: Query errors.

Run it ​

sh
cd examples
make run-crud LANG=go   # go, ruby, ts or curl
make run-crud           # every language

Source: examples/crud. The programs read NDB_URL and NDB_TOKEN, and they can run again and again.