diff --git a/fxa-auth-db-server/docs/DB_API.md b/fxa-auth-db-server/docs/DB_API.md index a8cfa868..4b852f0a 100644 --- a/fxa-auth-db-server/docs/DB_API.md +++ b/fxa-auth-db-server/docs/DB_API.md @@ -15,6 +15,7 @@ There are a number of methods that a DB storage backend should implement: * .accountExists(emailBuffer) * Session Tokens * .createSessionToken(tokenId, sessionToken) + * .updateSessionToken(tokenId, sessionToken) * .sessionToken(id) * .deleteSessionToken(tokenId) * Key Fetch Tokens @@ -249,7 +250,7 @@ Parameters. Each token takes the following fields for it's create method respectively: -* sessionToken : data, uid, createdAt +* sessionToken : data, uid, createdAt, uaBrowser, uaBrowserVersion, uaOS, uaOSVersion, uaDeviceType * keyFetchToken : authKey, uid, keyBundle, createdAt * passwordChangeToken : data, uid, createdAt * passwordForgotToken : data, uid, passCode, createdAt, triesxb @@ -288,7 +289,9 @@ Returns: Each token returns different fields. These fields are represented as `t.*` for a field from the token and `a.*` for a field from the corresponding account. -* sessionToken : t.tokenData, t.uid, t.createdAt, a.emailVerified, a.email, a.emailCode, a.verifierSetAt +* sessionToken : t.tokenData, t.uid, t.createdAt, t.uaBrowser, t.uaBrowserVersion, + t.uaOS, t.uaOSVersion, t.uaDeviceType, t.lastAccessTime, + a.emailVerified, a.email, a.emailCode, a.verifierSetAt * keyFetchToken : t.authKey, t.uid, t.keyBundle, t.createdAt, a.emailVerified, a.verifierSetAt * passwordChangeToken : t.tokenData, t.uid, t.createdAt, a.verifierSetAt * passwordForgotToken : t.tokenData, t.uid, t.createdAt, t.passCode, t.tries, a.email, a.verifierSetAt @@ -302,6 +305,28 @@ from the token and `a.*` for a field from the corresponding account. Will delete the token of the correct type designated by the given `tokenId`. +## .updateSessionToken(tokenId, token) ## + +An extra function for `sessionTokens`. Just updates the `uaBrowser`, `uaBrowserVersion`, `uaOS`, `uaOSVersion`, `uaDeviceType` and `lastAccessTime` fields of the token. + +Parameters. + +* tokenId : (Buffer32) the unique id for this token +* token : (Object) - + * uaBrowser : (string) + * uaBrowserVersion : (string) + * uaOS : (string) + * uaOSVersion : (string) + * uaDeviceType : (string) + * lastAccessTime : (number) + +Returns: + +* resolves with: + * an object `{}` (whether a row was updated or not, ie. even if `tokenId` does not exist.) +* rejects with: + * any error from the underlying storage system (wrapped in `error.wrap()`) + ## .updatePasswordForgotToken(tokenId, token) ## An extra function for `passwordForgotTokens`. Just updates the `tries` field of the token. @@ -317,7 +342,7 @@ Returns: * resolves with: * an object `{}` (whether a row was updated or not, ie. even if `tokenId` does not exist.) * rejects with: - * any error from the underlying storage system (wrapped in `error.wrap()` + * any error from the underlying storage system (wrapped in `error.wrap()`) ## .forgotPasswordVerified(tokenId, accountResetToken) ## diff --git a/fxa-auth-db-server/docs/Server_API.md b/fxa-auth-db-server/docs/Server_API.md index 841dafab..cf970c12 100644 --- a/fxa-auth-db-server/docs/Server_API.md +++ b/fxa-auth-db-server/docs/Server_API.md @@ -61,6 +61,7 @@ The following datatypes are used throughout this document: * sessionToken : `GET /sessionToken/:id` * deleteSessionToken : `DEL /sessionToken/:id` * createSessionToken : `PUT /sessionToken/:id` + * updateSessionToken : `POST /sessionToken/:id/update` * Account Reset Tokens: * accountResetToken : `GET /accountResetToken/:id` * deleteAccountResetToken : `DEL /accountResetToken/:id` @@ -482,6 +483,62 @@ Content-Length: 2 * Content-Type : 'application/json' * Body : {"code":"InternalError","message":"......"} +## updateSessionToken : `POST /sessionToken//update` + +This updates the user agent and last-access time for a particular token. + +### Example + +``` +curl \ + -v \ + -X POST \ + -H "Content-Type: application/json" \ + -d '{ + "uaBrowser" : "Firefox", + "uaBrowserVersion" : "42", + "usOS" : "Android", + "usOSVersion" : "5.1", + "uaDeviceType": "mobile", + "lastAccessTime": 1437992394186 + }' \ + http://localhost:8000/sessionToken/522c251a1623e1f1db1f4fe68b9594d26772d6e77e04cb68e110c58600f97a77/update +``` + +### Request + +* Method : POST +* Path : `/sessionToken//update` + * tokenId : hex256 +* Params: + * uaBrowser : string + * uaBrowserVersion : string + * uaOS : string + * uaOSVersion : string + * uaDeviceType : string + * lastAccessTime : epoch + +### Response + +``` +HTTP/1.1 200 OK +Content-Type: application/json +Content-Length: 2 + +{} +``` + +* Status Code : 200 OK + * Content-Type : 'application/json' + * Body : {} +* Status Code : 404 Not Found + * Conditions: if this session `tokenId` doesn't exist + * Content-Type : 'application/json' + * Body : `{"message":"Not Found"}` +* Status Code : 500 Internal Server Error + * Conditions: if something goes wrong on the server + * Content-Type : 'application/json' + * Body : `{"code":"InternalError","message":"......"}` ## accountDevices : `GET /account//devices` @@ -1199,7 +1256,7 @@ curl \ ### Request -* Method : PUT +* Method : POST * Path : `/passwordForgotToken//update` * tokenId : hex256 * Params: diff --git a/fxa-auth-db-server/index.js b/fxa-auth-db-server/index.js index cdc42d05..f0b65c43 100644 --- a/fxa-auth-db-server/index.js +++ b/fxa-auth-db-server/index.js @@ -83,6 +83,7 @@ function createServer(db) { api.get('/sessionToken/:id', reply(db.sessionToken)) api.del('/sessionToken/:id', reply(db.deleteSessionToken)) api.put('/sessionToken/:id', reply(db.createSessionToken)) + api.post('/sessionToken/:id/update', reply(db.updateSessionToken)) api.get('/keyFetchToken/:id', reply(db.keyFetchToken)) api.del('/keyFetchToken/:id', reply(db.deleteKeyFetchToken)) diff --git a/fxa-auth-db-server/test/backend/db_tests.js b/fxa-auth-db-server/test/backend/db_tests.js index c5a4f603..3ff04fdb 100644 --- a/fxa-auth-db-server/test/backend/db_tests.js +++ b/fxa-auth-db-server/test/backend/db_tests.js @@ -41,7 +41,12 @@ var SESSION_TOKEN_ID = hex32() var SESSION_TOKEN = { data : hex32(), uid : ACCOUNT.uid, - createdAt: now + 1 + createdAt : now + 1, + uaBrowser : 'mock browser', + uaBrowserVersion : 'mock browser version', + uaOS : 'mock OS', + uaOSVersion : 'mock OS version', + uaDeviceType : 'mock device type' } var KEY_FETCH_TOKEN_ID = hex32() @@ -197,7 +202,7 @@ module.exports = function(config, DB) { test( 'session token handling', function (t) { - t.plan(10) + t.plan(30) return db.createSessionToken(SESSION_TOKEN_ID, SESSION_TOKEN) .then(function(result) { t.deepEqual(result, {}, 'Returned an empty object on session token creation') @@ -208,6 +213,41 @@ module.exports = function(config, DB) { t.deepEqual(token.tokenData, SESSION_TOKEN.data, 'token data matches') t.deepEqual(token.uid, ACCOUNT.uid, 'token belongs to this account') t.equal(token.createdAt, SESSION_TOKEN.createdAt, 'createdAt is correct') + t.equal(token.uaBrowser, SESSION_TOKEN.uaBrowser, 'uaBrowser is correct') + t.equal(token.uaBrowserVersion, SESSION_TOKEN.uaBrowserVersion, 'uaBrowserVersion is correct') + t.equal(token.uaOS, SESSION_TOKEN.uaOS, 'uaOS is correct') + t.equal(token.uaOSVersion, SESSION_TOKEN.uaOSVersion, 'uaOSVersion is correct') + t.equal(token.uaDeviceType, SESSION_TOKEN.uaDeviceType, 'uaDeviceType is correct') + t.equal(token.lastAccessTime, SESSION_TOKEN.createdAt, 'lastAccessTime was set') + t.equal(!!token.emailVerified, ACCOUNT.emailVerified, 'token emailVerified is same as account emailVerified') + t.equal(token.email, ACCOUNT.email, 'token email same as account email') + t.deepEqual(token.emailCode, ACCOUNT.emailCode, 'token emailCode same as account emailCode') + t.equal(token.verifierSetAt, ACCOUNT.verifierSetAt, 'verifierSetAt is correct') + }) + .then(function() { + return db.updateSessionToken(SESSION_TOKEN_ID, { + uaBrowser: 'foo', + uaBrowserVersion: '1', + uaOS: 'bar', + uaOSVersion: '2', + uaDeviceType: 'baz', + lastAccessTime: 42 + }) + }) + .then(function(result) { + t.deepEqual(result, {}, 'Returned an empty object on session token update') + return db.sessionToken(SESSION_TOKEN_ID) + }) + .then(function(token) { + t.deepEqual(token.tokenData, SESSION_TOKEN.data, 'token data matches') + t.deepEqual(token.uid, ACCOUNT.uid, 'token belongs to this account') + t.equal(token.createdAt, SESSION_TOKEN.createdAt, 'createdAt is correct') + t.equal(token.uaBrowser, 'foo', 'uaBrowser is correct') + t.equal(token.uaBrowserVersion, '1', 'uaBrowserVersion is correct') + t.equal(token.uaOS, 'bar', 'uaOS is correct') + t.equal(token.uaOSVersion, '2', 'uaOSVersion is correct') + t.equal(token.uaDeviceType, 'baz', 'uaDeviceType is correct') + t.equal(token.lastAccessTime, 42, 'lastAccessTime is correct') t.equal(!!token.emailVerified, ACCOUNT.emailVerified, 'token emailVerified is same as account emailVerified') t.equal(token.email, ACCOUNT.email, 'token email same as account email') t.deepEqual(token.emailCode, ACCOUNT.emailCode, 'token emailCode same as account emailCode') @@ -749,7 +789,7 @@ module.exports = function(config, DB) { var anotherSessionToken = { data : hex32(), uid : ACCOUNT.uid, - createdAt: Date.now(), + createdAt: Date.now() } db.createSessionToken(SESSION_TOKEN_ID, SESSION_TOKEN) .then(function(sessionToken) { diff --git a/fxa-auth-db-server/test/backend/remote.js b/fxa-auth-db-server/test/backend/remote.js index 8fe3eed8..4cedcaf4 100644 --- a/fxa-auth-db-server/test/backend/remote.js +++ b/fxa-auth-db-server/test/backend/remote.js @@ -180,7 +180,7 @@ module.exports = function(cfg, server) { test( 'session token handling', function (t) { - t.plan(14) + t.plan(31) var user = fake.newUserDataHex() client.putThen('/account/' + user.accountId, user.account) .then(function() { @@ -202,13 +202,47 @@ module.exports = function(cfg, server) { // tokenId is not returned from db.sessionToken() t.deepEqual(token.tokenData, user.sessionToken.data, 'token data matches') t.deepEqual(token.uid, user.accountId, 'token belongs to this account') - t.ok(token.createdAt, 'Got a createdAt') + t.equal(token.createdAt, user.sessionToken.createdAt, 'createdAt matches') + t.equal(token.uaBrowser, user.sessionToken.uaBrowser, 'uaBrowser matches') + t.equal(token.uaBrowserVersion, user.sessionToken.uaBrowserVersion, 'uaBrowserVersion matches') + t.equal(token.uaOS, user.sessionToken.uaOS, 'uaOS matches') + t.equal(token.uaOSVersion, user.sessionToken.uaOSVersion, 'uaOSVersion matches') + t.equal(token.uaDeviceType, user.sessionToken.uaDeviceType, 'uaDeviceType matches') + t.equal(token.lastAccessTime, token.createdAt, 'lastAccessTime was set') t.equal(!!token.emailVerified, user.account.emailVerified, 'emailVerified same as account emailVerified') t.equal(token.email, user.account.email, 'token.email same as account email') t.deepEqual(token.emailCode, user.account.emailCode, 'token emailCode same as account emailCode') t.ok(token.verifierSetAt, 'verifierSetAt is set to a truthy value') - // now delete it + // update the session token + return client.postThen('/sessionToken/' + user.sessionTokenId + '/update', { + uaBrowser: 'different browser', + uaBrowserVersion: 'different browser version', + uaOS: 'different OS', + uaOSVersion: 'different OS version', + uaDeviceType: 'different device type', + lastAccessTime: 42 + }) + }) + .then(function(r) { + respOk(t, r) + return client.getThen('/sessionToken/' + user.sessionTokenId) + }) + .then(function(r) { + var token = r.obj + + // tokenId is not returned from db.sessionToken() + t.deepEqual(token.tokenData, user.sessionToken.data, 'token data matches') + t.deepEqual(token.uid, user.accountId, 'token belongs to this account') + t.equal(token.createdAt, user.sessionToken.createdAt, 'createdAt was not updated') + t.equal(token.uaBrowser, 'different browser', 'uaBrowser was updated') + t.equal(token.uaBrowserVersion, 'different browser version', 'uaBrowserVersion was updated') + t.equal(token.uaOS, 'different OS', 'uaOS was updated') + t.equal(token.uaOSVersion, 'different OS version', 'uaOSVersion was updated') + t.equal(token.uaDeviceType, 'different device type', 'uaDeviceType was updated') + t.equal(token.lastAccessTime, 42, 'lastAccessTime was updated') + + // delete the session token return client.delThen('/sessionToken/' + user.sessionTokenId) }) .then(function(r) { diff --git a/fxa-auth-db-server/test/fake.js b/fxa-auth-db-server/test/fake.js index d64c7d34..4c58fb66 100644 --- a/fxa-auth-db-server/test/fake.js +++ b/fxa-auth-db-server/test/fake.js @@ -43,7 +43,12 @@ module.exports.newUserDataHex = function() { data.sessionToken = { data : hex32(), uid : data.accountId, - createdAt: Date.now() + createdAt: Date.now(), + uaBrowser: 'fake browser', + uaBrowserVersion: 'fake browser version', + uaOS: 'fake OS', + uaOSVersion: 'fake OS version', + uaDeviceType: 'fake device type' } // keyFetchToken diff --git a/lib/db/mem.js b/lib/db/mem.js index 33b24172..8c0ee3b7 100644 --- a/lib/db/mem.js +++ b/lib/db/mem.js @@ -70,6 +70,12 @@ module.exports = function (log, error) { data: sessionToken.data, uid: sessionToken.uid, createdAt: sessionToken.createdAt, + uaBrowser: sessionToken.uaBrowser, + uaBrowserVersion: sessionToken.uaBrowserVersion, + uaOS: sessionToken.uaOS, + uaOSVersion: sessionToken.uaOSVersion, + uaDeviceType: sessionToken.uaDeviceType, + lastAccessTime: sessionToken.createdAt } var account = accounts[sessionToken.uid.toString('hex')] @@ -294,6 +300,12 @@ module.exports = function (log, error) { item.tokenData = sessionTokens[id].data item.uid = sessionTokens[id].uid item.createdAt = sessionTokens[id].createdAt + item.uaBrowser = sessionTokens[id].uaBrowser + item.uaBrowserVersion = sessionTokens[id].uaBrowserVersion + item.uaOS = sessionTokens[id].uaOS + item.uaOSVersion = sessionTokens[id].uaOSVersion + item.uaDeviceType = sessionTokens[id].uaDeviceType + item.lastAccessTime = sessionTokens[id].lastAccessTime var accountId = sessionTokens[id].uid.toString('hex') var account = accounts[accountId] @@ -511,6 +523,8 @@ module.exports = function (log, error) { return P.resolve(unlockCode) } + // UPDATE + Memory.prototype.updatePasswordForgotToken = function (id, data) { var token = passwordForgotTokens[id.toString('hex')] if (!token) { return P.reject(error.notFound()) } @@ -518,6 +532,21 @@ module.exports = function (log, error) { return P.resolve({}) } + Memory.prototype.updateSessionToken = function (id, data) { + var token = sessionTokens[id.toString('hex')] + if (!token) { + return P.reject(error.notFound()) + } + token.uaBrowser = data.uaBrowser + token.uaBrowserVersion = data.uaBrowserVersion + token.uaOS = data.uaOS + token.uaOSVersion = data.uaOSVersion + token.uaDeviceType = data.uaDeviceType + token.lastAccessTime = data.lastAccessTime + return P.resolve({}) + } + + // UTILITY FUNCTIONS Memory.prototype.ping = function () { diff --git a/lib/db/mysql.js b/lib/db/mysql.js index a2b7b762..fba9de65 100644 --- a/lib/db/mysql.js +++ b/lib/db/mysql.js @@ -179,8 +179,10 @@ module.exports = function (log, error) { } // Insert : sessionTokens - // Values : tokenId = $1, tokenData = $2, uid = $3, createdAt = $4 - var CREATE_SESSION_TOKEN = 'CALL createSessionToken_1(?, ?, ?, ?)' + // Values : tokenId = $1, tokenData = $2, uid = $3, createdAt = $4, + // uaBrowser = $5, uaBrowserVersion = $6, uaOS = $7, + // uaOSVersion = $8, uaDeviceType = $9 + var CREATE_SESSION_TOKEN = 'CALL createSessionToken_2(?, ?, ?, ?, ?, ?, ?, ?, ?)' MySql.prototype.createSessionToken = function (tokenId, sessionToken) { return this.write( @@ -189,7 +191,12 @@ module.exports = function (log, error) { tokenId, sessionToken.data, sessionToken.uid, - sessionToken.createdAt + sessionToken.createdAt, + sessionToken.uaBrowser, + sessionToken.uaBrowserVersion, + sessionToken.uaOS, + sessionToken.uaOSVersion, + sessionToken.uaDeviceType ] ) } @@ -301,9 +308,11 @@ module.exports = function (log, error) { } // Select : sessionTokens t, accounts a - // Fields : t.tokenData, t.uid, t.createdAt, a.emailVerified, a.email, a.emailCode, a.verifierSetAt, a.locale + // Fields : t.tokenData, t.uid, t.createdAt, t.uaBrowser, t.uaBrowserVersion, + // t.uaOS, t.uaOSVersion, t.uaDeviceType, t.lastAccessTime, + // a.emailVerified, a.email, a.emailCode, a.verifierSetAt, a.locale // Where : t.tokenId = $1 AND t.uid = a.uid - var SESSION_TOKEN = 'CALL sessionToken_1(?)' + var SESSION_TOKEN = 'CALL sessionToken_2(?)' MySql.prototype.sessionToken = function (id) { return this.readFirstResult(SESSION_TOKEN, [id]) @@ -373,6 +382,27 @@ module.exports = function (log, error) { return this.write(UPDATE_PASSWORD_FORGOT_TOKEN, [token.tries, tokenId]) } + // Update : sessionTokens + // Set : uaBrowser = $1, uaBrowserVersion = $2, uaOS = $3, uaOSVersion = $4, + // uaDeviceType = $5, lastAccessTime = $6 + // Where : tokenId = $7 + var UPDATE_SESSION_TOKEN = 'CALL updateSessionToken_1(?, ?, ?, ?, ?, ?, ?)' + + MySql.prototype.updateSessionToken = function (tokenId, token) { + return this.write( + UPDATE_SESSION_TOKEN, + [ + token.uaBrowser, + token.uaBrowserVersion, + token.uaOS, + token.uaOSVersion, + token.uaDeviceType, + token.lastAccessTime, + tokenId + ] + ) + } + // DELETE // Delete : sessionTokens, keyFetchTokens, accountResetTokens, passwordChangeTokens, passwordForgotTokens, accountUnlockCodes, accounts diff --git a/lib/db/patch.js b/lib/db/patch.js index 2d669f8c..1b2aed94 100644 --- a/lib/db/patch.js +++ b/lib/db/patch.js @@ -4,4 +4,4 @@ // The expected patch level of the database. Update if you add a new // patch in the schema/ directory. -module.exports.level = 14 +module.exports.level = 15 diff --git a/lib/db/schema/patch-014-015.sql b/lib/db/schema/patch-014-015.sql new file mode 100644 index 00000000..995bcefd --- /dev/null +++ b/lib/db/schema/patch-014-015.sql @@ -0,0 +1,99 @@ +-- Add uaBrowser, uaBrowserVersion, uaOS, uaOSVersion, uaDeviceType +-- and lastAccessTime fields to sessionTokens table + +ALTER TABLE sessionTokens ADD COLUMN uaBrowser VARCHAR(255); +ALTER TABLE sessionTokens ADD COLUMN uaBrowserVersion VARCHAR(255); +ALTER TABLE sessionTokens ADD COLUMN uaOS VARCHAR(255); +ALTER TABLE sessionTokens ADD COLUMN uaOSVersion VARCHAR(255); +ALTER TABLE sessionTokens ADD COLUMN uaDeviceType VARCHAR(255); +ALTER TABLE sessionTokens ADD COLUMN lastAccessTime BIGINT UNSIGNED NOT NULL DEFAULT 0; + +CREATE PROCEDURE `createSessionToken_2` ( + IN tokenId BINARY(32), + IN tokenData BINARY(32), + IN uid BINARY(16), + IN createdAt BIGINT UNSIGNED, + IN uaBrowser VARCHAR(255), + IN uaBrowserVersion VARCHAR(255), + IN uaOS VARCHAR(255), + IN uaOSVersion VARCHAR(255), + IN uaDeviceType VARCHAR(255) +) +BEGIN + INSERT INTO sessionTokens( + tokenId, + tokenData, + uid, + createdAt, + uaBrowser, + uaBrowserVersion, + uaOS, + uaOSVersion, + uaDeviceType, + lastAccessTime + ) + VALUES( + tokenId, + tokenData, + uid, + createdAt, + uaBrowser, + uaBrowserVersion, + uaOS, + uaOSVersion, + uaDeviceType, + createdAt + ); +END; + +CREATE PROCEDURE `sessionToken_2` ( + IN `inTokenId` BINARY(32) +) +BEGIN + SELECT + t.tokenData, + t.uid, + t.createdAt, + t.uaBrowser, + t.uaBrowserVersion, + t.uaOS, + t.uaOSVersion, + t.uaDeviceType, + t.lastAccessTime, + a.emailVerified, + a.email, + a.emailCode, + a.verifierSetAt, + a.locale + FROM + sessionTokens t, + accounts a + WHERE + t.tokenId = inTokenId + AND + t.uid = a.uid + ; +END; + +CREATE PROCEDURE `updateSessionToken_1` ( + IN uaBrowserArg VARCHAR(255), + IN uaBrowserVersionArg VARCHAR(255), + IN uaOSArg VARCHAR(255), + IN uaOSVersionArg VARCHAR(255), + IN uaDeviceTypeArg VARCHAR(255), + IN lastAccessTimeArg BIGINT UNSIGNED, + IN tokenIdArg BINARY(32) +) +BEGIN + UPDATE sessionTokens + SET uaBrowser = uaBrowserArg, + uaBrowserVersion = uaBrowserVersionArg, + uaOS = uaOSArg, + uaOSVersion = uaOSVersionArg, + uaDeviceType = uaDeviceTypeArg, + lastAccessTime = lastAccessTimeArg + WHERE tokenId = tokenIdArg; +END; + +UPDATE dbMetadata SET value = '15' WHERE name = 'schema-patch-level'; + diff --git a/lib/db/schema/patch-015-014.sql b/lib/db/schema/patch-015-014.sql new file mode 100644 index 00000000..98e67a76 --- /dev/null +++ b/lib/db/schema/patch-015-014.sql @@ -0,0 +1,15 @@ +-- -- drop new stored procedures +-- DROP PROCEDURE `createSessionToken_2`; +-- DROP PROCEDURE `sessionToken_2`; +-- DROP PROCEDURE `updateSessionToken_1`; + +-- -- drop new columns +-- ALTER TABLE sessionTokens DROP COLUMN uaBrowser; +-- ALTER TABLE sessionTokens DROP COLUMN uaBrowserVersion; +-- ALTER TABLE sessionTokens DROP COLUMN uaOS; +-- ALTER TABLE sessionTokens DROP COLUMN uaOSVersion; +-- ALTER TABLE sessionTokens DROP COLUMN uaDeviceType; +-- ALTER TABLE sessionTokens DROP COLUMN lastAccessTime; + +-- -- Schema patch-level decrement. +-- UPDATE dbMetadata SET value = '14' WHERE name = 'schema-patch-level';