@diegodeg58/cyclic.sh-dynamodb
Version:
SDK for interacting with Cyclic.sh (https://cyclic.sh) app AWS DynamoDB databases
434 lines (378 loc) • 13.2 kB
JavaScript
const { docClient } = require('./ddb_client')
const { UpdateCommand, QueryCommand, DeleteCommand } = require("@aws-sdk/lib-dynamodb")
const { ValidationError, RetryableError, validate_strings } = require('./cy_db_utils')
let make_sub_expr = function (item, expr_type, expr_prefix = '') {
let expression = []
let attr_names = {}
let attr_vals = {}
Object.keys(item).forEach((k, i) => {
if (k == 'pk' || k == 'sk' || item[k] === undefined) { return true }
let v = item[k]
attr_names[`#k${expr_prefix}${i}`] = k
attr_vals[`:v${expr_prefix}${i}`] = v
expression.push(`#k${expr_prefix}${i} = :v${expr_prefix}${i}`)
})
expression = `${expr_type} ${expression.join(', ')}`
return {
attr_names,
attr_vals,
expression
}
}
let upsert = async function (item, opts) {
let ops = []
let { attr_names, attr_vals, expression } = make_sub_expr(item, 'set', 's')
let d = new Date().toISOString()
attr_names['#kc'] = 'created'
attr_names['#ku'] = 'updated'
attr_vals[':kc'] = d
attr_vals[':ku'] = d
expression = `${expression}, #ku = :ku, #kc = if_not_exists(#kc,:kc)`
if (opts['$unset']) {
for (let k of opts.$unset) {
if (Object.keys(item).includes(k)) {
throw `${k}: property can not appear in both set and $unset`
}
}
d_expression = []
opts['$unset'].forEach((k, i) => {
attr_names[`#dk${i}`] = k
d_expression.push(`#dk${i}`)
if (!item.sk.startsWith('fragment#index') && !item.sk.startsWith('index#')) {
ops.push(
docClient.send(new DeleteCommand({
TableName: process.env.CYCLIC_DB,
Key: {
pk: item.pk,
sk: item.sk.startsWith('fragment') ? `fragment#index#${k}` : `index#${k}`
}
}))
)
}
})
expression = `${expression} remove ${d_expression.join(', ')}`
}
var record = {
TableName: process.env.CYCLIC_DB,
Key: {
pk: item.pk,
sk: item.sk || item.pk
},
UpdateExpression: expression,
ExpressionAttributeNames: attr_names,
ExpressionAttributeValues: attr_vals,
ReturnConsumedCapacity: "TOTAL",
ReturnValues: "ALL_OLD",
}
// console.log(record)
if (opts.condition) {
record.Expected = opts.condition
}
ops.push(
docClient.send(new UpdateCommand(record))
)
try {
let res = await Promise.all(ops)
return res
} catch (e) {
if (e.code == 'ConditionalCheckFailedException') {
throw new RetryableError(`${item.pk} ${e.code}`)
}
throw e
}
}
const list_sks = async function (pk, sk_prefix = null) {
let params = {
TableName: process.env.CYCLIC_DB,
ProjectionExpression: 'pk,sk',
KeyConditionExpression: 'pk = :pk',
ExpressionAttributeValues: {
':pk': pk,
}
};
if (sk_prefix) {
params.KeyConditionExpression = `${params.KeyConditionExpression} and begins_with(sk,:sk)`,
params.ExpressionAttributeValues[':sk'] = sk_prefix
}
let res = await docClient.send(new QueryCommand(params))
return res.Items.map(d => {
return d.sk
})
}
const exclude_cy_keys = function (o) {
delete o.pk
delete o.sk
delete o.keys_gsi
delete o.keys_gsi_sk
delete o.gsi_s
delete o.gsi_s_sk
delete o.gsi_1
delete o.gsi_2
delete o.gsi_s2
delete o.gsi_prj
delete o.cy_meta
return o
}
class CyclicItem {
constructor(collection, key, props = {}, opts = {}) {
validate_strings(collection, "Collection Name")
validate_strings(key, "Item Key")
this.collection = collection
this.key = key
this.props = exclude_cy_keys(props)
if (opts.$index) {
this.$index = opts.$index
}
}
static from_dynamo(d) {
if (d.sk.startsWith('fragment')) {
return CyclicItemFragment.from_dynamo(d)
}
let [collection, key] = d.pk.split('#')
let props = { ...d }
let opts = {}
if (d.keys_gsi_sk) {
props.updated = d.keys_gsi_sk
}
if (d.cy_meta && d.cy_meta.$i) {
opts.$index = d.cy_meta.$i
}
return new CyclicItem(collection, key, props, opts)
}
async indexes() {
let indexes = await list_sks(`${this.collection}#${this.key}`, `index#`) //("animals#leo", "index#")
return indexes.map(d => {
return d.split('#').slice(-1)[0]
})
}
async fragments() {
let frags = await list_sks(`${this.collection}#${this.key}`, `fragment#`)
return frags.map(d => {
return d.split('#')[1]
}).filter(d => { return d != 'index' })
}
async delete(props = {}, opts = {}) {
let ops = []
let sks = await list_sks(`${this.collection}#${this.key}`)
sks.forEach(sk => {
ops.push(
docClient.send(new DeleteCommand({
TableName: process.env.CYCLIC_DB,
Key: {
pk: `${this.collection}#${this.key}`,
sk: sk
}
}))
)
})
let res = await Promise.all(ops)
return true
}
async get() {
let pk = `${this.collection}#${this.key}`
let sk = `${this.collection}#${this.key}`
let params = {
TableName: process.env.CYCLIC_DB,
KeyConditionExpression: 'pk = :pk and sk = :sk',
ExpressionAttributeValues: {
':pk': pk,
':sk': sk
}
};
let res = await docClient.send(new QueryCommand(params))
if (!res.Items.length) {
return null
}
return CyclicItem.from_dynamo(res.Items[0])
// this.props = exclude_cy_keys(res.Items[0])
// return this
}
async set(props, opts = {}) {
this.props = { ...this.props, ...props }
let r = {
pk: `${this.collection}#${this.key}`,
sk: `${this.collection}#${this.key}`,
keys_gsi: this.collection,
keys_gsi_sk: new Date().toISOString(),
cy_meta: {
c: this.collection,
rt: 'item',
},
...props
}
let index_records = []
if (opts.$index) {
this.$index = opts.$index
r.cy_meta.$i = opts.$index
opts.$index.forEach(idx => {
let prop_keys = Object.keys(props)
if (!prop_keys.includes(idx)) {
throw new ValidationError(`index property "${idx}" does not exist in object properties ["${prop_keys.join('", "')}"]`)
}
})
index_records = opts.$index.map(idx => {
let index = {
name: idx,
}
let index_item = {
pk: `${this.collection}#${this.key}`,
sk: `index#${index.name}`,
gsi_s: `${index.name}#${this.props[index.name]}`,
gsi_s_sk: `${this.collection}#${this.key}`,
cy_meta: {
c: this.collection,
rt: 'item_index',
$i: this.$index
},
...props
}
return upsert(index_item, opts)
})
}
let res = await Promise.all([
upsert(r, opts),
...index_records
])
return this
}
fragment(type, name = '', props = {}) {
return new CyclicItemFragment(type, name, props, this)
}
}
class CyclicItemFragment {
constructor(type, key, props, parent, opts = {}) {
validate_strings(type, "Fragment Type")
validate_strings(key, "Fragment Key")
this.type = type
this.key = key
this.parent = parent
this.props = exclude_cy_keys(props)
if (opts.$index) {
this.$index = opts.$index
}
}
static from_dynamo(d) {
// console.log(d)
let [parent_collection, parent_key] = d.pk.split('#')
let [type, key, index_name] = d.sk.replace('fragment#', '').replace('index#', '').split('#')
let props = { ...d }
let opts = {}
if (d.keys_gsi_sk) {
props.updated = d.keys_gsi_sk
}
if (d.cy_meta && d.cy_meta.$i) {
opts.$index = d.cy_meta.$i
}
let parent = new CyclicItem(parent_collection, parent_key)
return new CyclicItemFragment(type, key, props, parent, opts)
}
async indexes() {
let indexes = await list_sks(`${this.parent.collection}#${this.parent.key}`, `fragment#index#`)
return indexes.map(d => {
return d.split('#').slice(-1)[0]
})
}
async delete(props = {}, opts = {}) {
let indexes = await this.indexes()
let ops = []
ops.push(docClient.send(new DeleteCommand({
TableName: process.env.CYCLIC_DB,
Key: {
pk: `${this.parent.collection}#${this.parent.key}`,
sk: `fragment#${this.type}#${this.key}`
}
})))
indexes.forEach(idx => {
ops.push(docClient.send(new DeleteCommand({
TableName: process.env.CYCLIC_DB,
Key: {
pk: `${this.parent.collection}#${this.parent.key}`,
sk: `fragment#index#${this.type}#${this.key}#${idx}`,
}
})))
})
await Promise.all(ops)
return true
}
async set(props, opts = {}) {
this.props = { ...this.props, ...props }
let r = {
pk: `${this.parent.collection}#${this.parent.key}`,
sk: `fragment#${this.type}#${this.key}`,
cy_meta: {
c: this.parent.collection,
rt: 'fragment',
},
...props
}
let index_records = []
if (opts.$index) {
this.$index = opts.$index
r.cy_meta.$i = opts.$index
index_records = opts.$index.map(idx => {
let index = {
name: idx,
}
let index_item = {
pk: `${this.parent.collection}#${this.parent.key}`,
sk: `fragment#index#${this.type}#${this.key}#${index.name}`,
gsi_s: `${index.name}#${this.props[index.name]}`,
gsi_s_sk: `${this.parent.collection}#${this.parent.key}`,
cy_meta: {
c: this.parent.collection,
rt: 'fragment_index',
$i: this.$index
},
...props
}
return upsert(index_item, opts)
})
}
let res = await Promise.all([
upsert(r, opts),
...index_records
])
return this
}
async get() {
let pk = `${this.parent.collection}#${this.parent.key}`
let sk = `fragment#${this.type}#${this.key}`
let params = {
TableName: process.env.CYCLIC_DB,
KeyConditionExpression: 'pk = :pk and sk = :sk',
ExpressionAttributeValues: {
':pk': pk,
':sk': sk
}
};
let res = await docClient.send(new QueryCommand(params))
let results = res.Items.map(r => {
return CyclicItemFragment.from_dynamo(r)
})
if (this.key && this.key.length) {
if (results.length) {
return results[0]
} else {
return null
}
}
return results
}
async list() {
let pk = `${this.parent.collection}#${this.parent.key}`
let sk = `fragment#${this.type}#`
let params = {
TableName: process.env.CYCLIC_DB,
KeyConditionExpression: 'pk = :pk and begins_with(sk,:sk)',
ExpressionAttributeValues: {
':pk': pk,
':sk': sk
}
};
let res = await docClient.send(new QueryCommand(params))
return res.Items.map(r => {
return CyclicItemFragment.from_dynamo(r)
})
}
}
module.exports = CyclicItem