@prsm/arc
v2.2.8
Published
[![NPM version](https://img.shields.io/npm/v/@prsm/arc?color=a1b858&label=)](https://www.npmjs.com/package/@prsm/arc)
Downloads
7
Readme
arc
This is a lightweight, in-memory, optionally persistent, and fully JavaScript-based document database. You can use it with node, in a browser using the localStorage adapter, or as an embedded database solution for your electron app.
Please note that this library is currently under active development and its API may evolve as new features are added. However, it is unlikely that any breaking changes will be introduced.
- Installation
- Quick note before you read on
- API overview
Installation
npm i @prsm/arc
Quick note before you read on
arc runs entirely in-process — not as a port-bound service. @prsm/arc-server
fills this gap.
@prsm/arc-server
https://github.com/node-prism/arc-server
If you'd prefer to run this as a self-contained service within your stack that accepts connections over TCP, authenticates them, and receives and responds to queries in this manner, you may want to use @prsm/arc-server
. It's currently moderately opinionated in the way that it handles authentication (no RBAC or Collection-level permissions), so if it does not meet your expectations, you may consider developing your own service-based solution.
@prsm/arc-client
https://github.com/node-prism/arc-client
If you decide to use @prsm/arc-server
, you most likely also want to use @prsm/arc-client
as a means of simplifying communication with @prsm/arc-server
API overview
For a comprehensive API reference, please refer to the tests in this repository.
Creating a collection
A collection is just a .json
file when you're using the default FSAdapter
.
import { Collection, FSAdapter } from "@prsm/arc";
type Planet = {
planet: {
name: string;
population?: number;
moons: string[];
temp: {
avg: number;
};
composition: {
type: "gas" | "molten" | "ice";
};
};
};
// from `./.data` load or create `planets.json`
const collection = new Collection<Planet>({
adapter: new FSAdapter({ storagePath: ".data", name: "planets" }),
});
Persistence
Storage adapters
The method of data retrieval and storage depends on the StorageAdapter
used by the collection. The default storage adapter is FSAdapter
, which reads and writes data to a file. To achieve persistence in a browser environment, you may use the included LocalStorageAdapter
. Alternatively, you can create a custom adapter by implementing the StorageAdapter
interface. Additionally, an EncryptedFSAdapter
is available that encrypts data before writing and decrypts it before reading.
Using another adapter
import { EncryptedFSAdapter } from "@prsm/arc";
process.env.ARC_ENCFS_KEY = "Mahpsee2X7TKLe1xwJYmar91pCSaZIY7";
new Collection<Planet>({
autosync: false,
adapter: new EncryptedFSAdapter({ storagePath: ".data", name: "planets" }),
});
Auto sync
By default, any operation that modifies data is followed by a synchronization using the adapter with which the collection was initialized. You have the option to disable this autosync
feature during collection creation:
new Collection<Planet>({
autosync: false,
adapter: new FSAdapter({ storagePath: ".data", name: "planets" }),
});
When autosync
is disabled, you must call collection.sync()
to persist, which calls the in-use adapter's write
method.
Indexing
Indexes can be deeply nested properties, e.g.:
createIndex({ key: "planet.composition.type" })
When defining indexes using dot notation, the performance benefit of using indexes is the same whether you choose to find documents by using dot notation syntax or object syntax. In other words, the queries below provide the same performance benefit.
find({ "planet.composition.type": "gas" }); find({ planet: { composition: { type: "gas" } } });
The value of the key must be a type that can be converted to a string using
String(value)
.Indexes can optionally enforce a unique constraint, e.g.:
createIndex({ key: "planet.life.dominant_species", unique: true })
You can create an index at any time, even if your database has existing records with the index key provided, although ideally they are defined at the point of database creation.
In large databases, especially with complex documents, you will see a noticeable performance boost when making practical use of indexes:
In a collection made up of 1,000,000 Planet
documents:
- Without an index on
planet.composition.type
, afind({ "planet.composition.type": "gas" })
takes an average of 2s. - With an index on
planet.composition.type
, afind({ "planet.composition.type": "gas" })
takes an average of 25ms, which is 80x faster.
These numbers were seen while benchmarking on a 2022 M1. YMMV.
Index limitations
You can't combine boolean expressions with indexes, because the result of the expression isn't known until the expression is evaluated, which defeats the purpose of an index entirely. In other words, the following would be true assuming you had an index key defined at "planet.composition.type":
// This bypasses known index records for the key "planet.composition.type",
// because the documents that match the provided expression cannot be known
// until the `$oneOf` expression is evaluated.
find({ "planet.composition.type": { $oneOf: ["gas", "molten"] } });
// Instead, if performance was a concern for this query, you'd be better off
// doing something like this:
const gas = find({ "planet.composition.type": "gas" }); // index hit
const molten = find({ "planet.composition.type": "molten" }); // index hit
Inserting
See the inserting tests for more examples.
insert({
planet: {
name: "Mercury",
moons: [],
temp: { avg: 475 },
composition: {
type: "molten",
},
},
});
insert([
{
planet: {
name: "Venus",
moons: [],
temp: { avg: 737_000 },
composition: {
type: "molten",
},
},
},
{
planet: {
name: "Earth",
population: 8_000_000_000,
moons: ["Luna"],
temp: { avg: 13 },
composition: {
type: "molten",
},
},
},
{
planet: {
name: "Jupiter",
moons: ["Io", "Europa", "Ganymede"],
temp: { avg: -145 },
composition: {
type: "gas",
},
},
},
]);
Finding
arc's query syntax is uncomplicated and, with the many builtin boolean operators, enables the creation of complex yet intelligible queries. These boolean operators, described below, may seem familiar to those who have experience with either MongoDB or NeDB.
See the finding tests for more examples.
Here's a brief overview:
find({ avg: -145 }); // implicit deep searching
find({ planet: { temp: { avg: -145 } } }); // explicit deep searching
find({ "planet.temp.avg": -145 }); // dot notation
find({ avg: { $gt: 12_000 } });
find({ temp: { avg: { $lt: 1_000 } } });
find({ "planet.temp.avg": { $lt: 1_000 } });
find({ $and: [{ avg: { $gt: 100 } }, { avg: { $lt: 10_000 } }] });
find({
$and: [{ $not: { $has: "planet.population" } }, { moons: { $gt: 1 } }],
});
find({ $and: [{ "planet.temp.avg": { $gt: 100 } }, { avg: { $lt: 10_000 } }] });
find({ planet: { name: { $length: { $gt: 7 } } } }); // string length
find({ "planet.moons": { $length: 1 } }); // array length
find({ "planet.composition.type": { $oneOf: ["molten", "gas"] } });
// etc.
find({ $not: { a: 1, b: 2 } });
find({ $not: { "planet.temp.avg": { $gt: 10_000 } } });
find({ $and: [{ $not: { a: { $lte: 2 } } }, { $not: { a: { $gte: 5 } } }] });
find({ $xor: [{ planet: { $includes: "art" } }, { num: { $lt: 9 } }] });
Boolean operators
$and
find({ $and: [{ a: 1 }, { b: 2 }, { c: 3 }, { d: 4 }] });
find({
$and: [
{ "planet.name": { $includes: "Ea" } },
{ population: { $gt: 1_000_000 } },
],
});
find({
$and: [
{ "planet.composition.type": "gas" },
{ planet: { moons: { $length: { $gt: 5 } } } },
],
});
$not
// { a: 1, b: 2 }
// { nested: { a: 1 } }
find({ $not: { a: 1 } }); // Won't return either of the above
find({ $not: { "nested.a": 1 } }); // Returns the first document
// $not is commonly used with other boolean operators, like $and:
// { nested: { a: 1, b: 2 } }
find({
$and: [
{ $not: { "nested.a": 1 } },
{ $not: { "nested.b": 2 } },
],
});
// or $includes:
find({ $not: { moons: { $includes: "Io" } } });
find({ $not: { planet: { moons: { $includes: ["Io", "Ganymede"] } } } });
$or
find({
$or: [
{ planet: { temp: { avg: { $lt: 100 } } } },
{ "planet.temp.avg": { $gt: 5_000 } },
],
});
find({ $or: [{ a: 1 }, { a: 2 }, { a: 3 }, { a: 4 }] });
$xor
find({ $xor: [{ a: 1 }, { b: 2 }] });
find({
$xor: [{ $has: "planet.population" }, { "planet.moons": { $length: 0 } }],
});
$has
$has
returns documents that have the provided properties, and expects property references to be provided in dot notation.
find({ $has: "planet.population" });
find({ $has: ["planet.population", "planet.temp.avg"] }); // documents that have BOTH of these properties
find({ $not: { $has: "planet.temp.avg" } });
find({ $not: { "planet.temp": { $has: "avg" } } });
$hasAny
$hasAny
returns documents that have any of the provided properties, and expects property references to be provided in dot notation.
find({ $hasAny: ["planet.population", "planet.temp.avg"] }); // documents that have EITHER of these properties
find({ planet: { $hasAny: ["population", "temp.avg"] } }); // effectively the same as above
find({ $not: { $hasAny: ["planet.population", "planet.temp.avg"] } });
find({ $not: { "planet.temp": { $hasAny: ["max", "avg"] } } });
$includes
For an "excludes" query, prefix this with $not
.
find({ planet: { moons: { $includes: "Io" } } }); // Array.includes, because planet.moons is an array
find({ planet: { name: { $includes: "Ear" } } }); // String.includes, because planet.name is a string
find({ "planet.moons": { $includes: ["Io", "Europa"] } }); // match when ALL of the provided values are included in the document array
find({ $not: { "planet.moons": { $includes: "Io" } } }); // planets that do not have a moon named "Io"
$length
find({ "planet.name": { $length: 5 } }); // String.length
find({ "planet.moons": { $length: 0 } }); // Array.length
$oneOf
find({ "planet.composition.type": { $oneOf: ["gas", "molten", "rock"] } });
$re
find({ "visitor.ip": { $re: IP_REGEX } });
$fn
const populated = (v) => v > 1_000_000;
const notOverlyPopulated = (v) => v < 2_000_000;
find({ planet: { population: { $fn: populated } } });
find({ planet: { population: { $fn: [populated, notOverlyPopulated] } } });
$gt, $gte, $lt, $lte
When used against a number, does a numeric comparison. When used against a string, does a lexicographical comparison. When used against an array, does an array length comparison.
find({ "planet.temp.avg": { $lt: 500 } }); // numeric comparison
find({ planet: { name: { $gt: "Earth" } } }); // lexicographical comparison
find({ "planet.moons": { $gt: 2 } }); // array length comparison; planets with more than two moons
Updating
Any queries that work with Collection.find
will also work with Collection.update
.
Updating documents involves applying various mutation operators to whichever documents match the provided query, i.e.: update(query, mutations)
.
The following mutation operators are available, and should support most, if not all, use cases:
Mutation operators
$set
// given
// { a: 1, b: { c: 2 } }
update({ a: 1 }, { $set: { a: 2 }}) // -> { a: 2 }
update({ c: 2 }, { $set: { d: 3 }}) // -> { a: 1, b: { c: 2 }, d: 3 }
update({ c: 2 }, { $set: { b: { c: 3 }}) // -> { a: 1, b: { c: 3 } }
update({ c: 2 }, { $set: { "b.c": 3 }}) // -> { a: 1, b: { c: 3 } }
update({ a: 1 }, { $set: { ...someObject }}) // -> { a: 1, ...someObject }
$unset
// given
// { a: 1, b: { c: 2 } }
update({ a: 1 }, { $unset: "b.c" }); // -> { a: 1 }
update({ a: 1 }, { $unset: ["a", "b.c"] }); // -> {}
// given
// { a: 1, b: [{ c: 1, d: 1 }, { c: 2, d: 2 }] }
update({ a: 1 }, { $unset: "b.*.c" }); // -> { a: 1, b: [{ d: 1 }, { d: 2 }] }
$change
Like $set
, but refuses to create new properties.
// given
// { a: 1 }
update({ a: 1 }, { $change: { a: 2 } }); // -> { a: 2 }
update({ a: 1 }, { $change: { b: 2 } }); // -> { a: 1 }, no property created
$push
Push will concat an item or items to an array. It refuses to create the target array if it does not exist.
// given
// { a: 1, b: [1] }
update({ a: 1 }, { $push: { b: 2 } }); // -> { a: 1, b: [1, 2] }
update({ a: 1 }, { $push: { b: [2, 3] } }); // -> { a: 1, b: [1, 2, 3] }
// given
// { a: 1 }
update({ a: 1 }, { $push: { b: 2 } }); // -> { a: 1 }, no property created
// given
// { a: 1, b: { c: [] } }
update({ $has: "b.c" }, { $push: { "b.c": 1 } }); // -> { a: 1, b: { c: [1] } }
$unshift
Unshift will insert new elements to the start of the target array. It refuses to create the target array if it does not exist.
// given
// { a: 1, b: [1] }
update({ a: 1 }, { $unshift: { b: 2 } }); // -> { a: 1, b: [2, 1] }
update({ a: 1 }, { $unshift: { b: [2, 3] } }); // -> { a: 1, b: [2, 3, 1] }
// given
// { a: 1 }
update({ a: 1 }, { $unshift: { b: 2 } }); // -> { a: 1 }, no property created
// given
// { a: 1, b: { c: [] } }
update({ $has: "b.c" }, { $unshift: { "b.c": 1 } }); // -> { a: 1, b: { c: [1] } }
$merge
Merge the provided object into the documents that match the query.
// given
// { a: 1, b: { c: 5 }}
update({ a: 1 }, { $merge: { a: 2, b: { d: 6 } } }); // -> { a: 2, b: { c: 5, d: 6 } }
update({ c: 5 }, { $merge: { a: 2 } }); // -> { a: 1, b: { c: 5, a: 2 }}
update({ c: 5 }, { $merge: { ...someObject } }); // -> { a: 1, b: { c: 5, ...someObject }}
$map
Effectively Array.map
against only the documents that match the query.
// given
// { a: 1 }
// { a: 2 }
update({ a: 1 }, { $map: (doc) => ({ ...doc, d: 1 }) }); // -> { a: 1, d: 1 }, { a: 2 }
$inc, $dev, $mult, $div
// increase population, creating the property if it doesn't exist.
update({ planet: { name: "Earth" } }, { $inc: { planet: { population: 1 } } });
update({ name: "Earth" }, { $inc: { "planet.population": 1 } });
update({ planet: { population: { $gt: 0 } } }, { $inc: 1 });
When one of these operators is given in the format { $inc: 5 }
without a property specified, we implicitly apply the operator to the properties defined in the query that was used to find the document. For example:
update(
{ planet: { name: { $includes: "a" }, $has: "population" } },
{ $inc: 1 }
);
// Implicitly increases the property "planet.population" by 1 if it exists.
// Doesn't try to add `1` to "planet.name" because it is a string.
// Doesn't increase the population of Mars, because it has no "planet.population" property.
update({ a: { $hasAny: ["b", "c"] } }, { $inc: 1 });
// If "a.b" or "a.c" exists, and it is a number, it has the modifier applied to it.
Filtering
There's also a filter
method on the collection for when the provided update operations don't support your use case.
// given
// { a: 1 }
// { a: 2 }
filter((doc) => doc.a > 1); // -> { a: 2 }
Removing
See the remove tests for more examples.
Any queries that work with Collection.find
work with Collection.remove
.
// remove every planet except Earth
remove({ $not: { planet: "Earth" } });
Query options
find
, update
and remove
accept a QueryOptions
object.
When providing query options, the documents are not actually mutated in the database. The aggregation effect that they have is only applied to the returned documents. In other words, the primary function of query options is aggregation.
{
/** When true, attempts to deeply match the query against documents. */
deep: boolean;
/** Provide fallback values for null or undefined properties */
ifNull: Record<string, any>;
/** Provide fallback values for 'empty' properties ([], {}, "") */
ifEmpty: Record<string, any>;
/** Provide fallback values for null, undefined, or 'empty' properties. */
ifNullOrEmpty: Record<string, any>;
/**
* -1 || 0: descending
* 1: ascending
*/
sort: { [property: string]: -1 | 0 | 1 };
/**
* Particularly useful when sorting, `skip` defines the number of documents
* to ignore from the beginning of the result set.
*/
skip: number;
/** Determines the number of documents returned. */
take: number;
/**
* 1: property included in result document
* 0: property excluded from result document
*/
project: {
[property: string]: 0 | 1;
};
aggregate: {
[property: string]:
Record<"$floor", string> |
Record<"$ceil", string> |
Record<"$sub", (string|number)[]> |
Record<"$mult", (string|number)[]> |
Record<"$div", (string|number)[]> |
Record<"$add", (string|number)[]> |
Record<"$fn", (document) => unknown>;
};
join: Array<{
/** The collection to join on. */
collection: Collection<any>;
/** The property containing the foreign key(s). */
from: string;
/** The property on the joining collection that the foreign key should point to. */
on: string;
/** The name of the property to be created while will contain the joined documents. */
as: string;
/** QueryOptions that will be applied to the joined collection. */
options?: QueryOptions;
}>;
}
ifNull
Given an object path or dot notation path, assigns a value to the property at that path, only if that property is null or undefined.
See the ifNull tests for more examples.
// [
// { a: 1, b: 2, c: 3 },
// { a: 1, b: 2, c: 3, d: null },
// ];
find({ a: 1 }, { ifNull: { d: 4 } });
// [
// { a: 1, b: 2, c: 3, d: 4 },
// { a: 1, b: 2, c: 3, d: 4 },
// ];
ifEmpty
Given an object path or dot notation path, assigns a value to the property at that path, only if that property is "empty". "Empty" here means an empty string (""), an empty array ([]) or an empty object ({}).
Does not create properties if they do not already exist.
See the ifEmpty tests for more examples.
// [
// { a: 1, b: 2, c: 3, d: " " },
// { a: 1, b: 2, c: 3, d: [] },
// { a: 1, b: 2, c: 3, d: {} },
// { a: 1, b: 2, c: 3 },
// ];
find({}, { ifEmpty: { d: 4 } });
// [
// { a: 1, b: 2, c: 3, d: 4 },
// { a: 1, b: 2, c: 3, d: 4 },
// { a: 1, b: 2, c: 3, d: 4 },
// { a: 1, b: 2, c: 3 },
// ];
ifNullOrEmpty
See the ifNullOrEmpty tests for more examples.
Sorting
See the sort tests for more examples.
// [
// { name: "Deanna Troi", age: 28 },
// { name: "Worf", age: 24 },
// { name: "Xorf", age: 24 },
// { name: "Zorf", age: 24 },
// { name: "Jean-Luc Picard", age: 59 },
// { name: "William Riker", age: 29 },
// ];
find({ age: { $gt: 1 } }, { sort: { age: 1, name: -1 } });
// └─ asc └─ desc
// [
// { name: "Zorf", age: 24 },
// { name: "Xorf", age: 24 },
// { name: "Worf", age: 24 },
// { name: "Deanna Troi", age: 28 },
// { name: "William Riker", age: 29 },
// { name: "Jean-Luc Picard", age: 59 },
// ];
Skip & take (i.e. LIMIT)
See the skip & take tests for more examples.
Mostly useful when paired with sort
.
// [
// { a: 1, b: 1, c: 1 },
// { a: 2, b: 2, c: 2 },
// { a: 3, b: 3, c: 3 },
// ];
find({}, { skip: 1, take: 1 });
// [
// { a: 2, b: 2, c: 2 },
// ];
Projection
See the projection tests for more examples.
The ID property of a document is always included unless explicitly excluded.
Implicit exclusion
When all projected properties have a value of 1
, this is "implicit exclusion" mode.
In this mode, all document properties that are not defined in the projection are excluded from the result document.
// [
// { a: 1, b: 1, c: 1 },
// ];
find({ a: 1 }, { project: { b: 1 } });
// [
// { b: 1 },
// ];
Implicit inclusion
When all projected properties have a value of 0
, this is "implicit inclusion" mode.
In this mode, all document properties that are not defined in the projection are included from the result document.
// [
// { a: 1, b: 1, c: 1 },
// ];
find({ a: 1 }, { project: { b: 0 } });
// [
// { _id: .., a: 1, c: 1 },
// ];
Explicit
In the only remaining case (a mixture of 1s and 0s), all document properties are included unless explicitly removed with a 0
.
This is effectively the same behavior as implicit inclusion.
// [
// { a: 1, b: 1, c: 1 },
// ];
find({ a: 1 }, { project: { b: 1, c: 0 } });
// [
// { _id: .., a: 1, b: 1 },
// ];
Aggregation
See the project tests for more examples.
You can use the aggregate
object to create intermediate properties derived from other document properties, and then project those intermediate properties out of the result set.
The provided aggregate
helpers are: $add
, $sub
, $mult
, $div
, $floor
, $ceil
and $fn
.
Aggregation happens before projection. This means that you can define as many intermediate properties during the aggregation step as you wish, before ultimately projecting them out of the result documents. In the example below, total
is created and used in subsequent aggregation steps before ultimately being projected out of the result.
// [
// { math: 72, english: 82, science: 92 },
// { math: 60, english: 70, science: 80 },
// { math: 90, english: 72, science: 84 }
// ]
find(
{},
{
aggregate: {
// Create an intermediate property named `total`.
total: { $add: ["math", "english", "science"] },
// Use the intermediate `total` to create an `average` property.
average: { $div: ["total", 3] },
},
// Project out the intermediate `total` property, leaving
// only the original scores and the aggregate `average`.
project: { _id: 0, total: 0 },
}
);
// [
// { math: 72, english: 82, science: 92, average: 82 },
// { math: 60, english: 70, science: 80, average: 70 },
// { math: 90, english: 72, science: 84, average: 82 },
// ]
You can also use dot notation to reference deeply nested properties, e.g.:
find(
{},
aggregate: {
// ...
total: { $add: ["scores.math", "scores.english", "scores.science" ] },
// ...
}
);
Using $fn
, you can provide a function which receives the document and returns some value which is then assigned to the intermediate aggregate property.
find(
{ $has: ["first", "last"] },
{
aggregate: {
// Create an aggregate `fullName` property by defining a function
// that receives the document and returns a string of
// `doc.first` + `doc.last`.
fullName: { $fn: (doc) => `${doc.first} ${doc.last}` },
},
}
);
Joining
See the join.test.ts for more examples.
Joining allows you to join data from other collections.
// "users" collection
// [
// { name: "Alice", purchasedTicketIds: [1, 2] },
// ];
// "tickets" collection
// [
// { _id: 0, seat: "A1" },
// { _id: 1, seat: "B1" },
// { _id: 2, seat: "C1" },
// { _id: 3, seat: "D1" },
// ];
users.find(
{ name: "Alice" },
{
join: [
{
collection: tickets,
from: "purchasedTicketIds",
on: "_id",
as: "tickets",
options: {
project: { _id: 0 },
},
},
],
}
);
// [
// {
// name: "Alice",
// purchasedTicketIds: [1, 2],
// tickets: [
// { seat: "B1" },
// { seat: "C1" },
// ],
// },
// ];
You can also use dot notation when defining the from
or as
fields:
// "inventory" collection
// {
// name: "Bob",
// items: [
// { itemId: 3, quantity: 1 }, <-- we want to join on these `id` properties
// { itemId: 5, quantity: 2 },
// ],
// }
// "items" collection
// [
// { _id: 3, name: "The Unstoppable Force", atk: 100 },
// { _id: 4, name: "Sneakers", agi: 100 },
// { _id: 5, name: "The Immovable Object", def: 100 },
// ]
users.find(
{ name: "Bob" },
{
join: [
{
collection: items,
from: "items.*.itemId",
on: "_id",
as: "items.*.itemData", // creates a new `itemData` property for each item in `from`
options: {
project: { _id: 0, _created_at: 0, _updated_at: 0 },
},
},
],
}
);
// [
// {
// name: "Bob",
// items: [
// { itemId: 3, quantity: 1, itemData: { name: "The Unstoppable Force", atk: 100 } },
// { itemId: 5, quantity: 2, itemData: { name: "The Immovable Object", def: 100 } },
// ],
// }
// ]
join
provides the ability to include options
of type QueryOptions
, which in turn facilitates further joins. In simpler terms, you can nest joins infinitely to achieve more complex hierarchical relationships between collections.
users.find(
{ .. },
{
join: [{
collection: tickets,
options: {
join: [{
collection: seats,
options: {
join: [{
collection: auditoriums,
}]
}
}]
}
}]
}
);
Misc
Builtin property name defaults
The default property names for document ID (default _id
), "created at" (default _created_at
) and "updated at" (default _updated_at
) timestamps can all be changed.
import { ID_KEY, CREATED_AT_KEY, UPDATED_AT_KEY } from "@prsm/arc";
ID_KEY = "id";
CREATED_AT_KEY = "createdAt";
UPDATED_AT_KEY = "updatedAt";
If you do this, make sure to do it at the beginning of collection creation.
Documents
The returned value from find
, update
, filter
and remove
is always an Array<T>
, even when there are no results.