Amazon QLDB driver for Node.js – Cookbook
reference
This reference guide shows common use cases of the Amazon QLDB driver for Node.js. It
provides JavaScript and TypeScript code examples that demonstrate how to use the driver to
run basic create, read, update, and delete (CRUD) operations. It also
includes code examples for processing Amazon Ion data. In addition, this guide highlights
best practices for making transactions idempotent and implementing uniqueness
constraints.
Importing the driver
The following code example imports the driver.
- JavaScript
-
var qldb = require('amazon-qldb-driver-nodejs');
var ionjs = require('ion-js');
- TypeScript
-
import { QldbDriver, TransactionExecutor } from "amazon-qldb-driver-nodejs";
import { dom, dumpBinary, load } from "ion-js";
This example also imports the Amazon Ion package (ion-js
). You need
this package to process Ion data when running some data operations in this
reference. To learn more, see Working with Amazon Ion.
Instantiating the driver
The following code example creates an instance of the driver that connects to a
specified ledger name using default settings.
- JavaScript
-
const qldbDriver = new qldb.QldbDriver("vehicle-registration");
- TypeScript
-
const qldbDriver: QldbDriver = new QldbDriver("vehicle-registration");
CRUD operations
QLDB runs create, read, update, and delete (CRUD)
operations as part of a transaction.
As a best practice, make your write transactions strictly idempotent.
Making transactions idempotent
We recommend that you make write transactions idempotent to avoid any unexpected side effects in
the case of retries. A transaction is idempotent if it can run
multiple times and produce identical results each time.
For example, consider a transaction that inserts a document into a table named
Person
. The transaction should first check whether or not the document
already exists in the table. Without this check, the table might end up with duplicate
documents.
Suppose that QLDB successfully commits the transaction on the server side, but the
client times out while waiting for a response. If the transaction isn't idempotent, the
same document could be inserted more than once in the case of a retry.
Using indexes to avoid full table scans
We also recommend that you run statements with a WHERE
predicate clause using
an equality operator on an indexed field or a document ID; for example,
WHERE indexedField = 123
or WHERE indexedField IN (456, 789)
.
Without this indexed lookup, QLDB needs to do a table scan, which can lead to transaction
timeouts or optimistic concurrency control (OCC) conflicts.
For more information about OCC, see Amazon QLDB concurrency model.
Implicitly created
transactions
The QldbDriver.executeLambda method accepts a lambda function that receives an
instance of TransactionExecutor, which you can use to run statements. The instance of
TransactionExecutor
wraps an implicitly created transaction.
You can run statements within the lambda function by using the execute method of the transaction executor. The driver implicitly commits
the transaction when the lambda function returns.
The execute
method supports both Amazon Ion types and Node.js native
types. If you pass a Node.js native type as an argument to execute
, the
driver converts it to an Ion type using the ion-js
package (provided
that conversion for the given Node.js data type is supported). For supported data
types and conversion rules, see the Ion JavaScript DOM README.
The following sections show how to run basic CRUD operations, specify custom retry
logic, and implement uniqueness constraints.
Creating tables
- JavaScript
-
(async function() {
await qldbDriver.executeLambda(async (txn) => {
await txn.execute("CREATE TABLE Person");
});
})();
- TypeScript
-
(async function(): Promise<void> {
await qldbDriver.executeLambda(async (txn: TransactionExecutor) => {
await txn.execute('CREATE TABLE Person');
});
}());
Creating indexes
- JavaScript
-
(async function() {
await qldbDriver.executeLambda(async (txn) => {
await txn.execute("CREATE INDEX ON Person (GovId)");
});
})();
- TypeScript
-
(async function(): Promise<void> {
await qldbDriver.executeLambda(async (txn: TransactionExecutor) => {
await txn.execute('CREATE INDEX ON Person (GovId)');
});
}());
Reading documents
- JavaScript
-
(async function() {
// Assumes that Person table has documents as follows:
// { "GovId": "TOYENC486FH", "FirstName": "Brent" }
await qldbDriver.executeLambda(async (txn) => {
const results = (await txn.execute("SELECT * FROM Person WHERE GovId = 'TOYENC486FH'")).getResultList();
for (let result of results) {
console.log(result.get('GovId')); // prints [String: 'TOYENC486FH']
console.log(result.get('FirstName')); // prints [String: 'Brent']
}
});
}());
- TypeScript
-
(async function(): Promise<void> {
// Assumes that Person table has documents as follows:
// { "GovId": "TOYENC486FH", "FirstName": "Brent" }
await qldbDriver.executeLambda(async (txn: TransactionExecutor) => {
const results: dom.Value[] = (await txn.execute("SELECT * FROM Person WHERE GovId = 'TOYENC486FH'")).getResultList();
for (let result of results) {
console.log(result.get('GovId')); // prints [String: 'TOYENC486FH']
console.log(result.get('FirstName')); // prints [String: 'Brent']
}
});
}());
Using query
parameters
The following code example uses a native type query parameter.
- JavaScript
-
(async function() {
// Assumes that Person table has documents as follows:
// { "GovId": "TOYENC486FH", "FirstName": "Brent" }
await qldbDriver.executeLambda(async (txn) => {
const results = (await txn.execute('SELECT * FROM Person WHERE GovId = ?', 'TOYENC486FH')).getResultList();
for (let result of results) {
console.log(result.get('GovId')); // prints [String: 'TOYENC486FH']
console.log(result.get('FirstName')); // prints [String: 'Brent']
}
});
}());
- TypeScript
-
(async function(): Promise<void> {
// Assumes that Person table has documents as follows:
// { "GovId": "TOYENC486FH", "FirstName": "Brent" }
await qldbDriver.executeLambda(async (txn: TransactionExecutor) => {
const results: dom.Value[] = (await txn.execute('SELECT * FROM Person WHERE GovId = ?', 'TOYENC486FH')).getResultList();
for (let result of results) {
console.log(result.get('GovId')); // prints [String: 'TOYENC486FH']
console.log(result.get('FirstName')); // prints [String: 'Brent']
}
});
}());
The following code example uses an Ion type query parameter.
- JavaScript
-
(async function() {
await qldbDriver.executeLambda(async (txn) => {
const govId = ionjs.load("TOYENC486FH");
const results = (await txn.execute('SELECT * FROM Person WHERE GovId = ?', govId)).getResultList();
for (let result of results) {
console.log(result.get('GovId')); // prints [String: 'TOYENC486FH']
console.log(result.get('FirstName')); // prints [String: 'Brent']
}
});
}());
- TypeScript
-
(async function(): Promise<void> {
await qldbDriver.executeLambda(async (txn: TransactionExecutor) => {
const govId: dom.Value = load("TOYENC486FH");
const results: dom.Value[] = (await txn.execute('SELECT * FROM Person WHERE GovId = ?', govId)).getResultList();
for (let result of results) {
console.log(result.get('GovId')); // prints [String: 'TOYENC486FH']
console.log(result.get('FirstName')); // prints [String: 'Brent']
}
});
}());
The following code example uses multiple query parameters.
- JavaScript
-
(async function() {
await qldbDriver.executeLambda(async (txn) => {
const results = (await txn.execute('SELECT * FROM Person WHERE GovId = ? AND FirstName = ?', 'TOYENC486FH', 'Brent')).getResultList();
for (let result of results) {
console.log(result.get('GovId')); // prints [String: 'TOYENC486FH']
console.log(result.get('FirstName')); // prints [String: 'Brent']
}
});
}());
- TypeScript
-
(async function(): Promise<void> {
await qldbDriver.executeLambda(async (txn: TransactionExecutor) => {
const results: dom.Value[] = (await txn.execute('SELECT * FROM Person WHERE GovId = ? AND FirstName = ?', 'TOYENC486FH', 'Brent')).getResultList();
for (let result of results) {
console.log(result.get('GovId')); // prints [String: 'TOYENC486FH']
console.log(result.get('FirstName')); // prints [String: 'Brent']
}
});
}());
The following code example uses a list of query parameters.
- JavaScript
-
(async function() {
await qldbDriver.executeLambda(async (txn) => {
const govIds = ['TOYENC486FH','LOGANB486CG','LEWISR261LL'];
/*
Assumes that Person table has documents as follows:
{ "GovId": "TOYENC486FH", "FirstName": "Brent" }
{ "GovId": "LOGANB486CG", "FirstName": "Brent" }
{ "GovId": "LEWISR261LL", "FirstName": "Raul" }
*/
const results = (await txn.execute('SELECT * FROM Person WHERE GovId IN (?,?,?)', ...govIds)).getResultList();
for (let result of results) {
console.log(result.get('GovId'));
console.log(result.get('FirstName'));
/*
prints:
[String: 'TOYENC486FH']
[String: 'Brent']
[String: 'LOGANB486CG']
[String: 'Brent']
[String: 'LEWISR261LL']
[String: 'Raul']
*/
}
});
}());
- TypeScript
-
(async function(): Promise<void> {
await qldbDriver.executeLambda(async (txn: TransactionExecutor) => {
const govIds: string[] = ['TOYENC486FH','LOGANB486CG','LEWISR261LL'];
/*
Assumes that Person table has documents as follows:
{ "GovId": "TOYENC486FH", "FirstName": "Brent" }
{ "GovId": "LOGANB486CG", "FirstName": "Brent" }
{ "GovId": "LEWISR261LL", "FirstName": "Raul" }
*/
const results: dom.Value[] = (await txn.execute('SELECT * FROM Person WHERE GovId IN (?,?,?)', ...govIds)).getResultList();
for (let result of results) {
console.log(result.get('GovId'));
console.log(result.get('FirstName'));
/*
prints:
[String: 'TOYENC486FH']
[String: 'Brent']
[String: 'LOGANB486CG']
[String: 'Brent']
[String: 'LEWISR261LL']
[String: 'Raul']
*/
}
});
}());
When you run a query without an indexed lookup, it invokes a full table scan. In this
example, we recommend having an index on
the GovId
field to optimize performance. Without an index on
GovId
, queries can have more latency and can also lead to OCC conflict
exceptions or transaction timeouts.
Inserting documents
The following code example inserts native data types.
- JavaScript
-
(async function() {
await qldbDriver.executeLambda(async (txn) => {
// Check if doc with GovId:TOYENC486FH exists
// This is critical to make this transaction idempotent
const results = (await txn.execute('SELECT * FROM Person WHERE GovId = ?', 'TOYENC486FH')).getResultList();
// Insert the document after ensuring it doesn't already exist
if (results.length == 0) {
const doc = {
'FirstName': 'Brent',
'GovId': 'TOYENC486FH',
};
await txn.execute('INSERT INTO Person ?', doc);
}
});
}());
- TypeScript
-
(async function(): Promise<void> {
await qldbDriver.executeLambda(async (txn: TransactionExecutor) => {
// Check if doc with GovId:TOYENC486FH exists
// This is critical to make this transaction idempotent
const results: dom.Value[] = (await txn.execute('SELECT * FROM Person WHERE GovId = ?', 'TOYENC486FH')).getResultList();
// Insert the document after ensuring it doesn't already exist
if (results.length == 0) {
const doc: Record<string, string> = {
'FirstName': 'Brent',
'GovId': 'TOYENC486FH',
};
await txn.execute('INSERT INTO Person ?', doc);
}
});
}());
The following code example inserts Ion data types.
- JavaScript
-
(async function() {
await qldbDriver.executeLambda(async (txn) => {
// Check if doc with GovId:TOYENC486FH exists
// This is critical to make this transaction idempotent
const results = (await txn.execute('SELECT * FROM Person WHERE GovId = ?', 'TOYENC486FH')).getResultList();
// Insert the document after ensuring it doesn't already exist
if (results.length == 0) {
const doc = {
'FirstName': 'Brent',
'GovId': 'TOYENC486FH',
};
// Create a sample Ion doc
const ionDoc = ionjs.load(ionjs.dumpBinary(doc));
await txn.execute('INSERT INTO Person ?', ionDoc);
}
});
}());
- TypeScript
-
(async function(): Promise<void> {
await qldbDriver.executeLambda(async (txn: TransactionExecutor) => {
// Check if doc with GovId:TOYENC486FH exists
// This is critical to make this transaction idempotent
const results: dom.Value[] = (await txn.execute('SELECT * FROM Person WHERE GovId = ?', 'TOYENC486FH')).getResultList();
// Insert the document after ensuring it doesn't already exist
if (results.length == 0) {
const doc: Record<string, string> = {
'FirstName': 'Brent',
'GovId': 'TOYENC486FH',
};
// Create a sample Ion doc
const ionDoc: dom.Value = load(dumpBinary(doc));
await txn.execute('INSERT INTO Person ?', ionDoc);
}
});
}());
This transaction inserts a document into the Person
table. Before
inserting, it first checks if the document already exists in the table. This check makes the transaction idempotent in nature.
Even if you run this transaction multiple times, it won't cause any unintended side effects.
In this example, we recommend having an index on the GovId
field
to optimize performance. Without an index on GovId
, statements can
have more latency and can also lead to OCC conflict exceptions or transaction timeouts.
Inserting multiple
documents in one statement
To insert multiple documents by using a single INSERT
statement, you can pass a parameter of type list to the statement as follows.
// people is a list
txn.execute("INSERT INTO People ?", people);
You don't enclose the variable placeholder (?
) in double angle
brackets ( <<...>>
) when passing a list. In manual PartiQL
statements, double angle brackets denote an unordered collection known as a
bag.
Updating documents
The following code example uses native data types.
- JavaScript
-
(async function() {
await qldbDriver.executeLambda(async (txn) => {
await txn.execute('UPDATE Person SET FirstName = ? WHERE GovId = ?', 'John', 'TOYENC486FH');
});
}());
- TypeScript
-
(async function(): Promise<void> {
await qldbDriver.executeLambda(async (txn: TransactionExecutor) => {
await txn.execute('UPDATE Person SET FirstName = ? WHERE GovId = ?', 'John', 'TOYENC486FH');
});
}());
The following code example uses Ion data types.
- JavaScript
-
(async function() {
await qldbDriver.executeLambda(async (txn) => {
const firstName = ionjs.load("John");
const govId = ionjs.load("TOYENC486FH");
await txn.execute('UPDATE Person SET FirstName = ? WHERE GovId = ?', firstName, govId);
});
}());
- TypeScript
-
(async function(): Promise<void> {
await qldbDriver.executeLambda(async (txn: TransactionExecutor) => {
const firstName: dom.Value = load("John");
const govId: dom.Value = load("TOYENC486FH");
await txn.execute('UPDATE Person SET FirstName = ? WHERE GovId = ?', firstName, govId);
});
}());
In this example, we recommend having an index on the GovId
field
to optimize performance. Without an index on GovId
, statements can
have more latency and can also lead to OCC conflict exceptions or transaction timeouts.
Deleting documents
The following code example uses native data types.
- JavaScript
-
(async function() {
await qldbDriver.executeLambda(async (txn) => {
await txn.execute('DELETE FROM Person WHERE GovId = ?', 'TOYENC486FH');
});
}());
- TypeScript
-
(async function(): Promise<void> {
await qldbDriver.executeLambda(async (txn: TransactionExecutor) => {
await txn.execute('DELETE FROM Person WHERE GovId = ?', 'TOYENC486FH');
});
}());
The following code example uses Ion data types.
- JavaScript
-
(async function() {
await qldbDriver.executeLambda(async (txn) => {
const govId = ionjs.load("TOYENC486FH");
await txn.execute('DELETE FROM Person WHERE GovId = ?', govId);
});
}());
- TypeScript
-
(async function(): Promise<void> {
await qldbDriver.executeLambda(async (txn: TransactionExecutor) => {
const govId: dom.Value = load("TOYENC486FH");
await txn.execute('DELETE FROM Person WHERE GovId = ?', govId);
});
}());
In this example, we recommend having an index on the GovId
field
to optimize performance. Without an index on GovId
, statements can
have more latency and can also lead to OCC conflict exceptions or transaction timeouts.
Running multiple statements
in a transaction
- TypeScript
-
// This code snippet is intentionally trivial. In reality you wouldn't do this because you'd
// set your UPDATE to filter on vin and insured, and check if you updated something or not.
async function insureCar(driver: QldbDriver, vin: string): Promise<boolean> {
return await driver.executeLambda(async (txn: TransactionExecutor) => {
const results: dom.Value[] = (await txn.execute(
"SELECT insured FROM Vehicles WHERE vin = ? AND insured = FALSE", vin)).getResultList();
if (results.length > 0) {
await txn.execute(
"UPDATE Vehicles SET insured = TRUE WHERE vin = ?", vin);
return true;
}
return false;
});
};
Retry logic
The driver's executeLambda
method has a built-in retry mechanism that
retries the transaction if a retryable exception occurs (such as timeouts or OCC
conflicts). The maximum number of retry attempts and the backoff strategy are
configurable.
The default retry limit is 4
, and the default backoff strategy is
defaultBackoffFunction with a base of 10
milliseconds. You
can set the retry configuration per driver instance and also per transaction by
using an instance of RetryConfig.
The following code example specifies retry logic with a custom retry limit and a
custom backoff strategy for an instance of the driver.
- JavaScript
-
var qldb = require('amazon-qldb-driver-nodejs');
// Configuring retry limit to 2
const retryConfig = new qldb.RetryConfig(2);
const qldbDriver = new qldb.QldbDriver("test-ledger", undefined, undefined, retryConfig);
// Configuring a custom backoff which increases delay by 1s for each attempt.
const customBackoff = (retryAttempt, error, transactionId) => {
return 1000 * retryAttempt;
};
const retryConfigCustomBackoff = new qldb.RetryConfig(2, customBackoff);
const qldbDriverCustomBackoff = new qldb.QldbDriver("test-ledger", undefined, undefined, retryConfigCustomBackoff);
- TypeScript
-
import { BackoffFunction, QldbDriver, RetryConfig } from "amazon-qldb-driver-nodejs"
// Configuring retry limit to 2
const retryConfig: RetryConfig = new RetryConfig(2);
const qldbDriver: QldbDriver = new QldbDriver("test-ledger", undefined, undefined, retryConfig);
// Configuring a custom backoff which increases delay by 1s for each attempt.
const customBackoff: BackoffFunction = (retryAttempt: number, error: Error, transactionId: string) => {
return 1000 * retryAttempt;
};
const retryConfigCustomBackoff: RetryConfig = new RetryConfig(2, customBackoff);
const qldbDriverCustomBackoff: QldbDriver = new QldbDriver("test-ledger", undefined, undefined, retryConfigCustomBackoff);
The following code example specifies retry logic with a custom retry limit and a
custom backoff strategy for a particular lambda execution. This configuration for
executeLambda
overrides the retry logic that is set for the driver
instance.
- JavaScript
-
var qldb = require('amazon-qldb-driver-nodejs');
// Configuring retry limit to 2
const retryConfig1 = new qldb.RetryConfig(2);
const qldbDriver = new qldb.QldbDriver("test-ledger", undefined, undefined, retryConfig1);
// Configuring a custom backoff which increases delay by 1s for each attempt.
const customBackoff = (retryAttempt, error, transactionId) => {
return 1000 * retryAttempt;
};
const retryConfig2 = new qldb.RetryConfig(2, customBackoff);
// The config `retryConfig1` will be overridden by `retryConfig2`
(async function() {
await qldbDriver.executeLambda(async (txn) => {
await txn.execute('CREATE TABLE Person');
}, retryConfig2);
}());
- TypeScript
-
import { BackoffFunction, QldbDriver, RetryConfig, TransactionExecutor } from "amazon-qldb-driver-nodejs"
// Configuring retry limit to 2
const retryConfig1: RetryConfig = new RetryConfig(2);
const qldbDriver: QldbDriver = new QldbDriver("test-ledger", undefined, undefined, retryConfig1);
// Configuring a custom backoff which increases delay by 1s for each attempt.
const customBackoff: BackoffFunction = (retryAttempt: number, error: Error, transactionId: string) => {
return 1000 * retryAttempt;
};
const retryConfig2: RetryConfig = new RetryConfig(2, customBackoff);
// The config `retryConfig1` will be overridden by `retryConfig2`
(async function(): Promise<void> {
await qldbDriver.executeLambda(async (txn: TransactionExecutor) => {
await txn.execute('CREATE TABLE Person');
}, retryConfig2);
}());
Implementing
uniqueness constraints
QLDB doesn't support unique indexes, but you can implement this
behavior in your application.
Suppose that you want to implement a uniqueness constraint on the
GovId
field in the Person
table. To do this, you can write a
transaction that does the following:
-
Assert that the table has no existing documents with a specified
GovId
.
-
Insert the document if the assertion passes.
If a competing transaction concurrently passes the assertion, only one of the transactions
will commit successfully. The other transaction will fail with an OCC conflict exception.
The following code example shows how to implement this uniqueness constraint logic.
- JavaScript
-
const govId = 'TOYENC486FH';
const document = {
'FirstName': 'Brent',
'GovId': 'TOYENC486FH',
};
(async function() {
await qldbDriver.executeLambda(async (txn) => {
// Check if doc with GovId = govId exists
const results = (await txn.execute('SELECT * FROM Person WHERE GovId = ?', govId)).getResultList();
// Insert the document after ensuring it doesn't already exist
if (results.length == 0) {
await txn.execute('INSERT INTO Person ?', document);
}
});
})();
- TypeScript
-
const govId: string = 'TOYENC486FH';
const document: Record<string, string> = {
'FirstName': 'Brent',
'GovId': 'TOYENC486FH',
};
(async function(): Promise<void> {
await qldbDriver.executeLambda(async (txn: TransactionExecutor) => {
// Check if doc with GovId = govId exists
const results: dom.Value[] = (await txn.execute('SELECT * FROM Person WHERE GovId = ?', govId)).getResultList();
// Insert the document after ensuring it doesn't already exist
if (results.length == 0) {
await txn.execute('INSERT INTO Person ?', document);
}
});
})();
In this example, we recommend having an index on the GovId
field
to optimize performance. Without an index on GovId
, statements can
have more latency and can also lead to OCC conflict exceptions or transaction timeouts.
Working with Amazon Ion
The following sections show how to use the Amazon Ion module to process Ion
data.
Importing the Ion module
- JavaScript
-
var ionjs = require('ion-js');
- TypeScript
-
import { dom, dumpBinary, dumpText, load } from "ion-js";
Creating Ion types
The following code example creates an Ion object from Ion text.
- JavaScript
-
const ionText = '{GovId: "TOYENC486FH", FirstName: "Brent"}';
const ionObj = ionjs.load(ionText);
console.log(ionObj.get('GovId')); // prints [String: 'TOYENC486FH']
console.log(ionObj.get('FirstName')); // prints [String: 'Brent']
- TypeScript
-
const ionText: string = '{GovId: "TOYENC486FH", FirstName: "Brent"}';
const ionObj: dom.Value = load(ionText);
console.log(ionObj.get('GovId')); // prints [String: 'TOYENC486FH']
console.log(ionObj.get('FirstName')); // prints [String: 'Brent']
The following code example creates an Ion object from a Node.js dictionary.
- JavaScript
-
const aDict = {
'GovId': 'TOYENC486FH',
'FirstName': 'Brent'
};
const ionObj = ionjs.load(ionjs.dumpBinary(aDict));
console.log(ionObj.get('GovId')); // prints [String: 'TOYENC486FH']
console.log(ionObj.get('FirstName')); // prints [String: 'Brent']
- TypeScript
-
const aDict: Record<string, string> = {
'GovId': 'TOYENC486FH',
'FirstName': 'Brent'
};
const ionObj: dom.Value = load(dumpBinary(aDict));
console.log(ionObj.get('GovId')); // prints [String: 'TOYENC486FH']
console.log(ionObj.get('FirstName')); // prints [String: 'Brent']
Getting an Ion binary
dump
- JavaScript
-
// ionObj is an Ion struct
console.log(ionjs.dumpBinary(ionObj).toString()); // prints 224,1,0,234,238,151,129,131,222,147,135,190,144,133,71,111,118,73,100,137,70,105,114,115,116,78,97,109,101,222,148,138,139,84,79,89,69,78,67,52,56,54,70,72,139,133,66,114,101,110,116
- TypeScript
-
// ionObj is an Ion struct
console.log(dumpBinary(ionObj).toString()); // prints 224,1,0,234,238,151,129,131,222,147,135,190,144,133,71,111,118,73,100,137,70,105,114,115,116,78,97,109,101,222,148,138,139,84,79,89,69,78,67,52,56,54,70,72,139,133,66,114,101,110,116
Getting an Ion text dump
- JavaScript
-
// ionObj is an Ion struct
console.log(ionjs.dumpText(ionObj)); // prints {GovId:"TOYENC486FH",FirstName:"Brent"}
- TypeScript
-
// ionObj is an Ion struct
console.log(dumpText(ionObj)); // prints {GovId:"TOYENC486FH",FirstName:"Brent"}
For more information about Ion, see the Amazon Ion documentation on GitHub. For more code examples of working with
Ion in QLDB, see Working with Amazon Ion data types in Amazon QLDB.