updraft
Version:
Javascript ORM-like storage in SQLite (WebSQL or other), synced to the cloud
1,353 lines (1,214 loc) • 43.6 kB
text/typescript
///<reference path="./Column.ts"/>
///<reference path="./Delta.ts"/>
///<reference path="./Database.ts"/>
///<reference path="./Table.ts"/>
///<reference path="./Text.ts"/>
///<reference path="./assign.ts"/>
///<reference path="./verify.ts"/>
namespace Updraft {
function startsWith(str: string, val: string) {
return str.lastIndexOf(val, 0) === 0;
}
function quote(str: string) {
return '"' + str + '"';
}
export type TableSpecAny = TableSpec<any, any, any>;
export type TableAny = Table<any, any, any>;
export interface CreateStoreParams {
db: DbWrapper;
generateGuid?(): string;
}
export interface Schema {
[table: string]: TableSpecAny;
}
interface Resolver<T> {
(param: T): void;
}
interface SqliteMasterRow {
type: string;
name: string;
tbl_name: string;
sql: string;
}
interface BaselineInfo<Element> {
element: Element;
time: number;
rowid: number;
}
interface ChangeTableRow {
key?: KeyType;
time?: number;
change?: string;
source?: string;
syncId?: number;
}
interface SetTableRow {
key?: KeyType;
time?: number;
value?: string;
}
interface KeyValue {
key?: string;
value?: any; // stored as JSON
}
interface KeyValueMap {
[key: string]: any;
}
const MAX_VARIABLES = 999;
const ROWID = "rowid";
const COUNT = "COUNT(*)";
const DEFAULT_SYNCID = 100;
const internal_prefix = "updraft_";
const internal_column_deleted = internal_prefix + "deleted";
const internal_column_time = internal_prefix + "time";
const internal_column_latest = internal_prefix + "latest";
const internal_column_composed = internal_prefix + "composed";
const internal_column_source = internal_prefix + "source";
const internal_column_syncId = internal_prefix + "syncId";
const internalColumn: ColumnSet = {};
internalColumn[internal_column_deleted] = Column.Bool();
internalColumn[internal_column_time] = Column.Int().Key();
internalColumn[internal_column_latest] = Column.Bool();
internalColumn[internal_column_composed] = Column.Bool();
internalColumn[internal_column_source] = Column.String().Index();
internalColumn[internal_column_syncId] = Column.Int().Default(DEFAULT_SYNCID).Index();
const localKey_guid = "guid";
const localKey_syncId = "syncId";
const deleteRow_action = { [internal_column_deleted]: { $set: true } };
const keyValueTableSpec: TableSpec<KeyValue, any, any> = {
name: internal_prefix + "keyValues",
columns: {
key: Column.String().Key(),
value: Column.JSON(),
}
};
const localsTableSpec: TableSpec<KeyValue, any, any> = {
name: internal_prefix + "locals",
columns: {
key: Column.String().Key(),
value: Column.JSON(),
}
};
export class Store {
private params: CreateStoreParams;
private tables: TableSpecAny[];
private db: DbWrapper;
private keyValueTable: Table<KeyValue, any, any>;
private localsTable: Table<KeyValue, any, any>;
private guid: string;
private syncId: number;
private keyValues: KeyValueMap;
constructor(params: CreateStoreParams) {
this.params = params;
this.tables = [];
this.db = null;
verify(this.params.db, "must pass a DbWrapper");
this.localsTable = this.createUntrackedTable<KeyValue, any, any>(localsTableSpec);
this.keyValueTable = this.createTrackedTable<KeyValue, any, any>(keyValueTableSpec, true);
}
public createTable<Element, Delta, Query>(tableSpec: TableSpec<Element, Delta, Query>): Table<Element, Delta, Query> {
return this.createTrackedTable(tableSpec, false);
}
private createTrackedTable<Element, Delta, Query>(tableSpec: TableSpec<Element, Delta, Query>, internal: boolean): Table<Element, Delta, Query> {
verify(!this.db, "createTable() can only be added before open()");
if (!internal) {
verify(!startsWith(tableSpec.name, internal_prefix), "table name %s cannot begin with %s", tableSpec.name, internal_prefix);
}
for (let col in tableSpec.columns) {
verify(!startsWith(col, internal_prefix), "table %s column %s cannot begin with %s", tableSpec.name, col, internal_prefix);
}
let table = this.createTableObject(tableSpec);
this.tables.push(...createInternalTableSpecs(table));
this.tables.push(createChangeTableSpec(table));
return table;
}
private createUntrackedTable<Element, Delta, Query>(tableSpec: TableSpec<Element, Delta, Query>): Table<Element, Delta, Query> {
buildIndices(tableSpec);
let table = this.createTableObject<Element, any, any>(tableSpec);
this.tables.push(tableSpec);
return table;
}
private createTableObject<Element, Delta, Query>(tableSpec: TableSpec<Element, Delta, Query>): Table<Element, Delta, Query> {
let table = new Table<Element, Delta, Query>(tableSpec);
table.add = (...changes: TableChange<Element, Delta>[]): Promise<any> => {
changes.forEach(change => change.table = table);
return this.add(...changes);
};
table.find = (queryArg: Query | Query[], opts?: FindOpts): Promise<Element[] | number> => {
return this.find(table, queryArg, opts);
};
return table;
}
public open(): Promise<any> {
verify(!this.db, "open() called more than once!");
verify(this.tables.length, "open() called before any tables were added");
this.db = this.params.db;
return Promise.resolve()
.then(() => this.readSchema())
.then((schema) => {
return new Promise((resolve, reject) => {
let i = 0;
let act = (transaction: DbTransaction) => {
if (i < this.tables.length) {
let table = this.tables[i];
i++;
this.syncTable(transaction, schema, table, act);
}
else {
this.loadLocals(transaction, () => {
this.loadKeyValues(transaction, () => {
transaction.commit(resolve);
});
});
}
};
this.db.transaction(act, reject);
});
})
;
}
public readSchema(): Promise<Schema> {
verify(this.db, "readSchema(): not opened");
return new Promise((resolve: Resolver<Schema>, reject: DbErrorCallback) => {
this.db.readTransaction((transaction: DbTransaction) => {
return transaction.executeSql("SELECT name, tbl_name, type, sql FROM sqlite_master", [], (tx: DbTransaction, resultSet: any[]) => {
let schema: Schema = {};
for (let i = 0; i < resultSet.length; i++) {
let row = <SqliteMasterRow>resultSet[i];
if (row.name[0] != "_" && !startsWith(row.name, "sqlite")) {
switch (row.type) {
case "table":
schema[row.name] = tableFromSql(row.name, row.sql);
break;
case "index":
let index = indexFromSql(row.sql);
if (index.length == 1) {
let col = index[0];
verify(row.tbl_name in schema, "table %s used by index %s should have been returned first", row.tbl_name, row.name);
verify(col in schema[row.tbl_name].columns, "table %s does not have column %s used by index %s", row.tbl_name, col, row.name);
schema[row.tbl_name].columns[col].isIndex = true;
}
else {
schema[row.tbl_name].indices.push(index);
}
break;
// case "trigger":
// break;
}
}
}
transaction.commit(() => resolve(schema));
});
}, reject);
});
}
private syncTable(transaction: DbTransaction, schema: Schema, spec: TableSpecAny, nextCallback: DbTransactionCallback): void {
if (spec.name in schema) {
let oldColumns = schema[spec.name].columns;
let newColumns = spec.columns;
let recreateTable: boolean = false;
for (let colName in oldColumns) {
if (!(colName in newColumns)) {
recreateTable = true;
break;
}
let oldCol = oldColumns[colName];
let newCol = newColumns[colName];
if (!Column.equal(oldCol, newCol)) {
recreateTable = true;
break;
}
}
let renamedColumns = shallowCopy(spec.renamedColumns) || {};
for (let colName in renamedColumns) {
if (colName in oldColumns) {
recreateTable = true;
}
else {
delete renamedColumns[colName];
}
}
let addedColumns: ColumnSet = {};
if (!recreateTable) {
for (let colName of selectableColumns(spec, newColumns)) {
if (!(colName in oldColumns)) {
addedColumns[colName] = newColumns[colName];
}
}
}
if (recreateTable) {
// recreate and migrate data
let tempTableName = "temp_" + spec.name;
let changeTableName = getChangeTableName(spec.name);
dropTable(transaction, tempTableName, (tx2: DbTransaction) => {
createTable(tx2, tempTableName, spec.columns, (tx3: DbTransaction) => {
copyData(tx3, spec.name, tempTableName, oldColumns, newColumns, renamedColumns, (tx4: DbTransaction) => {
dropTable(tx4, spec.name, (tx5: DbTransaction) => {
renameTable(tx5, tempTableName, spec.name, (tx6: DbTransaction) => {
migrateChangeTable(tx6, changeTableName, oldColumns, newColumns, renamedColumns, (tx7: DbTransaction) => {
createIndices(tx7, schema, spec, true, nextCallback);
});
});
});
});
});
});
}
else if (!isEmpty(addedColumns)) {
// alter table, add columns
let stmts: DbStatement[] = [];
Object.keys(addedColumns).forEach((colName) => {
let col: Column = spec.columns[colName];
let columnDecl = quote(colName) + " " + Column.sql(col);
stmts.push({sql: "ALTER TABLE " + spec.name + " ADD COLUMN " + columnDecl});
});
DbExecuteSequence(transaction, stmts, (tx2: DbTransaction) => {
createIndices(tx2, schema, spec, false, nextCallback);
});
}
else {
// no table modification is required
createIndices(transaction, schema, spec, false, nextCallback);
}
}
else {
// create new table
createTable(transaction, spec.name, spec.columns, (tx2: DbTransaction) => {
createIndices(tx2, schema, spec, true, nextCallback);
});
}
}
private loadLocals(transaction: DbTransaction, nextCallback: DbTransactionCallback): void {
transaction.executeSql("SELECT key, value FROM " + this.localsTable.spec.name, [], (tx2: DbTransaction, rows: KeyValue[]) => {
rows.forEach((row: KeyValue) => {
switch (row.key) {
case localKey_guid:
this.guid = row.value;
break;
case localKey_syncId:
this.syncId = row.value;
break;
/* istanbul ignore next */
default:
verify(false, "unknown key %s in %s", row.key, this.localsTable.spec.name);
}
});
const initGuid = (tx: DbTransaction, next: DbTransactionCallback) => {
if (!this.guid && this.params.generateGuid) {
this.guid = this.params.generateGuid();
this.saveLocal(tx, localKey_guid, this.guid, next);
}
else {
next(tx);
}
};
const initSyncId = (tx: DbTransaction, next: DbTransactionCallback) => {
if (!this.syncId) {
this.syncId = DEFAULT_SYNCID;
this.saveLocal(tx, localKey_syncId, this.syncId, next);
}
else {
next(tx);
}
};
initGuid(tx2, (tx3) => {
initSyncId(tx3, nextCallback);
});
});
}
private saveLocal(transaction: DbTransaction, key: string, value: any, nextCallback: DbTransactionCallback): void {
let sql: string = "INSERT INTO " + this.localsTable.spec.name + " (key, value) VALUES (?, ?)";
transaction.executeSql(sql, [key, value], nextCallback);
}
private loadKeyValues(transaction: DbTransaction, nextCallback: DbTransactionCallback): void {
return runQuery(transaction, this.keyValueTable, {}, undefined, undefined, (tx2: DbTransaction, rows: KeyValue[]) => {
this.keyValues = {};
rows.forEach((row: KeyValue) => {
this.keyValues[row.key] = row.value;
});
nextCallback(tx2);
});
}
public getValue(key: string): any {
return this.keyValues[key];
}
public setValue(key: string, value: any): Promise<any> {
this.keyValues[key] = value;
return this.keyValueTable.add({create: {key, value}});
}
public add(...changes: TableChange<any, any>[]): Promise<any> {
return this.addFromSource(changes, null);
}
public addFromSource(changes: TableChange<any, any>[], source: string): Promise<any> {
verify(this.db, "addFromSource(): not opened");
interface ResolveKey {
table: TableAny;
key: KeyType;
}
interface TableKeySet {
table: TableAny;
keysArray: Set<KeyType>[];
allKeys: Set<KeyType>;
duplicateKeys: Set<KeyType>;
existingKeys: Set<KeyType>;
}
return new Promise((promiseResolve, reject) => {
const syncId = this.syncId;
verify(syncId, "invalid syncId");
const tableKeySet: TableKeySet[] = [];
changes.forEach(change => {
if (change.create) {
const key = change.table.keyValue(change.create);
let keys: Set<KeyType> = null;
let duplicateKeys: Set<KeyType> = null;
let allKeys: Set<KeyType> = null;
for (let j = 0; j < tableKeySet.length; j++) {
/* istanbul ignore else */
if (tableKeySet[j].table === change.table) {
duplicateKeys = tableKeySet[j].duplicateKeys;
allKeys = tableKeySet[j].allKeys;
for (let k = 0; k < tableKeySet[j].keysArray.length; k++) {
let kk = tableKeySet[j].keysArray[k];
if (kk.size < MAX_VARIABLES) {
keys = kk;
break;
}
}
if (!keys) {
keys = new Set<KeyType>();
tableKeySet[j].keysArray.push(keys);
}
break;
}
}
if (keys == null) {
keys = new Set<KeyType>();
duplicateKeys = new Set<KeyType>();
allKeys = new Set<KeyType>();
tableKeySet.push({ table: change.table, keysArray: [keys], allKeys, duplicateKeys, existingKeys: new Set<KeyType>() });
}
if (allKeys.has(key)) {
duplicateKeys.add(key);
}
allKeys.add(key);
keys.add(key);
}
});
let findIdx = 0;
let findBatchIdx = 0;
let changeIdx = 0;
let toResolve = new Set<ResolveKey>();
let findExistingIds: DbTransactionCallback = null;
let insertNextChange: DbTransactionCallback = null;
let resolveChanges: DbTransactionCallback = null;
findExistingIds = (transaction: DbTransaction) => {
if (findIdx < tableKeySet.length) {
const table = tableKeySet[findIdx].table;
const keysArray = tableKeySet[findIdx].keysArray;
const duplicateKeys = tableKeySet[findIdx].duplicateKeys;
const existingKeys = tableKeySet[findIdx].existingKeys;
const notDuplicatedValues: KeyValue[] = [];
keysArray[findBatchIdx].forEach(key => {
if (!duplicateKeys.has(key)) {
notDuplicatedValues.push(key);
}
});
const query: any = { [table.key]: { $in: notDuplicatedValues } };
const opts: FindOpts = { fields: { [table.key]: true } };
runQuery(transaction, table, query, opts, null, (tx: DbTransaction, rows: any[]) => {
for (let row of rows) {
existingKeys.add(row[table.key]);
}
findBatchIdx++;
if (findBatchIdx >= keysArray.length) {
findIdx++;
findBatchIdx = 0;
}
findExistingIds(transaction);
});
}
else {
insertNextChange(transaction);
}
};
insertNextChange = (transaction: DbTransaction) => {
if (changeIdx < changes.length) {
let change = changes[changeIdx];
changeIdx++;
const table = change.table;
verify(table, "change must specify table");
let changeTable = getChangeTableName(table.spec.name);
let time = change.time || Date.now();
verify((change.create ? 1 : 0) + (change.update ? 1 : 0) + (change.delete ? 1 : 0) === 1, "change (%s) must specify exactly one action at a time", change);
let existingKeys: Set<KeyType> = null;
tableKeySet.some((tk): boolean => {
/* istanbul ignore else */
if (tk.table === table) {
existingKeys = tk.existingKeys;
return true;
}
else {
return false;
}
});
if (change.create) {
// append internal column values
let element = assign(
{},
change.create,
{ [internal_column_time]: time },
{ [internal_column_source]: source },
{ [internal_column_syncId]: syncId }
);
const key = table.keyValue(element);
// optimization: don't resolve elements that aren't already in the db- just mark them as latest
if (existingKeys.has(key)) {
toResolve.add({ table, key });
}
else {
element[internal_column_latest] = true;
}
insertElement(transaction, table, element, insertNextChange);
}
if (change.update || change.delete) {
let changeRow: ChangeTableRow = {
key: null,
time: time,
change: null,
source: source,
syncId: syncId
};
if (change.update) {
// store deltas
let delta = shallowCopy(change.update);
changeRow.key = table.keyValue(delta);
delete delta[table.key];
changeRow.change = serializeDelta(delta, table.spec);
}
else {
// mark deleted
changeRow.key = change.delete;
changeRow.change = serializeDelta(deleteRow_action, table.spec);
}
// insert into delta table
let columns = Object.keys(changeRow);
let values: any[] = columns.map(k => changeRow[k]);
toResolve.add({table, key: changeRow.key});
insert(transaction, changeTable, columns, values, insertNextChange);
}
/* istanbul ignore next */
if (!change.create && !change.update && !change.delete) {
throw new Error("no operation specified for delta- should be one of create, update, or delete");
}
}
else {
resolveChanges(transaction);
}
};
resolveChanges = (transaction: DbTransaction) => {
let j = 0;
let toResolveArray: ResolveKey[] = [];
toResolve.forEach((keyValue: ResolveKey) => toResolveArray.push(keyValue));
let resolveNextChange = (tx2: DbTransaction) => {
if (j < toResolveArray.length) {
let keyValue = toResolveArray[j];
j++;
resolve(tx2, keyValue.table, keyValue.key, resolveNextChange);
}
else {
tx2.commit(promiseResolve);
}
};
resolveNextChange(transaction);
};
this.db.transaction(findExistingIds, reject);
});
}
public find<Element, Query>(table: Table<Element, any, Query>, queryArg: Query | Query[], opts?: FindOpts): Promise<Element[] | number> {
return new Promise((resolve: Resolver<Element[] | number>, reject: DbErrorCallback) => {
this.db.readTransaction((transaction: DbTransaction) => {
let queries: Query[] = Array.isArray(queryArg) ? queryArg : [queryArg];
let qs = queries.map(query =>
assign({}, query, {
[internal_column_deleted]: false,
[internal_column_latest]: true,
})
);
runQuery(transaction, table, qs, opts, table.spec.clazz, (tx2: DbTransaction, results: Element[] | number) => {
tx2.commit(() => resolve(results));
});
}, reject);
});
}
}
function getChangeTableName(name: string): string {
return internal_prefix + "changes_" + name;
}
function getSetTableName(tableName: string, col: string): string {
return internal_prefix + "set_" + tableName + "_" + col;
}
function buildIndices(spec: TableSpecAny) {
spec.indices = shallowCopy(spec.indices) || [];
for (let col in spec.columns) {
if (spec.columns[col].isIndex) {
spec.indices.push([col]);
}
}
}
function createInternalTableSpecs(table: Table<any, any, any>): TableSpecAny[] {
let newSpec = shallowCopy(table.spec);
newSpec.columns = shallowCopy(table.spec.columns);
for (let col in internalColumn) {
verify(!table.spec.columns[col], "table %s cannot have reserved column name %s", table.spec.name, col);
newSpec.columns[col] = internalColumn[col];
}
buildIndices(newSpec);
return [newSpec, ...createSetTableSpecs(newSpec, verifyGetValue(newSpec.columns, table.key))];
}
function createChangeTableSpec(table: Table<any, any, any>): TableSpecAny {
let newSpec = <TableSpecAny>{
name: getChangeTableName(table.spec.name),
columns: {
key: Column.Int().Key(),
time: Column.DateTime().Key(),
change: Column.JSON(),
source: Column.String().Index(),
syncId: Column.Int().Default(DEFAULT_SYNCID).Index(),
}
};
buildIndices(newSpec);
return newSpec;
}
function createSetTableSpecs(spec: TableSpecAny, keyColumn: Column): TableSpecAny[] {
let newSpecs: TableSpecAny[] = [];
for (let col in spec.columns) {
let column = spec.columns[col];
if (column.type == ColumnType.set) {
let newSpec = <TableSpecAny>{
name: getSetTableName(spec.name, col),
columns: {
key: keyColumn,
value: new Column(column.element.type).Key(),
time: Column.Int().Key()
}
};
buildIndices(newSpec);
newSpecs.push(newSpec);
}
}
return newSpecs;
}
function tableFromSql(name: string, sql: string): TableSpecAny {
let table = <TableSpecAny>{ name: name, columns: {}, indices: [], triggers: {} };
let matches = sql.match(/\((.*)\)/);
/* istanbul ignore else */
if (matches) {
let pksplit: string[] = matches[1].split(/PRIMARY KEY/i);
let fields = pksplit[0].split(",");
for (let i = 0; i < fields.length; i++) {
verify(!fields[i].match(/^\s*(primary|foreign)\s+key/i), "unexpected column modifier (primary or foreign key) on %s", fields[i]);
let quotedName = /"(.+)"\s+(.*)/;
let unquotedName = /(\w+)\s+(.*)/;
let parts = fields[i].match(quotedName);
/* istanbul ignore else */
if (!parts) {
parts = fields[i].match(unquotedName);
}
if (parts) {
table.columns[parts[1]] = Column.fromSql(parts[2]);
}
}
/* istanbul ignore else */
if (pksplit.length > 1) {
let pk = pksplit[1].match(/\((.*)\)/);
/* istanbul ignore else */
if (pk) {
let keys = pk[1].split(",");
for (let i = 0; i < keys.length; i++) {
let key = keys[i].trim();
table.columns[key].isKey = true;
}
}
}
}
return table;
}
function indexFromSql(sql: string): string[] {
let regex = /\((.*)\)/;
let matches = regex.exec(sql);
verify(matches, "bad format on index- couldn't determine column names from sql: %s", sql);
return matches[1].split(",").map((x: string) => x.trim());
}
function createTable(transaction: DbTransaction, name: string, columns: ColumnSet, nextCallback: DbTransactionCallback): void {
let cols: string[] = [];
let pk: string[] = [];
for (let col in columns) {
let attrs: Column = columns[col];
let decl: string;
switch (attrs.type) {
case ColumnType.set:
// ignore this column; values go into a separate table
verify(!attrs.isKey, "table %s cannot have a key on set column %s", name, col);
break;
default:
decl = quote(col) + " " + Column.sql(attrs);
cols.push(decl);
if (attrs.isKey) {
pk.push(col);
}
break;
}
}
verify(pk.length, "table %s has no keys", name);
cols.push("PRIMARY KEY(" + pk.join(", ") + ")");
transaction.executeSql("CREATE TABLE " + name + " (" + cols.join(", ") + ")", [], nextCallback);
}
function renameTable(transaction: DbTransaction, oldName: string, newName: string, nextCallback: DbTransactionCallback): void {
transaction.executeSql("ALTER TABLE " + oldName + " RENAME TO " + newName, [], nextCallback);
}
function dropTable(transaction: DbTransaction, name: string, nextCallback: DbTransactionCallback): void {
transaction.executeSql("DROP TABLE IF EXISTS " + name, [], nextCallback);
}
function createIndices(transaction: DbTransaction, schema: Schema, spec: TableSpecAny, force: boolean, nextCallback: DbTransactionCallback): void {
let indicesEqual = function(a: string[], b: string[]) {
if (a.length != b.length) {
return false;
}
for (let i = 0; i < a.length; i++) {
if (a[i] != b[i]) {
return false;
}
}
return true;
};
let oldIndices = (spec.name in schema) ? schema[spec.name].indices : [];
let newIndices = spec.indices;
let getIndexName = function(indices: string[]): string {
return "index_" + spec.name + "__" + indices.join("_");
};
let stmts: DbStatement[] = [];
oldIndices.forEach((value: string[], i: number) => {
let drop = true;
for (let j = 0; j < newIndices.length; j++) {
if (indicesEqual(oldIndices[i], newIndices[j])) {
drop = false;
break;
}
}
if (drop) {
stmts.push({ sql: "DROP INDEX IF EXISTS " + getIndexName(oldIndices[i]) });
}
});
newIndices.forEach((value: string[], j: number) => {
let create = true;
for (let i = 0; i < oldIndices.length; i++) {
if (indicesEqual(oldIndices[i], newIndices[j])) {
create = false;
break;
}
}
if (create || force) {
let index = newIndices[j];
stmts.push({ sql: "CREATE INDEX IF NOT EXISTS " + getIndexName(index) + " ON " + spec.name + " (" + index.join(", ") + ")" });
}
});
DbExecuteSequence(transaction, stmts, nextCallback);
}
function copyData(transaction: DbTransaction, oldName: string, newName: string, oldColumns: ColumnSet, newColumns: ColumnSet, renamedColumns: RenamedColumnSet, nextCallback: DbTransactionCallback): void {
let oldTableColumns = Object.keys(oldColumns).filter(col => (col in newColumns) || (col in renamedColumns));
let newTableColumns = oldTableColumns.map(col => (col in renamedColumns) ? renamedColumns[col] : col);
/* istanbul ignore else */
if (oldTableColumns.length && newTableColumns.length) {
let stmt = "INSERT INTO " + newName + " (" + newTableColumns.map(quote).join(", ") + ") ";
stmt += "SELECT " + oldTableColumns.map(quote).join(", ") + " FROM " + oldName + ";";
transaction.executeSql(stmt, [], nextCallback);
}
else {
nextCallback(transaction);
}
}
function migrateChangeTable(transaction: DbTransaction, changeTableName: string, oldColumns: ColumnSet, newColumns: ColumnSet, renamedColumns: RenamedColumnSet, nextCallback: DbTransactionCallback): void {
let deletedColumns = Object.keys(oldColumns).filter(col => !(col in newColumns) && !(col in renamedColumns));
/* istanbul ignore else */
if (!isEmpty(renamedColumns) || deletedColumns) {
transaction.each(
"SELECT " + ROWID + ", change"
+ " FROM " + changeTableName,
[],
(selectChangeTransaction: DbTransaction, row: any) => {
let change = fromText(row.change);
let changed = false;
for (let oldCol in renamedColumns) {
let newCol = renamedColumns[oldCol];
if (oldCol in change) {
change[newCol] = change[oldCol];
delete change[oldCol];
changed = true;
}
}
for (let oldCol of deletedColumns) {
if (oldCol in change) {
delete change[oldCol];
changed = true;
}
}
if (changed) {
if (!isEmpty(change)) {
selectChangeTransaction.executeSql(
"UPDATE " + changeTableName
+ " SET change=?"
+ " WHERE " + ROWID + "=?",
[toText(change), row[ROWID]],
() => {}
);
}
else {
selectChangeTransaction.executeSql(
"DELETE FROM " + changeTableName
+ " WHERE " + ROWID + "=?",
[row[ROWID]],
() => {}
);
}
}
},
nextCallback
);
}
}
function verifyGetValue(element: any, field: string | number): any {
verify(field in element, "element does not contain field %s: %s", field, element);
return element[field];
}
function insert(transaction: DbTransaction, tableName: string, columns: string[], values: any[], nextCallback: DbTransactionCallback): void {
let questionMarks = values.map(v => "?");
verify(columns.indexOf(ROWID) == -1, "should not insert with rowid column");
transaction.executeSql("INSERT OR REPLACE INTO " + tableName + " (" + columns.join(", ") + ") VALUES (" + questionMarks.join(", ") + ")", values, nextCallback);
}
function insertElement<Element>(transaction: DbTransaction, table: Table<Element, any, any>, element: Element, nextCallback: DbTransactionCallback): void {
let keyValue = table.keyValue(element);
let columns = selectableColumns(table.spec, element);
let values: any[] = columns.map(col => serializeValue(table.spec, col, element[col]));
let time = verifyGetValue(element, internal_column_time);
insert(transaction, table.spec.name, columns, values, (tx2: DbTransaction) => {
// insert set values
let stmts: DbStatement[] = [];
Object.keys(table.spec.columns).forEach(function insertElementEachColumn(col: string) {
let column = table.spec.columns[col];
if (column.type == ColumnType.set && (col in element)) {
let set: Set<any> = element[col];
if (set.size) {
set.forEach((value: any) => {
stmts.push({
sql: "INSERT INTO " + getSetTableName(table.spec.name, col)
+ " (time, key, value)"
+ " VALUES (?, ?, ?)",
params: [time, table.keyValue(element), column.element.serialize(value)]
});
});
}
}
});
DbExecuteSequence(tx2, stmts, nextCallback);
});
}
function resolve<Element>(transaction: DbTransaction, table: Table<Element, any, any>, keyValue: KeyType, nextCallback: DbTransactionCallback): void {
selectBaseline(transaction, table, keyValue, (tx2: DbTransaction, baseline: BaselineInfo<Element>) => {
getChanges(tx2, table, baseline, (tx3: DbTransaction, changes: ChangeTableRow[]) => {
let deltaResult = applyChanges(baseline, changes, table.spec);
let promises: Promise<any>[] = [];
if (!deltaResult.isChanged) {
// mark it as latest (and others as not)
setLatest(tx3, table, keyValue, baseline.rowid, nextCallback);
}
else {
// invalidate old latest rows
// insert new latest row
let element = update(deltaResult.element, {
[internal_column_latest]: {$set: true},
[internal_column_time]: {$set: deltaResult.time},
[internal_column_composed]: {$set: true},
});
invalidateLatest(tx3, table, keyValue, (tx4: DbTransaction) => {
insertElement(tx4, table, element, nextCallback);
});
}
});
});
}
function runQuery<Element, Query>(transaction: DbTransaction, table: Table<Element, any, Query>, queryArg: Query | Query[], opts: FindOpts, clazz: new (props: Element) => Element, resultCallback: DbCallback<number | Element[]>): void {
opts = opts || {};
let conditionSets: string[][] = [];
let values: (string | number)[] = [];
const queries: Query[] = Array.isArray(queryArg) ? queryArg : [queryArg];
queries.forEach(query => {
let conditions: string[] = [];
Object.keys(query).forEach((col: string) => {
verify((col in table.spec.columns) || (col in internalColumn), "attempting to query based on column '%s' not in schema (%s)", col, table.spec.columns);
let column: Column = (col in internalColumn) ? internalColumn[col] : table.spec.columns[col];
let spec = query[col];
let found = false;
switch (column.type) {
case ColumnType.int:
case ColumnType.real:
case ColumnType.enum:
case ColumnType.date:
case ColumnType.datetime:
const comparisons = {
$gt: ">",
$gte: ">=",
$lt: "<",
$lte: "<=",
$ne: "!="
};
for (let condition in comparisons) {
if (hasOwnProperty.call(spec, condition)) {
conditions.push("(" + col + comparisons[condition] + "?)");
let value = column.serialize(spec[condition]);
verify(Object(value) !== value, "condition %s must have a numeric-ish argument; got %s instead", condition, value);
values.push(value);
found = true;
}
}
break;
case ColumnType.text:
const operations = {
$like: (value: string) => {
conditions.push("(" + col + " LIKE ? ESCAPE '\\')");
values.push(value);
found = true;
},
$notLike: (value: string) => {
conditions.push("(" + col + " NOT LIKE ? ESCAPE '\\')");
values.push(value);
found = true;
}
};
for (let condition in operations) {
if (hasOwnProperty.call(spec, condition)) {
operations[condition](spec[condition]);
}
}
break;
case ColumnType.bool:
conditions.push(col + (spec ? "!=0" : "=0"));
found = true;
break;
case ColumnType.set:
let existsSetValues = function(setValues: any[], args: (string | number)[]): string {
let escapedValues = setValues.map(value => column.element.serialize(value));
args.push(...escapedValues);
return "EXISTS ("
+ "SELECT 1 FROM " + getSetTableName(table.spec.name, col)
+ " WHERE value IN (" + setValues.map(x => "?").join(", ") + ")"
+ " AND key=" + table.spec.name + "." + table.key
+ " AND time=" + table.spec.name + "." + internal_column_time
+ ")";
};
let setConditions = {
$has: (hasValue: any) => {
verify(!Array.isArray(hasValue), "must not be an array: %s", hasValue);
let condition = existsSetValues([hasValue], values);
conditions.push(condition);
},
$hasAny: (hasAnyValues: any[]) => {
verify(Array.isArray(hasAnyValues), "must be an array: %s", hasAnyValues);
let condition = existsSetValues(hasAnyValues, values);
conditions.push(condition);
},
$hasAll: (hasAllValues: any[]) => {
verify(Array.isArray(hasAllValues), "must be an array: %s", hasAllValues);
for (let hasValue of hasAllValues) {
let condition = existsSetValues([hasValue], values);
conditions.push(condition);
}
}
};
for (let condition in setConditions) {
if (hasOwnProperty.call(spec, condition)) {
let value = spec[condition];
setConditions[condition](value);
found = true;
break;
}
}
break;
}
if (!found) {
const inCondition = keyOf({ $in: false });
if (hasOwnProperty.call(spec, inCondition)) {
verify(Array.isArray(spec[inCondition]), "must be an array: %s", spec[inCondition]);
conditions.push(col + " IN (" + spec[inCondition].map((x: any) => "?").join(", ") + ")");
let inValues: any[] = spec[inCondition];
inValues = inValues.map(val => column.serialize(val));
values.push(...inValues);
found = true;
}
}
if (!found) {
/* istanbul ignore else */
if (typeof spec === "number" || typeof spec === "string") {
conditions.push(col + "=?");
values.push(spec);
found = true;
}
}
verify(found, "unknown query condition for %s: %s", col, spec);
});
if (conditions.length) {
conditionSets.push(conditions);
}
});
let fields: FieldSpec = assign({}, opts.fields || table.spec.columns, {[internal_column_time]: true});
let columns: string[] = selectableColumns(table.spec, fields);
let stmt = "SELECT " + (opts.count ? COUNT : columns.map(quote).join(", "));
stmt += " FROM " + table.spec.name;
if (conditionSets.length) {
stmt += " WHERE " + conditionSets.map(conditions => "(" + conditions.join(" AND ") + ")").join(" OR ");
}
if (opts.orderBy) {
let col = keyOf(opts.orderBy);
let order = opts.orderBy[col];
stmt += " ORDER BY " + col + " " + (order == OrderBy.ASC ? "ASC" : "DESC");
}
if (opts.limit) {
stmt += " LIMIT " + opts.limit;
}
if (opts.offset) {
stmt += " OFFSET " + opts.offset;
}
transaction.executeSql(stmt, values, (tx2: DbTransaction, rows: any[]) => {
if (opts.count) {
let count = parseInt(rows[0][COUNT], 10);
resultCallback(transaction, count);
}
else {
loadAllExternals(transaction, rows, table, opts.fields, (tx3: DbTransaction) => {
let results: Element[] = [];
for (let i = 0; i < rows.length; i++) {
let row = deserializeRow<Element>(table.spec, rows[i]);
for (let col in internalColumn) {
if (!opts.fields || !(col in opts.fields)) {
delete row[col];
}
}
let obj = clazz ? new clazz(row) : row;
results.push(obj);
}
resultCallback(tx3, results);
});
}
});
}
function popValue<Element>(element: Element, field: string) {
let ret = verifyGetValue(element, field);
delete element[field];
return ret;
}
function selectBaseline<Element, Query>(transaction: DbTransaction, table: Table<Element, any, any>, keyValue: KeyType, resultCallback: DbCallback<BaselineInfo<Element>>): void {
let fieldSpec = <FieldSpec>{
[ROWID]: true,
[internal_column_time]: true,
[internal_column_deleted]: true,
};
Object.keys(table.spec.columns).forEach(col => fieldSpec[col] = true);
let query = <Query>{
[table.key]: keyValue,
[internal_column_composed]: false
};
let opts = <FindOpts>{
fields: fieldSpec,
orderBy: { [internal_column_time]: OrderBy.DESC },
limit: 1
};
runQuery(transaction, table, query, opts, null, (tx2: DbTransaction, baselineResults: any[]) => {
let baseline: BaselineInfo<Element> = {
element: <Element>{},
time: 0,
rowid: -1
};
if (baselineResults.length) {
let element = <Element>baselineResults[0];
baseline.element = element;
baseline.time = popValue(element, internal_column_time);
baseline.rowid = popValue(element, ROWID);
}
else {
baseline.element[table.key] = keyValue;
}
resultCallback(tx2, baseline);
});
}
function loadAllExternals<Element>(transaction: DbTransaction, elements: Element[], table: Table<Element, any, any>, fields: FieldSpec, nextCallback: DbTransactionCallback) {
let i = 0;
let loadNextElement = (tx2: DbTransaction) => {
if (i < elements.length) {
let element = elements[i];
i++;
loadExternals(tx2, table, element, fields, loadNextElement);
}
else {
nextCallback(tx2);
}
};
loadNextElement(transaction);
};
function loadExternals<Element>(transaction: DbTransaction, table: Table<Element, any, any>, element: any, fields: FieldSpec, nextCallback: DbTransactionCallback) {
let cols: string[] = Object.keys(table.spec.columns).filter(col => !fields || (col in fields && fields[col]));
let i = 0;
let loadNextField = (tx2: DbTransaction) => {
if (i < cols.length) {
let col: string = cols[i];
i++;
let column = table.spec.columns[col];
if (column.type == ColumnType.set) {
let set: Set<any> = element[col] = element[col] || new Set<any>();
let keyValue = verifyGetValue(element, table.key);
let time = verifyGetValue(element, internal_column_time);
let p = tx2.executeSql(
"SELECT value "
+ "FROM " + getSetTableName(table.spec.name, col)
+ " WHERE key=?"
+ " AND time=?",
[keyValue, time],
(tx: DbTransaction, results: SetTableRow[]) => {
for (let row of results) {
set.add(column.element.deserialize(row.value));
}
loadNextField(tx2);
}
);
} else {
loadNextField(tx2);
}
}
else {
nextCallback(tx2);
}
};
loadNextField(transaction);
}
function getChanges<Element>(transaction: DbTransaction, table: Table<Element, any, any>, baseline: BaselineInfo<Element>, resultCallback: DbCallback<ChangeTableRow[]>): void {
let keyValue = verifyGetValue(baseline.element, table.key);
transaction.executeSql(
"SELECT key, time, change"
+ " FROM " + getChangeTableName(table.spec.name)
+ " WHERE key=? AND time>=?"
+ " ORDER BY time ASC",
[keyValue, baseline.time],
resultCallback);
}
interface DeltaResult<Element> {
element: Element;
time: number;
isChanged: boolean;
}
function applyChanges<Element, Delta>(baseline: BaselineInfo<Element>, changes: ChangeTableRow[], spec: TableSpecAny): DeltaResult<Element> {
let element: Element = baseline.element;
let time = baseline.time;
for (let i = 0; i < changes.length; i++) {
let row = changes[i];
let delta = <Delta>deserializeDelta(row.change, spec);
element = update(element, delta);
time = Math.max(time, row.time);
}
let isChanged = (baseline.element !== element) || baseline.rowid == -1;
return { element, time, isChanged };
}
function setLatest<Element>(transaction: DbTransaction, table: Table<Element, any, any>, keyValue: KeyType, rowid: number, nextCallback: DbTransactionCallback): void {
transaction.executeSql(
"UPDATE " + table.spec.name
+ " SET " + internal_column_latest + "=(" + ROWID + "=" + rowid + ")"
+ " WHERE " + table.key + "=?",
[keyValue],
nextCallback);
}
function invalidateLatest<Element>(transaction: DbTransaction, table: Table<Element, any, any>, keyValue: KeyType, nextCallback: DbTransactionCallback): void {
transaction.executeSql(
"UPDATE " + table.spec.name
+ " SET " + internal_column_latest + "=0"
+ " WHERE " + table.key + "=?",
[keyValue],
nextCallback);
}
function selectableColumns(spec: TableSpecAny, cols: { [key: string]: any }): string[] {
return Object.keys(cols).filter(col => (col == ROWID) || (col in internalColumn) || ((col in spec.columns) && (spec.columns[col].type != ColumnType.set)));
}
function serializeValue(spec: TableSpecAny, col: string, value: any): Serializable {
if (col in spec.columns) {
let x = spec.columns[col].serialize(value);
return x;
}
return value;
}
function deserializeValue(spec: TableSpecAny, col: string, value: any) {
if (col in spec.columns) {
value = spec.columns[col].deserialize(value);
}
return value;
}
let setKey = keyOf({ $set: false });
function serializeDelta<Delta>(change: Delta, spec: TableSpec<any, Delta, any>): string {
for (let col in change) {
let val = change[col];
if (hasOwnProperty.call(val, setKey)) {
change[col] = shallowCopy(change[col]);
change[col][setKey] = serializeValue(spec, col, change[col][setKey]);
}
}
return toText(change);
}
function deserializeDelta<Delta>(text: string, spec: TableSpec<any, Delta, any>): Delta {
let delta = fromText(text);
for (let col in delta) {
let val = delta[col];
if (hasOwnProperty.call(val, setKey)) {
delta[col][setKey] = deserializeValue(spec, col, delta[col][setKey]);
}
}
return delta;
}
function deserializeRow<T>(spec: TableSpecAny, row: any[]): T {
let ret: T = <any>{};
for (let col in row) {
let src = row[col];
if (src != null) {
ret[col] = deserializeValue(spec, col, src);
}
}
return ret;
}
function isEmpty(obj: any): boolean {
for (let field in obj) {
return false;
}
return true;
}
export function createStore(params: CreateStoreParams): Store {
return new Store(params);
}
/* istanbul ignore next */
export function makeCreate<Element>(table: Updraft.Table<Element, any, any>, time: number) {
return (create: Element): Updraft.TableChange<Element, any> => ({
table,
time,
create
});
}
/* istanbul ignore next */
export function makeUpdate<Delta>(table: Updraft.Table<any, Delta, any>, time: number) {
return (update: Delta): Updraft.TableChange<Element, any> => ({
table,
time,
update
});
}
/* istanbul ignore next */
export function makeDelete(table: Updraft.TableAny, time: number) {
return (id: KeyType): Updraft.TableChange<any, any> => ({
table,
time,
delete: id
});
}
}