diff --git a/Makefile b/Makefile index c43efecb7..f170ad052 100644 --- a/Makefile +++ b/Makefile @@ -19,29 +19,29 @@ default: .PHONY: clean clean: @echo "Clean..." - find packages -mindepth 2 -maxdepth 2 -type f -name '.bowerrc' -delete - find packages -mindepth 2 -maxdepth 2 -type f -name '.editorconfig' -delete - find packages -mindepth 2 -maxdepth 2 -type f -name '.ember-cli' -delete - find packages -mindepth 2 -maxdepth 2 -type f -name '.ember-cli.js' -delete - find packages -mindepth 2 -maxdepth 2 -type f -name '.eslintignore' -delete - find packages -mindepth 2 -maxdepth 2 -type f -name '.eslintrc.js' -delete - find packages -mindepth 2 -maxdepth 2 -type f -name '.gitignore' -delete - find packages -mindepth 2 -maxdepth 2 -type f -name '.gitkeep' -delete - find packages -mindepth 2 -maxdepth 2 -type f -name '.template-lintrc.js' -delete - find packages -mindepth 2 -maxdepth 2 -type f -name '.travis.yml' -delete - find packages -mindepth 2 -maxdepth 2 -type f -name '.watchmanconfig' -delete - find packages -mindepth 2 -maxdepth 2 -type f -name 'bower.json' -delete - find packages -mindepth 2 -maxdepth 2 -type f -name 'ember-cli-build.js' -delete - find packages -mindepth 2 -maxdepth 2 -type f -name 'jsconfig.json' -delete - find packages -mindepth 2 -maxdepth 2 -type f -name 'package-lock.json' -delete - find packages -mindepth 2 -maxdepth 2 -type f -name 'README.md' -delete - find packages -mindepth 2 -maxdepth 2 -type f -name 'testem.js' -delete - find packages -mindepth 2 -maxdepth 2 -type f -name 'yarn.lock' -delete - find packages -mindepth 2 -maxdepth 2 -type d -name '.git' -exec rm -rf "{}" \; - find packages -mindepth 2 -maxdepth 2 -type d -name 'config' -exec rm -rf "{}" \; - find packages -mindepth 2 -maxdepth 2 -type d -name 'dist' -exec rm -rf "{}" \; - find packages -mindepth 2 -maxdepth 2 -type d -name 'tests' -exec rm -rf "{}" \; - find packages -mindepth 2 -maxdepth 2 -type d -name 'tmp' -exec rm -rf "{}" \; + find packages -mindepth 2 -maxdepth 2 -type d ! -path 'packages/surrealdb' -exec find {} -type f -name '.bowerrc' -delete + find packages -mindepth 2 -maxdepth 2 -type d ! -path 'packages/surrealdb' -exec find {} -type f -name '.editorconfig' -delete + find packages -mindepth 2 -maxdepth 2 -type d ! -path 'packages/surrealdb' -exec find {} -type f -name '.ember-cli' -delete + find packages -mindepth 2 -maxdepth 2 -type d ! -path 'packages/surrealdb' -exec find {} -type f -name '.ember-cli.js' -delete + find packages -mindepth 2 -maxdepth 2 -type d ! -path 'packages/surrealdb' -exec find {} -type f -name '.eslintignore' -delete + find packages -mindepth 2 -maxdepth 2 -type d ! -path 'packages/surrealdb' -exec find {} -type f -name '.eslintrc.js' -delete + find packages -mindepth 2 -maxdepth 2 -type d ! -path 'packages/surrealdb' -exec find {} -type f-name '.gitignore' -delete + find packages -mindepth 2 -maxdepth 2 -type d ! -path 'packages/surrealdb' -exec find {} -type f -name '.gitkeep' -delete + find packages -mindepth 2 -maxdepth 2 -type d ! -path 'packages/surrealdb' -exec find {} -type f -name '.template-lintrc.js' -delete + find packages -mindepth 2 -maxdepth 2 -type d ! -path 'packages/surrealdb' -exec find {} -type f -name '.travis.yml' -delete + find packages -mindepth 2 -maxdepth 2 -type d ! -path 'packages/surrealdb' -exec find {} -type f -name '.watchmanconfig' -delete + find packages -mindepth 2 -maxdepth 2 -type d ! -path 'packages/surrealdb' -exec find {} -type f -name '.bowerrc' -delete \; + find packages -mindepth 2 -maxdepth 2 -type d ! -path 'packages/surrealdb' -exec find {} -type f -name 'ember-cli-build.js' -delete + find packages -mindepth 2 -maxdepth 2 -type d ! -path 'packages/surrealdb' -exec find {} -type f -name 'jsconfig.json' -delete + find packages -mindepth 2 -maxdepth 2 -type d ! -path 'packages/surrealdb' -exec find {} -type f -name 'package-lock.json' -delete + find packages -mindepth 2 -maxdepth 2 -type d ! -path 'packages/surrealdb' -exec find {} -type f -name 'README.md' -delete + find packages -mindepth 2 -maxdepth 2 -type d ! -path 'packages/surrealdb' -exec find {} -type f -name 'testem.js' -delete + find packages -mindepth 2 -maxdepth 2 -type d ! -path 'packages/surrealdb' -exec find {} -type f -name 'yarn.lock' -delete + find packages -mindepth 2 -maxdepth 2 -type d ! -path 'packages/surrealdb' -name '.git' -exec rm -rf "{}" \; + find packages -mindepth 2 -maxdepth 2 -type d ! -path 'packages/surrealdb' -name 'config' -exec rm -rf "{}" \; + find packages -mindepth 2 -maxdepth 2 -type d ! -path 'packages/surrealdb' -name 'dist' -exec rm -rf "{}" \; + find packages -mindepth 2 -maxdepth 2 -type d ! -path 'packages/surrealdb' -name 'tests' -exec rm -rf "{}" \; + find packages -mindepth 2 -maxdepth 2 -type d ! -path 'packages/surrealdb' -name 'tmp' -exec rm -rf "{}" \; rm -rf node_modules npx lerna clean --yes @@ -58,12 +58,12 @@ serve: .PHONY: version version: @echo "Version..." - npx lerna version --no-push --force-publish + npx lerna@4.0.0 version --no-push --force-publish .PHONY: publish publish: @echo "Publish..." - npx lerna publish from-package + npx lerna@4.0.0 publish from-package .PHONY: deploy deploy: diff --git a/packages/surrealdb/.editorconfig b/packages/surrealdb/.editorconfig new file mode 100644 index 000000000..c35a00240 --- /dev/null +++ b/packages/surrealdb/.editorconfig @@ -0,0 +1,19 @@ +# EditorConfig helps developers define and maintain consistent +# coding styles between different editors and IDEs +# editorconfig.org + +root = true + +[*] +end_of_line = lf +charset = utf-8 +trim_trailing_whitespace = true +insert_final_newline = true +indent_style = space +indent_size = 2 + +[*.hbs] +insert_final_newline = false + +[*.{diff,md}] +trim_trailing_whitespace = false diff --git a/packages/surrealdb/.ember-cli b/packages/surrealdb/.ember-cli new file mode 100644 index 000000000..465c4050d --- /dev/null +++ b/packages/surrealdb/.ember-cli @@ -0,0 +1,7 @@ +{ + /** + Setting `isTypeScriptProject` to true will force the blueprint generators to generate TypeScript + rather than JavaScript by default, when a TypeScript version of a given blueprint is available. + */ + "isTypeScriptProject": false +} diff --git a/packages/surrealdb/.eslintignore b/packages/surrealdb/.eslintignore new file mode 100644 index 000000000..9385391f2 --- /dev/null +++ b/packages/surrealdb/.eslintignore @@ -0,0 +1,13 @@ +# unconventional js +/blueprints/*/files/ + +# compiled output +/dist/ + +# misc +/coverage/ +!.* +.*/ + +# ember-try +/.node_modules.ember-try/ diff --git a/packages/surrealdb/.eslintrc.js b/packages/surrealdb/.eslintrc.js new file mode 100644 index 000000000..ade61fb01 --- /dev/null +++ b/packages/surrealdb/.eslintrc.js @@ -0,0 +1,56 @@ +'use strict'; + +module.exports = { + root: true, + parser: '@babel/eslint-parser', + parserOptions: { + ecmaVersion: 'latest', + sourceType: 'module', + requireConfigFile: false, + babelOptions: { + plugins: [ + ['@babel/plugin-proposal-decorators', { decoratorsBeforeExport: true }], + ], + }, + }, + plugins: ['ember'], + extends: [ + 'eslint:recommended', + 'plugin:ember/recommended', + 'plugin:prettier/recommended', + ], + env: { + browser: true, + }, + rules: {}, + overrides: [ + // node files + { + files: [ + './.eslintrc.js', + './.prettierrc.js', + './.stylelintrc.js', + './.template-lintrc.js', + './ember-cli-build.js', + './index.js', + './testem.js', + './blueprints/*/index.js', + './config/**/*.js', + './tests/dummy/config/**/*.js', + ], + parserOptions: { + sourceType: 'script', + }, + env: { + browser: false, + node: true, + }, + extends: ['plugin:n/recommended'], + }, + { + // test files + files: ['tests/**/*-test.{js,ts}'], + extends: ['plugin:qunit/recommended'], + }, + ], +}; diff --git a/packages/surrealdb/.github/workflows/ci.yml b/packages/surrealdb/.github/workflows/ci.yml new file mode 100644 index 000000000..57a6bf253 --- /dev/null +++ b/packages/surrealdb/.github/workflows/ci.yml @@ -0,0 +1,78 @@ +name: CI + +on: + push: + branches: + - main + - master + pull_request: {} + +concurrency: + group: ci-${{ github.head_ref || github.ref }} + cancel-in-progress: true + +jobs: + test: + name: "Tests" + runs-on: ubuntu-latest + timeout-minutes: 10 + + steps: + - uses: actions/checkout@v3 + - name: Install Node + uses: actions/setup-node@v3 + with: + node-version: 18 + cache: npm + - name: Install Dependencies + run: npm ci + - name: Lint + run: npm run lint + - name: Run Tests + run: npm run test:ember + + floating: + name: "Floating Dependencies" + runs-on: ubuntu-latest + timeout-minutes: 10 + + steps: + - uses: actions/checkout@v3 + - uses: actions/setup-node@v3 + with: + node-version: 18 + cache: npm + - name: Install Dependencies + run: npm install --no-shrinkwrap + - name: Run Tests + run: npm run test:ember + + try-scenarios: + name: ${{ matrix.try-scenario }} + runs-on: ubuntu-latest + needs: "test" + timeout-minutes: 10 + + strategy: + fail-fast: false + matrix: + try-scenario: + - ember-lts-4.8 + - ember-lts-4.12 + - ember-release + - ember-beta + - ember-canary + - embroider-safe + - embroider-optimized + + steps: + - uses: actions/checkout@v3 + - name: Install Node + uses: actions/setup-node@v3 + with: + node-version: 18 + cache: npm + - name: Install Dependencies + run: npm ci + - name: Run Tests + run: ./node_modules/.bin/ember try:one ${{ matrix.try-scenario }} diff --git a/packages/surrealdb/.gitignore b/packages/surrealdb/.gitignore new file mode 100644 index 000000000..71ad79d02 --- /dev/null +++ b/packages/surrealdb/.gitignore @@ -0,0 +1,25 @@ +# compiled output +/dist/ +/declarations/ + +# dependencies +/node_modules/ + +# misc +/.env* +/.pnp* +/.eslintcache +/coverage/ +/npm-debug.log* +/testem.log +/yarn-error.log + +# ember-try +/.node_modules.ember-try/ +/npm-shrinkwrap.json.ember-try +/package.json.ember-try +/package-lock.json.ember-try +/yarn.lock.ember-try + +# broccoli-debug +/DEBUG/ diff --git a/packages/surrealdb/.npmignore b/packages/surrealdb/.npmignore new file mode 100644 index 000000000..69beb28f0 --- /dev/null +++ b/packages/surrealdb/.npmignore @@ -0,0 +1,35 @@ +# compiled output +/dist/ +/tmp/ + +# misc +/.editorconfig +/.ember-cli +/.env* +/.eslintcache +/.eslintignore +/.eslintrc.js +/.git/ +/.github/ +/.gitignore +/.prettierignore +/.prettierrc.js +/.stylelintignore +/.stylelintrc.js +/.template-lintrc.js +/.travis.yml +/.watchmanconfig +/CONTRIBUTING.md +/ember-cli-build.js +/testem.js +/tests/ +/yarn-error.log +/yarn.lock +.gitkeep + +# ember-try +/.node_modules.ember-try/ +/npm-shrinkwrap.json.ember-try +/package.json.ember-try +/package-lock.json.ember-try +/yarn.lock.ember-try diff --git a/packages/surrealdb/.prettierignore b/packages/surrealdb/.prettierignore new file mode 100644 index 000000000..9385391f2 --- /dev/null +++ b/packages/surrealdb/.prettierignore @@ -0,0 +1,13 @@ +# unconventional js +/blueprints/*/files/ + +# compiled output +/dist/ + +# misc +/coverage/ +!.* +.*/ + +# ember-try +/.node_modules.ember-try/ diff --git a/packages/surrealdb/.prettierrc.js b/packages/surrealdb/.prettierrc.js new file mode 100644 index 000000000..e5f7b6d1e --- /dev/null +++ b/packages/surrealdb/.prettierrc.js @@ -0,0 +1,12 @@ +'use strict'; + +module.exports = { + overrides: [ + { + files: '*.{js,ts}', + options: { + singleQuote: true, + }, + }, + ], +}; diff --git a/packages/surrealdb/.stylelintignore b/packages/surrealdb/.stylelintignore new file mode 100644 index 000000000..a0cf71cbd --- /dev/null +++ b/packages/surrealdb/.stylelintignore @@ -0,0 +1,8 @@ +# unconventional files +/blueprints/*/files/ + +# compiled output +/dist/ + +# addons +/.node_modules.ember-try/ diff --git a/packages/surrealdb/.stylelintrc.js b/packages/surrealdb/.stylelintrc.js new file mode 100644 index 000000000..021c539ad --- /dev/null +++ b/packages/surrealdb/.stylelintrc.js @@ -0,0 +1,5 @@ +'use strict'; + +module.exports = { + extends: ['stylelint-config-standard', 'stylelint-prettier/recommended'], +}; diff --git a/packages/surrealdb/.template-lintrc.js b/packages/surrealdb/.template-lintrc.js new file mode 100644 index 000000000..f35f61c7b --- /dev/null +++ b/packages/surrealdb/.template-lintrc.js @@ -0,0 +1,5 @@ +'use strict'; + +module.exports = { + extends: 'recommended', +}; diff --git a/packages/surrealdb/.watchmanconfig b/packages/surrealdb/.watchmanconfig new file mode 100644 index 000000000..f9c3d8f84 --- /dev/null +++ b/packages/surrealdb/.watchmanconfig @@ -0,0 +1,3 @@ +{ + "ignore_dirs": ["dist"] +} diff --git a/packages/surrealdb/CONTRIBUTING.md b/packages/surrealdb/CONTRIBUTING.md new file mode 100644 index 000000000..eac27e7ba --- /dev/null +++ b/packages/surrealdb/CONTRIBUTING.md @@ -0,0 +1,25 @@ +# How To Contribute + +## Installation + +- `git clone ` +- `cd surrealdb` +- `npm install` + +## Linting + +- `npm run lint` +- `npm run lint:fix` + +## Running tests + +- `ember test` – Runs the test suite on the current Ember version +- `ember test --server` – Runs the test suite in "watch mode" +- `ember try:each` – Runs the test suite against multiple Ember versions + +## Running the dummy application + +- `ember serve` +- Visit the dummy application at [http://localhost:4200](http://localhost:4200). + +For more information on using ember-cli, visit [https://cli.emberjs.com/release/](https://cli.emberjs.com/release/). diff --git a/packages/surrealdb/LICENSE.md b/packages/surrealdb/LICENSE.md new file mode 100644 index 000000000..943856053 --- /dev/null +++ b/packages/surrealdb/LICENSE.md @@ -0,0 +1,9 @@ +The MIT License (MIT) + +Copyright (c) 2023 + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/packages/surrealdb/README.md b/packages/surrealdb/README.md new file mode 100644 index 000000000..fba334d41 --- /dev/null +++ b/packages/surrealdb/README.md @@ -0,0 +1,27 @@ +# @ascua/surrealdb + +[Short description of the addon.] + +## Compatibility + +- Ember.js v4.8 or above +- Ember CLI v4.8 or above +- Node.js v18 or above + +## Installation + +``` +ember install surreal +``` + +## Usage + +[Longer description of how to use the addon in apps.] + +## Contributing + +See the [Contributing](CONTRIBUTING.md) guide for details. + +## License + +This project is licensed under the [MIT License](LICENSE.md). diff --git a/packages/surrealdb/addon/builders/count.js b/packages/surrealdb/addon/builders/count.js new file mode 100644 index 000000000..7a25a96b3 --- /dev/null +++ b/packages/surrealdb/addon/builders/count.js @@ -0,0 +1,21 @@ +export default function (table, options = {}) { + let bits = []; + + let vars = options.param || {}; + + vars.tb = table; + + bits.push('SELECT'); + + bits.push('count(*) AS count'); + + bits.push('FROM type::table($tb)'); + + if (options.where && options.where.length) { + bits.push(`WHERE ${options.where.join(' AND ')}`); + } + + bits.push(`GROUP BY all`); + + return { text: bits.join(' '), vars }; +} diff --git a/packages/surrealdb/addon/builders/hasher.js b/packages/surrealdb/addon/builders/hasher.js new file mode 100644 index 000000000..de7874691 --- /dev/null +++ b/packages/surrealdb/addon/builders/hasher.js @@ -0,0 +1,53 @@ +import md5 from '../utils/md5'; + +export default function (table, options = {}) { + let bits = []; + + bits.push('SELECT'); + + if (options.field) { + bits.push(options.field.join(', ')); + } else { + bits.push('*'); + } + + bits.push(`FROM ${table}`); + + if (options.where && options.where.length) { + bits.push(`WHERE ${options.where.join(' AND ')}`); + } + + if (options.group) { + bits.push(`GROUP BY ${options.group}`); + } + + if (options.order) { + bits.push(`ORDER BY ${options.order}`); + } + + if (options.limit) { + bits.push(`LIMIT BY ${options.limit}`); + } + + if (options.start) { + bits.push(`START AT ${options.start}`); + } + + if (options.fetch && options.fetch.length) { + bits.push(`FETCH ${options.fetch.join(', ')}`); + } + + if (options.version) { + bits.push(`VERSION ${options.version}`); + } + + let sql = bits.join(' '); + + if (options.param) { + Object.keys(options.param).forEach((k) => { + sql = sql.replace(`$${k}`, JSON.stringify(options.param[k])); + }); + } + + return md5(sql); +} diff --git a/packages/surrealdb/addon/builders/index.js b/packages/surrealdb/addon/builders/index.js new file mode 100644 index 000000000..f8fd74d62 --- /dev/null +++ b/packages/surrealdb/addon/builders/index.js @@ -0,0 +1,9 @@ +import count from './count'; +import table from './table'; + +export default { + count, + table, +}; + +export { count, table }; diff --git a/packages/surrealdb/addon/builders/table.js b/packages/surrealdb/addon/builders/table.js new file mode 100644 index 000000000..862c3d30a --- /dev/null +++ b/packages/surrealdb/addon/builders/table.js @@ -0,0 +1,50 @@ +export default function (table, options = {}) { + let bits = []; + + let vars = options.param || {}; + + vars.tb = table; + + bits.push('SELECT'); + + if (options.field) { + bits.push(options.field.join(', ')); + } else { + bits.push('*'); + } + + bits.push('FROM type::table($tb)'); + + if (options.where && options.where.length) { + bits.push(`WHERE ${options.where.join(' AND ')}`); + } + + if (options.group) { + bits.push(`GROUP BY ${options.group}`); + } + + if (options.order) { + bits.push(`ORDER BY ${options.order}`); + } + + if (options.limit) { + bits.push('LIMIT BY $limit'); + vars.limit = options.limit; + } + + if (options.start) { + bits.push('START AT $start'); + vars.start = options.start; + } + + if (options.fetch && options.fetch.length) { + bits.push(`FETCH ${options.fetch.join(', ')}`); + } + + if (options.version) { + bits.push('VERSION $versn'); + vars.versn = options.version; + } + + return { text: bits.join(' '), vars }; +} diff --git a/packages/surrealdb/addon/classes/array.js b/packages/surrealdb/addon/classes/array.js new file mode 100644 index 000000000..06397a1b7 --- /dev/null +++ b/packages/surrealdb/addon/classes/array.js @@ -0,0 +1,11 @@ +export default class extends Array { + remove(callback, target) { + let arr = this.filter(callback, target); + return this.removeObjects(arr); + } + + removeBy(key, value) { + let arr = this.filterBy(key, value); + return this.removeObjects(arr); + } +} diff --git a/packages/surrealdb/addon/classes/cache.js b/packages/surrealdb/addon/classes/cache.js new file mode 100644 index 000000000..cf3624570 --- /dev/null +++ b/packages/surrealdb/addon/classes/cache.js @@ -0,0 +1,19 @@ +import { TrackedArray, TrackedObject } from 'tracked-built-ins'; + +export default class Cache { + #data = new TrackedObject({}); + + get(model) { + return (this.#data[model] = this.#data[model] || new TrackedArray([])); + } + + del(model) { + this.#data[model] = []; + } + + clear() { + for (const k in this.#data) { + this.del(k); + } + } +} diff --git a/packages/surrealdb/addon/classes/dmp/diff.js b/packages/surrealdb/addon/classes/dmp/diff.js new file mode 100644 index 000000000..a2d77ee01 --- /dev/null +++ b/packages/surrealdb/addon/classes/dmp/diff.js @@ -0,0 +1,130 @@ +import { typeOf } from '@ember/utils'; +import DMP from 'dmp'; + +const regex = + /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2}(?:\.\d*))(?:Z|(\+|-)([\d|:]*))?$/; + +function route(path, part) { + if (path.length === 0) { + return '/' + part; + } else { + if (part[0] === '/') { + return path + part; + } else { + return path + '/' + part; + } + } +} + +export default class Diff { + constructor(old = {}, now = {}) { + this.ops = []; + + this.obj(old, now, ''); + } + + output() { + return this.ops; + } + + op(op, path, value) { + this.ops.push({ op, path, value }); + } + + val(old, now, path = '') { + if (old === now) { + return; + } + + if (typeOf(old) !== typeOf(now)) { + this.op('replace', path, now); + return; + } + + switch (typeof old) { + case 'string': + let v = regex.exec(now); + if (v) { + this.op('replace', path, now); + } else { + this.txt(old, now, path); + } + return; + case 'object': + if (old.constructor === Array) { + this.arr(old, now, path); + } + if (old.constructor === Object) { + this.obj(old, now, path); + } + return; + default: + this.op('replace', path, now); + return; + } + } + + obj(old = {}, now = {}, path = '') { + for (let k in old) { + let p = route(path, k); + + // Value no longer exists + if (k in now === false) { + this.op('remove', p, now[k]); + continue; + } + } + + for (let k in now) { + let a = now[k]; + let b = old[k]; + let p = route(path, k); + + // Value did not previously exist + if (k in old === false) { + this.op('add', p, a); + continue; + } + + // Value is now completely different + if (typeOf(a) !== typeOf(b)) { + this.op('replace', p, a); + continue; + } + + // Check whether the values have changed + this.val(b, a, p); + } + } + + arr(old = [], now = [], path = '') { + let i = 0; + + for (i = 0; i < old.length && i < now.length; i++) { + let p = route(path, i); + this.val(old[i], now[i], p); + } + + for (let j = old.length; j < now.length; j++) { + let p = route(path, j); + let v = now[j]; + this.op('add', p, v); + } + + for (let j = old.length - 1; j >= now.length; j--) { + let p = route(path, j); + let v = undefined; + this.op('remove', p, v); + } + } + + txt(old = '', now = '', path = '') { + let dmp = new DMP(); + + let pch = dmp.patch_make(old, now); + + let txt = dmp.patch_toText(pch); + + this.op('change', path, txt); + } +} diff --git a/packages/surrealdb/addon/classes/dmp/patch.js b/packages/surrealdb/addon/classes/dmp/patch.js new file mode 100644 index 000000000..fc12fdf47 --- /dev/null +++ b/packages/surrealdb/addon/classes/dmp/patch.js @@ -0,0 +1,81 @@ +import DMP from 'dmp'; + +function getByPath(obj, path) { + var parts = path.split('.'); + var o = obj; + if (parts.length > 1) { + for (var i = 0; i < parts.length - 1; i++) { + if (!o[parts[i]]) { + o[parts[i]] = {}; + } + o = o[parts[i]]; + } + } + return o[parts[parts.length - 1]]; +} + +function setByPath(obj, path, value) { + var parts = path.split('.'); + var o = obj; + if (parts.length > 1) { + for (var i = 0; i < parts.length - 1; i++) { + if (!o[parts[i]]) { + o[parts[i]] = {}; + } + o = o[parts[i]]; + } + } + o[parts[parts.length - 1]] = value; +} + +function delByPath(obj, path) { + var parts = path.split('.'); + var o = obj; + if (parts.length > 1) { + for (var i = 0; i < parts.length - 1; i++) { + if (!o[parts[i]]) { + o[parts[i]] = {}; + } + o = o[parts[i]]; + } + } + delete o[parts[parts.length - 1]]; +} + +export default class Patch { + constructor(old = {}, ops = []) { + this.obj = old; + + this.pch(ops); + } + + output() { + return this.obj; + } + + pch(ops = []) { + ops.forEach((v) => { + let p = v.path.split('/').join('.').slice(1); + + switch (v.op) { + case 'add': + setByPath(this.obj, p, v.value); + return; + case 'remove': + delByPath(this.obj, p, v.value); + return; + case 'replace': + setByPath(this.obj, p, v.value); + return; + case 'change': { + let dmp = new DMP(); + let txt = getByPath(this.obj, p); + let pch = dmp.patch_fromText(v.value); + let [done] = dmp.patch_apply(pch, txt); + setByPath(this.obj, p, done); + return; + } + } + }); + } +} diff --git a/packages/surrealdb/addon/classes/field/any.js b/packages/surrealdb/addon/classes/field/any.js new file mode 100644 index 000000000..bb0b86203 --- /dev/null +++ b/packages/surrealdb/addon/classes/field/any.js @@ -0,0 +1,12 @@ +import Property from './property'; +import Any from '../types/any'; +import { RECORD } from '../model'; + +export default Property({ + get(key) { + return Any(this[RECORD].data[key]); + }, + set(key, value) { + return (this[RECORD].data[key] = Any(value)); + }, +}); diff --git a/packages/surrealdb/addon/classes/field/array.js b/packages/surrealdb/addon/classes/field/array.js new file mode 100644 index 000000000..ce814f97c --- /dev/null +++ b/packages/surrealdb/addon/classes/field/array.js @@ -0,0 +1,236 @@ +import Property from './property'; +import Array from '../types/array'; +import Any from '../types/any'; +import String from '../types/string'; +import Number from '../types/number'; +import Boolean from '../types/boolean'; +import Datetime from '../types/datetime'; +import Record from '../types/record'; +import Model from '@ascua/surrealdb/model'; +import Field from '@ascua/surrealdb/field'; +import { assert } from '@ember/debug'; +import { DestroyedError } from '@ascua/surrealdb/errors'; +import { RECORD } from '../model'; + +const json = (v) => { + try { + let o = JSON.parse(JSON.stringify(v)); + return JSON.stringify(o, Object.keys(o).sort()); + } catch (e) { + return JSON.stringify(v); + } +}; + +export default function (type) { + return Property({ + get(key) { + switch (type) { + case undefined: + return (this[RECORD].data[key] = + this[RECORD].data[key] || Array.create(this, Any)); + case 'string': + return (this[RECORD].data[key] = + this[RECORD].data[key] || Array.create(this, String)); + case 'number': + return (this[RECORD].data[key] = + this[RECORD].data[key] || Array.create(this, Number)); + case 'boolean': + return (this[RECORD].data[key] = + this[RECORD].data[key] || Array.create(this, Boolean)); + case 'datetime': + return (this[RECORD].data[key] = + this[RECORD].data[key] || Array.create(this, Datetime)); + default: + let value = this[RECORD].data[key] || []; + + try { + let model = this.store.lookup(type); + + if (model && model.class.prototype instanceof Field) { + return (this[RECORD].data[key] = + this[RECORD].data[key] || + Array.create( + this, + (v) => { + return model.create({ + ...v, + parent: this, + }); + }, + ...value + )); + } + + if (model && model.class.prototype instanceof Model) { + return (this[RECORD].data[key] = + this[RECORD].data[key] || + Array.create( + this, + (v) => { + switch (true) { + case v === null: + return v; + case v === undefined: + return v; + case v instanceof Record: + return v; + case v instanceof Model: + return this.store.proxy({ + id: v.id, + content: v, + }); + case v instanceof Object: + return this.store.proxy({ + id: v.id, + content: this.store.inject(v), + }); + default: + let cached = this.store.cached(type, v); + if (cached) { + return this.store.proxy({ + id: v, + content: cached, + }); + } else { + return this.store.proxy({ + id: v, + promise: () => this.store.select(type, v), + }); + } + } + }, + ...value + )); + } + + assert('An embedded object must be of type Model or Field'); + } catch (e) { + if (e instanceof DestroyedError) { + // ignore + } else { + throw e; + } + } + } + }, + set(key, value = []) { + if (this[RECORD].data[key] !== undefined) { + value.forEach((v, k) => { + switch (true) { + case this[RECORD].data[key][k] === undefined: { + this[RECORD].data[key].pushObject(v); + } + case this[RECORD].data[key][k] !== undefined: { + switch (true) { + case this[RECORD].data[key][k] === null: + this[RECORD].data[key].replace(k, 1, [v]); + break; + case this[RECORD].data[key][k].constructor === Object: + this[RECORD].data[key].replace(k, 1, [v]); + break; + case json(this[RECORD].data[key][k]) !== json(v): + this[RECORD].data[key].replace(k, 1, [v]); + break; + } + } + } + }); + for (let i = this[RECORD].data[key].length; i > value.length; i--) { + this[RECORD].data[key].popObject(); + } + return this[RECORD].data[key]; + } + + switch (type) { + case undefined: + return (this[RECORD].data[key] = Array.create(this, Any, ...value)); + case 'string': + return (this[RECORD].data[key] = Array.create( + this, + String, + ...value + )); + case 'number': + return (this[RECORD].data[key] = Array.create( + this, + Number, + ...value + )); + case 'boolean': + return (this[RECORD].data[key] = Array.create( + this, + Boolean, + ...value + )); + case 'datetime': + return (this[RECORD].data[key] = Array.create( + this, + Datetime, + ...value + )); + default: + try { + let model = this.store.lookup(type); + + if (model && model.class.prototype instanceof Field) { + return (this[RECORD].data[key] = Array.create( + this, + (v) => { + return model.create({ ...v, parent: this }); + }, + ...value + )); + } + + if (model && model.class.prototype instanceof Model) { + return (this[RECORD].data[key] = Array.create( + this, + (v) => { + switch (true) { + case v === null: + return v; + case v === undefined: + return v; + case v instanceof Record: + return v; + case v instanceof Model: + return this.store.proxy({ + id: v.id, + content: v, + }); + case v instanceof Object: + return this.store.proxy({ + id: v.id, + content: this.store.inject(v), + }); + default: + let cached = this.store.cached(type, v); + if (cached) { + return this.store.proxy({ + id: v, + content: cached, + }); + } else { + return this.store.proxy({ + id: v, + promise: () => this.store.select(type, v), + }); + } + } + }, + ...value + )); + } + + assert('An embedded object must be of type Model or Field'); + } catch (e) { + if (e instanceof DestroyedError) { + // ignore + } else { + throw e; + } + } + } + }, + }); +} diff --git a/packages/surrealdb/addon/classes/field/boolean.js b/packages/surrealdb/addon/classes/field/boolean.js new file mode 100644 index 000000000..b1957d2a2 --- /dev/null +++ b/packages/surrealdb/addon/classes/field/boolean.js @@ -0,0 +1,12 @@ +import Property from './property'; +import Boolean from '../types/boolean'; +import { RECORD } from '../model'; + +export default Property({ + get(key) { + return Boolean(this[RECORD].data[key]); + }, + set(key, value) { + return (this[RECORD].data[key] = Boolean(value)); + }, +}); diff --git a/packages/surrealdb/addon/classes/field/datetime.js b/packages/surrealdb/addon/classes/field/datetime.js new file mode 100644 index 000000000..9c1dc5adc --- /dev/null +++ b/packages/surrealdb/addon/classes/field/datetime.js @@ -0,0 +1,12 @@ +import Property from './property'; +import Datetime from '../types/datetime'; +import { RECORD } from '../model'; + +export default Property({ + get(key) { + return Datetime(this[RECORD].data[key]); + }, + set(key, value) { + return (this[RECORD].data[key] = Datetime(value)); + }, +}); diff --git a/packages/surrealdb/addon/classes/field/index.js b/packages/surrealdb/addon/classes/field/index.js new file mode 100644 index 000000000..6423be89a --- /dev/null +++ b/packages/surrealdb/addon/classes/field/index.js @@ -0,0 +1,93 @@ +import Ember from 'ember'; +import { setOwner } from '@ember/application'; +import { inject } from '@ember/service'; +import { tracked } from '@glimmer/tracking'; +import meta from '../meta'; +import json from '../../utils/json'; +import { RECORD } from '../model'; + +export default class Field { + // ------------------------------ + // Static methods + // ------------------------------ + + static create(owner, data, shadow) { + return new this(owner, data, shadow); + } + + // ------------------------------ + // Instance properties + // ------------------------------ + + @inject store; + + #parent = undefined; + + // The current underlying record state + [RECORD] = { + @tracked data: {}, + }; + + // The `parent` property can be used + // to retrieve the underlying parent + // model that owns this record. + + get parent() { + return this.#parent; + } + + set parent(value) { + this.#parent = value; + } + + // When formatted as a JSON string, + // the record's underlying data will + // be used for serlialization. + + toJSON() { + return this[RECORD].data; + } + + get _full() { + return json.full(this); + } + + get _some() { + return json.some(this); + } + + // ------------------------------ + // Instance methods + // ------------------------------ + + /** + * Finalizes the record setup. + * + * @returns {undefined} Does not return anything. + */ + + constructor(owner, data, shadow) { + setOwner(this, owner); + for (const key in data) { + this[key] = data[key]; + } + } + + /** + * Save the record to the database. + * @returns {Promise} Promise object with the saved record. + */ + + save() { + return this.#parent && this.#parent.save(); + } + + /** + * Autosave the record to the database. + * @returns {Promise} Promise object with the saved record. + */ + + autosave() { + return this.#parent && this.#parent.autosave(); + } +} diff --git a/packages/surrealdb/addon/classes/field/number.js b/packages/surrealdb/addon/classes/field/number.js new file mode 100644 index 000000000..b67f08c11 --- /dev/null +++ b/packages/surrealdb/addon/classes/field/number.js @@ -0,0 +1,12 @@ +import Property from './property'; +import Number from '../types/number'; +import { RECORD } from '../model'; + +export default Property({ + get(key) { + return Number(this[RECORD].data[key]); + }, + set(key, value) { + return (this[RECORD].data[key] = Number(value)); + }, +}); diff --git a/packages/surrealdb/addon/classes/field/object.js b/packages/surrealdb/addon/classes/field/object.js new file mode 100644 index 000000000..b73421dd7 --- /dev/null +++ b/packages/surrealdb/addon/classes/field/object.js @@ -0,0 +1,55 @@ +import Property from './property'; +import Field from '@ascua/surrealdb/field'; +import { assert } from '@ember/debug'; +import { setProperties } from '@ember/object'; +import { DestroyedError } from '@ascua/surrealdb/errors'; +import { RECORD } from '../model'; + +export default function (type) { + return Property({ + get(key) { + try { + let model = this.store.lookup(type); + + if (model && model.class.prototype instanceof Field) { + return (this[RECORD].data[key] = + this[RECORD].data[key] || model.create({ parent: this })); + } + + assert('An embedded object must be of type Field'); + } catch (e) { + if (e instanceof DestroyedError) { + // ignore + } else { + throw e; + } + } + }, + set(key, value = {}) { + try { + let model = this.store.lookup(type); + + if (model && model.class.prototype instanceof Field) { + switch (true) { + case this[RECORD].data[key] !== undefined: + setProperties(this[RECORD].data[key], value); + return this[RECORD].data[key]; + case this[RECORD].data[key] === undefined: + return (this[RECORD].data[key] = model.create({ + ...value, + parent: this, + })); + } + } + + assert('An embedded object must be of type Field'); + } catch (e) { + if (e instanceof DestroyedError) { + // ignore + } else { + throw e; + } + } + }, + }); +} diff --git a/packages/surrealdb/addon/classes/field/property.js b/packages/surrealdb/addon/classes/field/property.js new file mode 100644 index 000000000..c025b2d56 --- /dev/null +++ b/packages/surrealdb/addon/classes/field/property.js @@ -0,0 +1,31 @@ +import meta from '../meta'; +import { RECORD } from '../model'; + +const json = (v) => JSON.stringify(v); + +export default function (obj) { + return function (target, key, desc) { + meta.set(target, key); + + return { + configurable: false, + enumerable: true, + writeable: false, + get() { + return obj.get.apply(this, [key]); + }, + set(value) { + let old = json(this[RECORD].data[key]); + let val = obj.set.apply(this, [key, value]); + let now = json(val); + + if (old !== now) { + this[RECORD].data = this[RECORD].data; + this.autosave(); + } + + return val; + }, + }; + }; +} diff --git a/packages/surrealdb/addon/classes/field/readonly.js b/packages/surrealdb/addon/classes/field/readonly.js new file mode 100644 index 000000000..642f6198e --- /dev/null +++ b/packages/surrealdb/addon/classes/field/readonly.js @@ -0,0 +1,9 @@ +import meta from '../meta'; + +export default function (target, key, desc) { + meta.set(target, key, { + readonly: true, + }); + + return desc; +} diff --git a/packages/surrealdb/addon/classes/field/record.js b/packages/surrealdb/addon/classes/field/record.js new file mode 100644 index 000000000..e65f0a362 --- /dev/null +++ b/packages/surrealdb/addon/classes/field/record.js @@ -0,0 +1,69 @@ +import Property from './property'; +import Record from '../types/record'; +import Model from '@ascua/surrealdb/model'; +import { RECORD } from '../model'; + +export default function (type) { + return Property({ + get(key) { + let value = this[RECORD].data[key]; + + switch (true) { + case value === null: + return this[RECORD].data[key]; + case value === undefined: + return this[RECORD].data[key]; + case value instanceof Record: + return this[RECORD].data[key]; + default: + let cached = this.store.cached(type, value); + if (cached) { + return (this[RECORD].data[key] = this.store.proxy({ + id: value, + content: cached, + })); + } else { + return (this[RECORD].data[key] = this.store.proxy({ + id: value, + promise: () => this.store.select(type, value), + })); + } + } + }, + set(key, value) { + switch (true) { + case value === null: + return (this[RECORD].data[key] = value); + case value === undefined: + return (this[RECORD].data[key] = value); + case value instanceof Record: + return (this[RECORD].data[key] = value); + case value === String(this[RECORD].data[key]): + return (this[RECORD].data[key] = this[RECORD].data[key]); + case value instanceof Model: + return (this[RECORD].data[key] = this.store.proxy({ + id: value.id, + content: value, + })); + case value instanceof Object: + return (this[RECORD].data[key] = this.store.proxy({ + id: value.id, + content: this.store.inject(value), + })); + default: + let cached = this.store.cached(type, value); + if (cached) { + return (this[RECORD].data[key] = this.store.proxy({ + id: value, + content: cached, + })); + } else { + return (this[RECORD].data[key] = this.store.proxy({ + id: value, + promise: () => this.store.select(type, value), + })); + } + } + }, + }); +} diff --git a/packages/surrealdb/addon/classes/field/string.js b/packages/surrealdb/addon/classes/field/string.js new file mode 100644 index 000000000..ab8648f54 --- /dev/null +++ b/packages/surrealdb/addon/classes/field/string.js @@ -0,0 +1,12 @@ +import Property from './property'; +import String from '../types/string'; +import { RECORD } from '../model'; + +export default Property({ + get(key) { + return String(this[RECORD].data[key]); + }, + set(key, value) { + return (this[RECORD].data[key] = String(value)); + }, +}); diff --git a/packages/surrealdb/addon/classes/meta/index.js b/packages/surrealdb/addon/classes/meta/index.js new file mode 100644 index 000000000..b80eae7f6 --- /dev/null +++ b/packages/surrealdb/addon/classes/meta/index.js @@ -0,0 +1,28 @@ +const META = Symbol('META'); + +export function init(target) { + if (target[META] === undefined) { + Object.defineProperty(target, META, { + configurable: false, + enumerable: false, + writeable: false, + value: {}, + }); + } +} + +export function all(target) { + init(target); + return Object.keys(target[META]).map((k) => target[META][k]); +} + +export function get(target, name) { + init(target); + return target[META][name]; +} + +export function set(target, name, opt) { + init(target); + target[META][name] = target[META][name] || { name }; + Object.assign(target[META][name], opt, { name }); +} diff --git a/packages/surrealdb/addon/classes/model/index.js b/packages/surrealdb/addon/classes/model/index.js new file mode 100644 index 000000000..d63e903b1 --- /dev/null +++ b/packages/surrealdb/addon/classes/model/index.js @@ -0,0 +1,320 @@ +import Ember from 'ember'; +import context from '@ascua/context'; +import { setOwner } from '@ember/application'; +import { inject } from '@ember/service'; +import { tracked } from '@glimmer/tracking'; +import { defer } from '@ascua/queue'; +import Patch from '../dmp/patch'; +import Diff from '../dmp/diff'; +import json from '../../utils/json'; + +export const RECORD = Symbol('RECORD'); +export const LOADED = Symbol('LOADED'); +export const LOADING = Symbol('LOADING'); +export const DELETED = Symbol('DELETED'); + +export default class Model { + // ------------------------------ + // Static methods + // ------------------------------ + + static create(owner, data, shadow) { + return new this(owner, data, shadow); + } + + // ------------------------------ + // Instance properties + // ------------------------------ + + @inject surreal; + + @inject store; + + #id = null; + + #fake = false; + + // Current context object + #ctx = undefined; + + // Context cancel function + #cancel = undefined; + + // Shadow local record copy + #shadow = undefined; + + // Last state of sent data + #client = undefined; + + // Last state of received data + #server = undefined; + + // The current underlying record state + [RECORD] = { + @tracked data: {}, + @tracked state: LOADED, + }; + + // The `tb` property can be used + // to retrieve the actual table + // that this record belongs to. + + get tb() { + return this.#id.split(':')[0]; + } + + // The `id` property can be used + // to retrieve the actual thing + // id for this Surreal record. + + get id() { + return this.#id; + } + + set id(value) { + this.#id = value; + } + + // The exists property allows us + // to detect whether the record + // exists or has been deleted. + + get exists() { + return this[RECORD].state !== DELETED; + } + + // The `json` property returns a + // JSON representation copy of the + // record's current data snapshot. + + get json() { + return JSON.parse(JSON.stringify(this)); + } + + // When formatted as a string, the + // record will output the record + // id, with both table and id. + + toString() { + return this.#id; + } + + // When formatted as a JSON string, + // the record's underlying data will + // be used for serlialization. + + toJSON() { + return Object.assign(this[RECORD].data, { + id: this.id, + }); + } + + get _full() { + return json.full(this); + } + + get _some() { + return json.some(this); + } + + // ------------------------------ + // Instance methods + // ------------------------------ + + /** + * Finalizes the record setup. + * + * @returns {undefined} Does not return anything. + */ + + constructor(owner, data, shadow) { + setOwner(this, owner); + for (const key in data) { + this[key] = data[key]; + } + this.#fake = shadow; + this.#server = this._some; + this.#client = this._some; + } + + /** + * Autosaves the record to the database. + * + * @returns {Promise} Promise object with the saved record. + */ + + autosave() { + // Ignore + } + + /** + * Mark the record as deleted o the remote store. + * + * @returns {undefined} Does not return anything. + */ + + remove() { + this[RECORD].state = DELETED; + } + + /** + * Update the record in the database. + * + * @returns {Promise} Promise object with the updated record. + */ + + async update() { + if (this.#cancel) this.#cancel(); + [this.#ctx, this.#cancel] = context.withCancel(); + return this._update.queue(); + } + + /** + * Delete the record in the database. + * + * @returns {Promise} Promise object with the deleted record. + */ + + async delete() { + if (this.#cancel) this.#cancel(); + [this.#ctx, this.#cancel] = context.withCancel(); + return this._delete.queue(); + } + + /** + * Save the record to the database. + * + * @returns {Promise} Promise object with the saved record. + */ + + async save() { + if (this.#cancel) this.#cancel(); + [this.#ctx, this.#cancel] = context.withCancel(); + try { + await this.#ctx.delay(500); + return this._modify.queue(); + } catch (e) { + // Ignore + } + } + + /** + * Rollback the record without saving. + * + * @returns {undefined} Does not return anything. + */ + + rollback() { + // Set state to LOADING + this[RECORD].state = LOADING; + + // Get the local record state + let local = this.#shadow._full; + + // Apply server side changes to local record + for (const key in local) { + this[key] = local[key]; + } + + // Store the current client<->server state + this.#client = this.#server = this.#shadow._some; + + // Set state to LOADED + this[RECORD].state = LOADED; + } + + /** + * Initiates a record modification from the + * server based on the modified record data. + * + * @returns {undefined} Does not return anything. + */ + + ingest(data) { + // Set state to LOADING + this[RECORD].state = LOADING; + + // Create a new shadow record for the data + this.#shadow = this.store.lookup(this.tb).create(data); + + // Calculate changes while data was in flight + let changes = new Diff(this.#client, this._some).output(); + + // Merge in-flight changes with server changes + let current = new Patch(this.#shadow._full, changes).output(); + + // Apply server side changes to local record + for (const key in current) { + this[key] = current[key]; + } + + // Store the current client<->server state + this.#client = this.#server = this.#shadow._some; + + // Set state to LOADED + this[RECORD].state = LOADED; + + // Save any changes + if (changes.length) { + this.autosave(); + } + } + + /** + * Initiates a record update with the database. + * + * @returns {Promise} Promise object with the updated record. + */ + + @defer async _modify() { + if (this.#fake) return; + try { + let diff = new Diff(this.#client, this._some).output(); + if (diff.length) { + this[RECORD].state = LOADING; + this.#client = this._some; + return this.store.modify(this, diff); + } + } catch (e) { + // Ignore + } finally { + this[RECORD].state = LOADED; + } + } + + /** + * Initiates a record update with the database. + * + * @returns {Promise} Promise object with the updated record. + */ + + @defer async _update() { + if (this.#fake) return; + try { + this[RECORD].state = LOADING; + this.#client = this._some; + return this.store.update(this); + } catch (e) { + // Ignore + } finally { + this[RECORD].state = LOADED; + } + } + + /** + * Initiates a record delete with the database. + * + * @returns {Promise} Promise object with the deleted record. + */ + + @defer async _delete() { + if (this.#fake) return; + try { + return this.store.delete(this); + } catch (e) { + // Ignore + } finally { + this[RECORD].state = DELETED; + } + } +} diff --git a/packages/surrealdb/addon/classes/storage.js b/packages/surrealdb/addon/classes/storage.js new file mode 100644 index 000000000..e97403875 --- /dev/null +++ b/packages/surrealdb/addon/classes/storage.js @@ -0,0 +1,43 @@ +import test from '../utils/test'; + +const enabled = test(); + +export default class Storage { + #data = {}; + + set(id, val) { + switch (enabled) { + case true: + return window.localStorage.setItem(id, val); + case false: + return (this.#data[id] = val || undefined); + } + } + + get(id) { + switch (enabled) { + case true: + return window.localStorage.getItem(id); + case false: + return this.#data[id] || undefined; + } + } + + del(id) { + switch (enabled) { + case true: + return window.localStorage.removeItem(id); + case false: + return delete this.#data[id]; + } + } + + clear() { + switch (enabled) { + case true: + return window.localStorage.clear(); + case false: + return (this.#data = {}); + } + } +} diff --git a/packages/surrealdb/addon/classes/types/any.js b/packages/surrealdb/addon/classes/types/any.js new file mode 100644 index 000000000..3fab83856 --- /dev/null +++ b/packages/surrealdb/addon/classes/types/any.js @@ -0,0 +1,3 @@ +export default (v) => { + return v; +}; diff --git a/packages/surrealdb/addon/classes/types/array.js b/packages/surrealdb/addon/classes/types/array.js new file mode 100644 index 000000000..772a3424d --- /dev/null +++ b/packages/surrealdb/addon/classes/types/array.js @@ -0,0 +1,57 @@ +const func = (v) => v; + +export default class RecordArray extends Array { + static create(owner, type = func, ...values) { + let v = values.map(type); + let a = new this(...v); + a.type = type; + return new Proxy(a, { + get() { + return Reflect.get(...arguments); + }, + set() { + let val = Reflect.set(...arguments); + if (owner) owner.autosave(); + return val; + }, + }); + } + + type = func; + + addObject(value) { + return super.addObject(this.type(value)); + } + + addObjects(values) { + return super.addObjects([].concat(values).map(this.type)); + } + + pushObject(value) { + return super.pushObject(this.type(value)); + } + + pushObjects(values) { + return super.pushObjects([].concat(values).map(this.type)); + } + + setObjects(values) { + return super.setObjects([].concat(values).map(this.type)); + } + + replace(idx, count, values) { + return super.replace(idx, count, [].concat(values).map(this.type)); + } + + then() { + return Promise.all(this).then(...arguments); + } + + catch() { + return Promise.all(this).catch(...arguments); + } + + finally() { + return Promise.all(this).finally(...arguments); + } +} diff --git a/packages/surrealdb/addon/classes/types/boolean.js b/packages/surrealdb/addon/classes/types/boolean.js new file mode 100644 index 000000000..1e55f602a --- /dev/null +++ b/packages/surrealdb/addon/classes/types/boolean.js @@ -0,0 +1 @@ +export default (v) => Boolean(v); diff --git a/packages/surrealdb/addon/classes/types/datetime.js b/packages/surrealdb/addon/classes/types/datetime.js new file mode 100644 index 000000000..2b0de7a21 --- /dev/null +++ b/packages/surrealdb/addon/classes/types/datetime.js @@ -0,0 +1,10 @@ +export default (v) => { + switch (v) { + case undefined: + return null; + case null: + return null; + default: + return new Date(v).toJSON(); + } +}; diff --git a/packages/surrealdb/addon/classes/types/number.js b/packages/surrealdb/addon/classes/types/number.js new file mode 100644 index 000000000..dd93cdd5d --- /dev/null +++ b/packages/surrealdb/addon/classes/types/number.js @@ -0,0 +1 @@ +export default (v) => Number(v) || 0; diff --git a/packages/surrealdb/addon/classes/types/record.js b/packages/surrealdb/addon/classes/types/record.js new file mode 100644 index 000000000..a469fcfd4 --- /dev/null +++ b/packages/surrealdb/addon/classes/types/record.js @@ -0,0 +1,121 @@ +import Ember from 'ember'; +import { get, set } from '@ember/object'; +import { tracked } from '@glimmer/tracking'; +const { combine, updateTag, tagFor, tagMetaFor } = + Ember.__loader.require('@glimmer/validator'); +const { CUSTOM_TAG_FOR, tagForObject, tagForProperty } = Ember.__loader.require( + '@ember/-internals/metal' +); + +// https://github.com/emberjs/ember.js/blob/master/packages/%40ember/-internals/runtime/lib/mixins/-proxy.js + +export function contentFor(proxy) { + let content = proxy.content; + updateTag(tagForObject(proxy), tagForObject(content)); + return content; +} + +export default class Remote { + static initiate(data) { + return new Proxy(new Remote(data), { + get(target, key) { + switch (true) { + case key in target && typeof target[key] === 'function': + return target[key].bind(target); + case typeof key === 'symbol': + return target[key]; + case key in target: + return target[key]; + case target.content && typeof target.content[key] === 'function': + return target.content[key].bind(target.content); + default: + target.setup(); + return get(target, `content.${key}`); + } + }, + set(target, key, val) { + switch (true) { + case key in target: + target[key] = val; + return true; + default: + target.setup(); + set(target, `content.${key}`, val); + return true; + } + }, + }); + } + + #id = undefined; + + #content = undefined; + + #promise = undefined; + + @tracked content = undefined; + + toJSON() { + return this.#id; + } + + toString() { + return this.#id; + } + + constructor(params) { + this.#id = params.id; + this.content = params.content; + this.#content = params.content; + this.#promise = params.promise; + } + + [CUSTOM_TAG_FOR](key) { + let meta = tagMetaFor(this); + let tag = tagFor(this, key, meta); + if (key in this) { + return tag; + } else { + let tags = [tag, tagFor(this, 'content', meta)]; + let content = contentFor(this); + if (content && typeof content === 'object') { + tags.push(tagForProperty(content, key)); + } + return combine(tags); + } + } + + then() { + this.setup(); + return Promise.resolve(this.#promise || this.#content).then(...arguments); + } + + catch() { + this.setup(); + return Promise.resolve(this.#promise || this.#content).catch(...arguments); + } + + finally() { + this.setup(); + return Promise.resolve(this.#promise || this.#content).finally( + ...arguments + ); + } + + setup() { + if (this.#promise && this.#promise instanceof Function) { + this.#promise = this.#promise(); + + Promise.resolve(this.#promise).then( + (content) => { + this.content = content; + return content; + }, + (failure) => { + this.failure = failure; + throw failure; + } + ); + } + } +} diff --git a/packages/surrealdb/addon/classes/types/string.js b/packages/surrealdb/addon/classes/types/string.js new file mode 100644 index 000000000..e5f138aa0 --- /dev/null +++ b/packages/surrealdb/addon/classes/types/string.js @@ -0,0 +1,10 @@ +export default (v) => { + switch (v) { + case undefined: + return null; + case null: + return null; + default: + return String(v); + } +}; diff --git a/packages/surrealdb/addon/decorators/attempted.js b/packages/surrealdb/addon/decorators/attempted.js new file mode 100644 index 000000000..bb71f4c05 --- /dev/null +++ b/packages/surrealdb/addon/decorators/attempted.js @@ -0,0 +1,35 @@ +import Route from '@ember/routing/route'; +import { assert } from '@ember/debug'; +import { inject } from '@ember/service'; + +export default function (target) { + assert( + 'The @attempted decorator can only be applied to a Route', + !target || (target && target.prototype instanceof Route) + ); + return target + ? func(target) + : (target) => { + assert( + 'The @attempted decorator can only be applied to a Route', + target && target.prototype instanceof Route + ); + return func(target); + }; +} + +function func(target) { + let before = target.prototype.beforeModel; + + target.reopen({ + surreal: inject(), + + beforeModel() { + // Wait for authentication attempt. + return this.surreal.wait().then(() => { + // Continue with original hook. + return before.apply(this, ...arguments); + }); + }, + }); +} diff --git a/packages/surrealdb/addon/decorators/authenticated.js b/packages/surrealdb/addon/decorators/authenticated.js new file mode 100644 index 000000000..8526206e1 --- /dev/null +++ b/packages/surrealdb/addon/decorators/authenticated.js @@ -0,0 +1,65 @@ +import Route from '@ember/routing/route'; +import { assert } from '@ember/debug'; +import { inject } from '@ember/service'; + +export default function (target) { + assert( + 'The @authenticated decorator can only be applied to a Route', + !target || (target && target.prototype instanceof Route) + ); + return target + ? func(target) + : (target) => { + assert( + 'The @authenticated decorator can only be applied to a Route', + target && target.prototype instanceof Route + ); + return func(target); + }; +} + +function func(target) { + let enter = target.prototype.activate; + + let leave = target.prototype.deactivate; + + let before = target.prototype.beforeModel; + + target.reopen({ + router: inject(), + surreal: inject(), + session: inject(), + + redirectIfInvalidated: 'signin', + + activate() { + enter.apply(this, ...arguments); + // Enable listening to invalidated events. + this.surreal.on('invalidated', this, this.invalidate); + }, + + deactivate() { + leave.apply(this, ...arguments); + // Disable listening to invalidated events. + this.surreal.off('invalidated', this, this.invalidate); + }, + + invalidate() { + this.router.transitionTo(this.redirectIfInvalidated); + }, + + beforeModel(transition) { + // Store the current desired route. + this.surreal.transition = transition; + // Redirect if connection is invalidated. + if (this.surreal.invalidated === true) { + return this.router.replaceWith(this.redirectIfInvalidated); + } + // Wait for session identification. + return this.session.ready.then(() => { + // Continue with original hook. + return before.apply(this, ...arguments); + }); + }, + }); +} diff --git a/packages/surrealdb/addon/decorators/autosave.js b/packages/surrealdb/addon/decorators/autosave.js new file mode 100644 index 000000000..63e276292 --- /dev/null +++ b/packages/surrealdb/addon/decorators/autosave.js @@ -0,0 +1,28 @@ +import Model from '../model'; +import { RECORD } from '../model'; +import { LOADED } from '../model'; +import { assert } from '@ember/debug'; + +export default function (target) { + assert( + 'The @autosave decorator can only be applied to a Model', + !target || (target && target.prototype instanceof Model) + ); + return target + ? func(target) + : (target) => { + assert( + 'The @autosave decorator can only be applied to a Model', + target && target.prototype instanceof Model + ); + return func(target); + }; +} + +function func(target) { + target.prototype.autosave = function () { + if (this[RECORD].state === LOADED) { + return this.save(); + } + }; +} diff --git a/packages/surrealdb/addon/decorators/closed.js b/packages/surrealdb/addon/decorators/closed.js new file mode 100644 index 000000000..fd4ecf501 --- /dev/null +++ b/packages/surrealdb/addon/decorators/closed.js @@ -0,0 +1,39 @@ +import Route from '@ember/routing/route'; +import { assert } from '@ember/debug'; +import { inject } from '@ember/service'; + +export default function (target) { + assert( + 'The @closed decorator can only be applied to a Route', + !target || (target && target.prototype instanceof Route) + ); + return target + ? func(target) + : (target) => { + assert( + 'The @closed decorator can only be applied to a Route', + target && target.prototype instanceof Route + ); + return func(target); + }; +} + +function func(target) { + target.reopen({ + surreal: inject(), + + closed: () => {}, + + activate() { + this._super(...arguments); + // Enable listening to closed events. + this.surreal.on('closed', this, this.closed); + }, + + deactivate() { + this._super(...arguments); + // Disable listening to closed events. + this.surreal.off('closed', this, this.closed); + }, + }); +} diff --git a/packages/surrealdb/addon/decorators/invalidated.js b/packages/surrealdb/addon/decorators/invalidated.js new file mode 100644 index 000000000..8fb9bccce --- /dev/null +++ b/packages/surrealdb/addon/decorators/invalidated.js @@ -0,0 +1,63 @@ +import Route from '@ember/routing/route'; +import { assert } from '@ember/debug'; +import { inject } from '@ember/service'; + +export default function (target) { + assert( + 'The @invalidated decorator can only be applied to a Route', + !target || (target && target.prototype instanceof Route) + ); + return target + ? func(target) + : (target) => { + assert( + 'The @invalidated decorator can only be applied to a Route', + target && target.prototype instanceof Route + ); + return func(target); + }; +} + +function func(target) { + let enter = target.prototype.activate; + + let leave = target.prototype.deactivate; + + let before = target.prototype.beforeModel; + + target.reopen({ + router: inject(), + surreal: inject(), + + redirectIfAuthenticated: 'index', + + activate() { + enter.apply(this, ...arguments); + // Enable listening to authenticated events. + this.surreal.on('authenticated', this, this.authenticate); + }, + + deactivate() { + leave.apply(this, ...arguments); + // Disable listening to authenticated events. + this.surreal.off('authenticated', this, this.authenticate); + }, + + authenticate() { + if (this.surreal.transition) { + this.surreal.transition.retry(); + } else { + this.router.transitionTo(this.redirectIfAuthenticated); + } + }, + + beforeModel() { + // Redirect if connection is authenticated. + if (this.surreal.authenticated === true) { + return this.router.replaceWith(this.redirectIfAuthenticated); + } + // Continue with original hook. + return before.apply(this, ...arguments); + }, + }); +} diff --git a/packages/surrealdb/addon/decorators/opened.js b/packages/surrealdb/addon/decorators/opened.js new file mode 100644 index 000000000..767f5ff2e --- /dev/null +++ b/packages/surrealdb/addon/decorators/opened.js @@ -0,0 +1,39 @@ +import Route from '@ember/routing/route'; +import { assert } from '@ember/debug'; +import { inject } from '@ember/service'; + +export default function (target) { + assert( + 'The @opened decorator can only be applied to a Route', + !target || (target && target.prototype instanceof Route) + ); + return target + ? func(target) + : (target) => { + assert( + 'The @opened decorator can only be applied to a Route', + target && target.prototype instanceof Route + ); + return func(target); + }; +} + +function func(target) { + target.reopen({ + surreal: inject(), + + opened: () => {}, + + activate() { + this._super(...arguments); + // Enable listening to opened events. + this.surreal.on('opened', this, this.opened); + }, + + deactivate() { + this._super(...arguments); + // Disable listening to opened events. + this.surreal.off('opened', this, this.opened); + }, + }); +} diff --git a/packages/surrealdb/addon/decorators/signout.js b/packages/surrealdb/addon/decorators/signout.js new file mode 100644 index 000000000..bd097ac24 --- /dev/null +++ b/packages/surrealdb/addon/decorators/signout.js @@ -0,0 +1,43 @@ +import Route from '@ember/routing/route'; +import { assert } from '@ember/debug'; +import { inject } from '@ember/service'; + +export default function (target) { + assert( + 'The @signout decorator can only be applied to a Route', + !target || (target && target.prototype instanceof Route) + ); + return target + ? func(target) + : (target) => { + assert( + 'The @signout decorator can only be applied to a Route', + target && target.prototype instanceof Route + ); + return func(target); + }; +} + +function func(target) { + target.reopen({ + router: inject(), + store: inject(), + surreal: inject(), + + redirectAfterSignout: 'signin', + + beforeModel() { + // Reset the data store. + this.store.reset(); + // Invalidate the connection. + return this.surreal.invalidate(); + }, + + afterModel() { + // Reset the data store. + this.store.reset(); + // Redirect to the specified route. + return this.router.transitionTo(this.redirectAfterSignout); + }, + }); +} diff --git a/packages/surrealdb/addon/errors/index.js b/packages/surrealdb/addon/errors/index.js new file mode 100644 index 000000000..6b1d19401 --- /dev/null +++ b/packages/surrealdb/addon/errors/index.js @@ -0,0 +1,10 @@ +export class DestroyedError extends Error { + constructor() { + super(); + this.name = 'DestroyedError'; + } +} + +export default { + DestroyedError: DestroyedError, +}; diff --git a/packages/surrealdb/addon/field.js b/packages/surrealdb/addon/field.js new file mode 100644 index 000000000..97b868ccc --- /dev/null +++ b/packages/surrealdb/addon/field.js @@ -0,0 +1,24 @@ +import Field from './classes/field'; +import any from './classes/field/any'; +import array from './classes/field/array'; +import boolean from './classes/field/boolean'; +import datetime from './classes/field/datetime'; +import number from './classes/field/number'; +import object from './classes/field/object'; +import record from './classes/field/record'; +import string from './classes/field/string'; +import readonly from './classes/field/readonly'; + +export default Field; + +export { + any, + array, + boolean, + datetime, + number, + object, + record, + string, + readonly, +}; diff --git a/packages/surrealdb/addon/index.js b/packages/surrealdb/addon/index.js new file mode 100644 index 000000000..8dde71bc3 --- /dev/null +++ b/packages/surrealdb/addon/index.js @@ -0,0 +1,8 @@ +import opened from './decorators/opened'; +import signout from './decorators/signout'; +import autosave from './decorators/autosave'; +import attempted from './decorators/attempted'; +import invalidated from './decorators/invalidated'; +import authenticated from './decorators/authenticated'; + +export { opened, signout, autosave, attempted, invalidated, authenticated }; diff --git a/packages/surrealdb/addon/instance-initializers/session.js b/packages/surrealdb/addon/instance-initializers/session.js new file mode 100644 index 000000000..5d3c682dc --- /dev/null +++ b/packages/surrealdb/addon/instance-initializers/session.js @@ -0,0 +1,8 @@ +export default { + name: 'session', + + initialize(instance) { + // Instantiate the session service + instance.lookup('service:session'); + }, +}; diff --git a/packages/surrealdb/addon/instance-initializers/store.js b/packages/surrealdb/addon/instance-initializers/store.js new file mode 100644 index 000000000..84f604b0d --- /dev/null +++ b/packages/surrealdb/addon/instance-initializers/store.js @@ -0,0 +1,8 @@ +export default { + name: 'store', + + initialize(instance) { + // Instantiate the store service + instance.lookup('service:store'); + }, +}; diff --git a/packages/surrealdb/addon/instance-initializers/surreal.js b/packages/surrealdb/addon/instance-initializers/surreal.js new file mode 100644 index 000000000..422169c38 --- /dev/null +++ b/packages/surrealdb/addon/instance-initializers/surreal.js @@ -0,0 +1,8 @@ +export default { + name: 'surreal', + + initialize(instance) { + // Instantiate the surreal service + instance.lookup('service:surreal'); + }, +}; diff --git a/packages/surrealdb/addon/model.js b/packages/surrealdb/addon/model.js new file mode 100644 index 000000000..fe06f8884 --- /dev/null +++ b/packages/surrealdb/addon/model.js @@ -0,0 +1,5 @@ +import Model from './classes/model'; + +export * from './classes/model'; + +export default Model; diff --git a/packages/surrealdb/addon/services/session.js b/packages/surrealdb/addon/services/session.js new file mode 100644 index 000000000..63a79630d --- /dev/null +++ b/packages/surrealdb/addon/services/session.js @@ -0,0 +1,45 @@ +import Service from '@ember/service'; +import { inject } from '@ember/service'; +import { tracked } from '@glimmer/tracking'; + +export default class Session extends Service { + #ok = null; + + @inject store; + + @inject surreal; + + @tracked model = {}; + + constructor() { + super(...arguments); + + this.ready = new Promise((r) => (this.#ok = r)); + + // Reset the model data when invalidated + + this.surreal.on('invalidated', () => { + this.model = {}; + }); + + // Reset the store data when invalidated + + this.surreal.on('invalidated', () => { + this.store.reset(); + }); + + // Start a new promise object when invalidated + + this.surreal.on('invalidated', () => { + this.ready = new Promise((r) => (this.#ok = r)); + }); + + // When authenticated + + this.surreal.on('authenticated', async () => { + let info = await this.surreal.info(); + let sess = await this.store.inject(info); + this.#ok((this.model = sess)); + }); + } +} diff --git a/packages/surrealdb/addon/services/store.js b/packages/surrealdb/addon/services/store.js new file mode 100644 index 000000000..e1c8c38af --- /dev/null +++ b/packages/surrealdb/addon/services/store.js @@ -0,0 +1,510 @@ +import Service from '@ember/service'; +import Cache from '../classes/cache'; +import { inject } from '@ember/service'; +import { getOwner } from '@ember/application'; +import { assert } from '@ember/debug'; +import Model from '@ascua/surrealdb/model'; +import count from '../builders/count'; +import table from '../builders/table'; +import hasher from '../builders/hasher'; +import Record from '../classes/types/record'; +import { DestroyedError } from '../errors'; +import { TrackedObject } from 'tracked-built-ins'; + +export default class Store extends Service { + @inject surreal; + + #cache = new Cache(); // Record cache + + #proxy = new TrackedObject({}); // Cached record proxies + + #stack = new TrackedObject({}); // Inflight data requests + + #stash = new TrackedObject({}); // Data pushed from shoebox + + get fastboot() { + return getOwner(this).lookup('service:fastboot'); + } + + constructor() { + super(...arguments); + + if (this.fastboot) { + if (this.fastboot.isFastBoot === true) { + this.fastboot.shoebox.put('surreal', this.#stash); + } + + if (this.fastboot.isFastBoot === false) { + this.#stash = this.fastboot.shoebox.retrieve('surreal') || {}; + } + } + } + + /** + * When the store is to be destroyed, we + * destroy and clear all of the cached records. + * + * @returns {undefined} Does not return anything. + */ + + willDestroy() { + this.reset(); + super.willDestroy(...arguments); + } + + /** + * Reset the store. + * + * @returns {undefined} Does not return anything. + */ + + reset() { + this.#cache.clear(); + for (let k in this.#proxy) { + this.#proxy[k] = []; + delete this.#proxy[k]; + } + for (let k in this.#stack) { + this.#stack[k] = []; + delete this.#stack[k]; + } + } + + /** + * Lookup the model by its name. + * + * @returns {Model} The class for the desired model. + */ + lookup(model) { + let owner = getOwner(this); + if (owner.isDestroyed) { + throw new DestroyedError(); + } else { + let found = owner.factoryFor(`model:${model}`); + return { + class: found.class, + create() { + return found.class.create(owner, ...arguments); + }, + }; + } + } + + /** + * Create a new remote proxy record. + * + * @returns {Record} The remote proxy record. + */ + + proxy(data) { + if (this.#proxy[data.id]) { + return this.#proxy[data.id]; + } + return (this.#proxy[data.id] = Record.initiate(data)); + } + + /** + * Find records in the store. This is an alias + * for the select method, as the Ember Router + * will use this method if a Route's model + * hook has not been defined. + * + * @param {string} model - The model type. + * @param {undefined|string|Array} id - A specific record id. + * @returns {Promise} Promise object with the desired records. + */ + + async find() { + return this.select(...arguments); + } + + /** + * Query records in the store. This is an alias + * for the search method, as the Ember Router + * will use this method if a Route's model + * hook has not been defined. + * + * @param {string} model - The model type. + * @param {undefined|string|Array} id - A specific record id. + * @returns {Promise} Promise object with the desired records. + */ + + async query() { + return this.search(...arguments); + } + + /** + * Inject records into the local record cache. + * + * @param {string} model - The model type. + * @param {undefined|string|Array} id - A specific record id. + * @returns {Promise} Promise object with the removed records. + */ + + inject(items) { + let records = [].concat(items).map((item) => { + let tb = item.id.split(':')[0]; + try { + let cached = this.cached(tb, item.id); + + if (cached === undefined) { + cached = this.lookup(tb).create({ + id: item.id, + }); + this.#cache.get(tb).push(cached); + cached.ingest(item); + } else { + cached.ingest(item); + } + + return cached; + } catch (e) { + if (e instanceof DestroyedError) { + // ignore + } else { + throw e; + } + } + }); + + return Array.isArray(items) ? records : records[0]; + } + + /** + * Remove records from the local record cache. + * + * @param {string} model - The model type. + * @param {undefined|string|Array} id - A specific record id. + * @returns {Promise} Promise object with the removed records. + */ + + remove(ids) { + return [].concat(ids).map((id) => { + let model = id.split(':')[0]; + + this.cached(model, id).remove(); + + this.unload(model, id); + }); + } + + /** + * Unload records from the local record cache. + * The second argument can be a single id, an + * array of ids, or undefined. If no id is + * specified, then all records of the specified + * type will be unloaded from the cache. + * + * @param {string} model - The model type. + * @param {undefined|string|Array} id - A specific record id. + * @returns {Promise} Promise object with the removed records. + */ + + unload(model, id) { + assert('The model type must be a string', typeof model === 'string'); + + if (id !== undefined) { + let data = this.#cache.get(model); + + for (let i = 0; i < data.length; i++) { + if (data[i].id === id) { + data.splice(i, 1); + } + } + + return this.#cache.get(model); + } else { + return this.#cache.get(model).clear(); + } + } + + /** + * Retrieve records from the local record cache. + * The second argument can be a single id, an + * array of ids, or undefined. If no id is + * specified, then all records of the specified + * type will be retrieved from the cache. + * + * @param {string} model - The model type. + * @param {undefined|string|Array} id - A specific record id. + * @returns {Promise} Promise object with the cached records. + */ + + cached(model, id) { + assert('The model type must be a string', typeof model === 'string'); + + if (id !== undefined) { + if (Array.isArray(id)) { + return this.#cache.get(model).filter((v) => id.includes(v)); + } else { + return this.#cache.get(model).find((data) => data.id === id); + } + } else { + return this.#cache.get(model); + } + } + + /** + * Select records from the remote database server + * or from the local record cache if cached. The + * second argument can be a single id, an array + * of ids, or undefined. If no id is specified, + * then all records of the specified type will be + * retrieved from the database. This method will + * update the local record cache. + * + * @param {string} model - The model type. + * @param {undefined|string|Array} id - A specific record id. + * @param {Object} opts - Select options object. + * @returns {Promise} Promise object with the desired records. + */ + + select(thing, opts = {}) { + assert('The model type must be a string', typeof thing === 'string'); + + opts = Object.assign({}, { reload: false }, opts); + + let model = thing.includes(':') ? thing.split(':')[0] : thing; + let id = thing.includes(':') ? thing : undefined; + + if (this.#stack[thing] === undefined) { + let cached = this.cached(model, id); + + switch (true) { + case cached !== undefined && + cached.length !== 0 && + opts.reload !== true: + return cached; + case cached === undefined || + cached.length === 0 || + opts.reload === true: + this.#stack[thing] = this.remote(thing, opts); + return this.#stack[thing].then((result) => { + delete this.#stack[thing]; + + if (id) { + return result[0]; + } else { + return cached; + } + }); + } + } + + return this.#stack[thing]; + } + + /** + * Fetch records from the remote database server + * only, and inject the data into the cache. The + * second argument can be a single id, an array + * of ids, or undefined. If no id is specified, + * then all records of the specified type will be + * retrieved from the database. This method will + * update the local record cache. + * + * @param {string} model - The model type. + * @param {undefined|string|Array} id - A specific record id. + * @returns {Promise} Promise object with the desired records. + */ + + async remote(thing, opts = {}) { + assert('The model type must be a string', typeof thing === 'string'); + + if (this.#stash[thing] !== undefined) { + let server = await this.#stash[thing]; + delete this.#stash[thing]; + return this.inject(server); + } else { + let server = await this.surreal.select(thing); + if (opts.shoebox) this.#stash[thing] = server; + return this.inject(server); + } + } + + /** + * Creates a record in the database and in the + * local cache. If the create is not successful + * due to an error or permissions failure, then + * the record will not be stored locally. + * + * @param {string} model - The model type. + * @param {string} id - Optional record id. + * @param {Object} data - The record data. + * @returns {Promise} Promise object with the updated record. + */ + + async create(model, data) { + assert('The model type must be a string', typeof model === 'string'); + + try { + let record = this.lookup(model).create(data); + let server = await this.surreal.create(model, record.json); + return this.inject(server); + } catch (e) { + if (e instanceof DestroyedError) { + // ignore + } else { + throw e; + } + } + } + + /** + * Updates all record changes with the database. + * If the update is not successful due to an + * error or permissions failure, then the record + * will be rolled back. + * + * @param {Model} record - A record. + * @returns {Promise} Promise object with the updated record. + */ + + async modify(record, data) { + assert('You must pass a record to be modified', record instanceof Model); + + try { + let [server] = await this.surreal.patch(record.id, data); + record.ingest(server); + return record; + } catch (e) { + record.rollback(); + + throw e; + } + } + + /** + * Updates all record changes with the database. + * If the update is not successful due to an + * error or permissions failure, then the record + * will be rolled back. + * + * @param {Model} record - A record. + * @returns {Promise} Promise object with the updated record. + */ + + async update(thing, record) { + assert('You must pass a record to be updated', record instanceof Model); + + // + + try { + let [server] = await this.surreal.update(thing, record.json); + record.ingest(server); + return record; + } catch (e) { + record.rollback(); + + throw e; + } + } + + /** + * Deletes a record from the database and removes + * it from the local cache. If the delete is not + * successful due to an error or permissions + * failure, then the record will be rolled back. + * + * @param {Model} record - A record. + * @returns {Promise} Promise object with the delete record. + */ + + async delete(record) { + assert('You must pass a record to be deleted', record instanceof Model); + + try { + let tb = record.id.split(':')[0]; + await this.surreal.delete(record.id); + return this.unload(tb, record.id); + } catch (e) { + record.rollback(); + + throw e; + } + } + + /** + * Count the total number of records within the + * remote database server, for the given search + * query paramaters. The second argument is an + * object containing query parameters which will + * be built into a count(*) SQL query. This method + * will return a number with the total records. + * + * @param {string} model - The model type. + * @param {Object} query - The query parameters. + * @returns {Promise} Promise object with the total number of records. + */ + + async count(model, query = {}) { + let { text, vars } = count(model, query); + + let [json] = await this.surreal.query(text, vars); + + const { status, detail, result = [] } = json; + + if (status !== 'OK') throw new Error(detail); + + return (result && result[0] && result[0].count) || 0; + } + + /** + * Search for records within the remote database + * server, skipping records already in the cache. + * The second argument is an object containing + * query parameters which will be built into an + * SQL query. This method will not update records + * in the local cache. + * + * @param {string} model - The model type. + * @param {Object} query - The query parameters. + * @returns {Promise} Promise object with the desired matching records. + */ + + async search(model, query = {}) { + let result; + + let hash = hasher(model, query); + + let { text, vars } = table(model, query); + + if (this.#stash[hash] !== undefined) { + result = await this.#stash[hash]; + delete this.#stash[hash]; + } else { + let [json] = await this.surreal.query_raw(text, vars); + if (json.status !== 'OK') throw new Error(json.detail); + if (query.shoebox) this.#stash[hash] = json.result || []; + result = json.result || []; + } + + let records = [].concat(result).map((item) => { + try { + let cached = this.#cache.get(model).find((item) => { + return result[0].id === item.id; + }); + + if (cached === undefined) { + cached = this.lookup(model).create({ + id: item.id, + }); + this.#cache.get(model).push(cached); + cached.ingest(item); + } else { + cached.ingest(item); + } + + return cached; + } catch (e) { + if (e instanceof DestroyedError) { + // ignore + } else { + throw e; + } + } + }); + + return query.limit !== 1 ? records : records[0]; + } +} diff --git a/packages/surrealdb/addon/services/surreal.js b/packages/surrealdb/addon/services/surreal.js new file mode 100644 index 000000000..db22f1d31 --- /dev/null +++ b/packages/surrealdb/addon/services/surreal.js @@ -0,0 +1,327 @@ +import Service from '@ascua/service/evented'; +import Storage from '../classes/storage'; +import config from '@ascua/config'; +import unid from '../utils/unid'; +import Database from 'surrealdb.js'; +import { tracked } from '@glimmer/tracking'; +import { inject } from '@ember/service'; +import { assert } from '@ember/debug'; +import { cache } from '@ascua/decorators'; +import JWT from '../utils/jwt'; + +const defaults = { + id: unid(), + ns: undefined, + db: undefined, + NS: undefined, + DB: undefined, + url: Database.EU, +}; + +export default class Surreal extends Service { + @inject store; + + // The localStorage proxy class + // which enables us to write to + // localStorage if it is enabled. + + #ls = new Storage(); + + // The underlying instance of + // the Surreal database which + // connects to the server. + + #db = null; + + // The full configuration info for + // SurrealDB, including NS, DB, + // and custom endpoint options. + + #config = undefined; + + // The contents of the token + // used for authenticating with + // the Surreal database; + + @tracked token = null; + + // Whether we can proceed to + // transition to authenticated + // and unauthenticated routes. + + @tracked opened = false; + + // Whether there has been an + // attempt to authenticate the + // connection with the database. + + @tracked attempted = false; + + // Whether the connection to the + // Surreal database has been + // invalidated with no token. + + @tracked invalidated = false; + + // Whether the connection to the + // Surreal database has been + // authenticated with a token. + + @tracked authenticated = false; + + // Add a property for the parsed + // authentication token, so we + // can access it when needed. + + @cache get jwt() { + return JWT(this.token); + } + + // Setup the Surreal service, + // listening for token changes + // and connecting to the DB. + + constructor() { + super(...arguments); + + // Listen for changes to the local storage + // authentication key, and reauthenticate + // if the token changes from another tab. + + if (window && window.addEventListener) { + window.addEventListener('storage', (e) => { + if (e.key === 'surreal') { + this.authenticate(e.newValue); + } + }); + } + + // Get the configuration options + // which have been specified in the + // app environment config file. + + this.#config = Object.assign({}, defaults, config.surreal); + + this.#db = new Database(this.#config.url); + + // Get the token so that it populates + // the jwt getter value, so that the + // token contents can be accessed. + + this.token = this.#db.token = this.#ls.get('surreal'); + + assert( + 'Set the `surreal.ns` property in your environment config as a string', + this.#config.namespace !== undefined || + this.#config.NAMESPACE !== undefined + ); + + assert( + 'Set the `surreal.db` property in your environment config as a string', + this.#config.database !== undefined || this.#config.DATABASE !== undefined + ); + + // Open the websocket for the first + // time. This will automatically + // attempt to reconnect on failure. + + if (this.#config.uri) this.#config.url = `${this.#config.uri}/rpc`; + + // Open the websocket for the first + // time. This will automatically + // attempt to reconnect on failure. + + this.#db.connect(this.#config.url, this.#config); + + this.#db.use({ + namespace: this.#config.NAMESPACE, + database: this.#config.DATABASE, + }); + } + + // Tear down the Surreal service, + // ensuring we stop the pinger, + // and close the WebSocket. + + willDestroy() { + this.#db.close(); + + this.removeAllListeners(); + + // this.#db.removeAllListeners(); + + super.willDestroy(...arguments); + } + + // -------------------------------------------------- + // Direct methods + // -------------------------------------------------- + + use() { + return this.#db.use(...arguments); + } + + wait() { + return this.#db.wait(...arguments); + } + + close() { + return this.#db.close(...arguments); + } + + info() { + return this.#db.info(...arguments); + } + + let() { + return this.#db.let(...arguments); + } + + unset() { + return this.#db.unset(...arguments); + } + + query() { + return this.#db.query(...arguments); + } + + query_raw() { + return this.#db.query_raw(...arguments); + } + + select() { + return this.#db.select(...arguments); + } + + create() { + return this.#db.create(...arguments); + } + + update() { + return this.#db.update(...arguments); + } + + merge() { + return this.#db.merge(...arguments); + } + + patch() { + return this.#db.patch(...arguments); + } + + delete() { + return this.#db.delete(...arguments); + } + + live() { + return this.#db.live(...arguments); + } + + listenLive() { + return this.#db.listenLive(...arguments); + } + + // -------------------------------------------------- + // Authentication methods + // -------------------------------------------------- + + async signup() { + try { + let t = await this.#db.signup(...arguments); + this.#ls.set('surreal', t); + this.token = t; + this.#db.token = t; + this.attempted = true; + this.invalidated = false; + this.authenticated = true; + this.emit('attempted'); + this.emit('authenticated'); + return Promise.resolve(); + } catch (e) { + this.#ls.del('surreal'); + this.token = null; + this.#db.token = null; + this.attempted = true; + this.invalidated = true; + this.authenticated = false; + this.emit('attempted'); + this.emit('invalidated'); + return Promise.reject(); + } + } + + async signin() { + try { + let t = await this.#db.signin(...arguments); + this.#ls.set('surreal', t); + this.token = t; + this.#db.token = t; + this.attempted = true; + this.invalidated = false; + this.authenticated = true; + this.emit('attempted'); + this.emit('authenticated'); + return Promise.resolve(); + } catch (e) { + this.#ls.del('surreal'); + this.token = null; + this.#db.token = null; + this.attempted = true; + this.invalidated = true; + this.authenticated = false; + this.emit('attempted'); + this.emit('invalidated'); + return Promise.reject(); + } + } + + async invalidate() { + try { + await this.#db.invalidate(...arguments); + this.#ls.del('surreal'); + this.token = null; + this.#db.token = null; + this.attempted = true; + this.invalidated = true; + this.authenticated = false; + this.emit('attempted'); + this.emit('invalidated'); + return Promise.resolve(); + } catch (e) { + this.#ls.del('surreal'); + this.token = null; + this.#db.token = null; + this.attempted = true; + this.invalidated = true; + this.authenticated = false; + this.emit('attempted'); + this.emit('invalidated'); + return Promise.resolve(); + } + } + + async authenticate(t) { + try { + await this.#db.authenticate(...arguments); + this.#ls.set('surreal', t); + this.token = t; + this.#db.token = t; + this.attempted = true; + this.invalidated = false; + this.authenticated = true; + this.emit('attempted'); + this.emit('authenticated'); + return Promise.resolve(); + } catch (e) { + this.#ls.del('surreal'); + this.token = null; + this.#db.token = null; + this.attempted = true; + this.invalidated = true; + this.authenticated = false; + this.emit('attempted'); + this.emit('invalidated'); + return Promise.resolve(); + } + } +} diff --git a/packages/surrealdb/addon/utils/base.js b/packages/surrealdb/addon/utils/base.js new file mode 100644 index 000000000..5a3adde6a --- /dev/null +++ b/packages/surrealdb/addon/utils/base.js @@ -0,0 +1,30 @@ +import window from 'ember-window-mock'; + +export default function (str) { + var output = str.replace(/-/g, '+').replace(/_/g, '/'); + + switch (output.length % 4) { + case 0: + break; + case 2: + output += '=='; + break; + case 3: + output += '='; + break; + default: + throw 'Illegal base64url string!'; + } + + try { + return decodeURIComponent( + window.atob(str).replace(/(.)/g, (m, p) => { + var code = p.charCodeAt(0).toString(16).toUpperCase(); + if (code.length < 2) code = '0' + code; + return '%' + code; + }) + ); + } catch (err) { + return window.atob(output); + } +} diff --git a/packages/surrealdb/addon/utils/json.js b/packages/surrealdb/addon/utils/json.js new file mode 100644 index 000000000..b3dc23a29 --- /dev/null +++ b/packages/surrealdb/addon/utils/json.js @@ -0,0 +1,49 @@ +import meta from '../classes/meta'; + +export function full(object) { + let json = {}; + + json.id = object.id; + + meta.all(object).forEach((p) => { + switch (true) { + case typeof object[p.name] === 'object' && + object[p.name] !== null && + '_full' in object[p.name]: + return (json[p.name] = object[p.name]._full); + default: + return (json[p.name] = object[p.name]); + } + }); + + return JSON.parse( + JSON.stringify(json, (k, v) => { + return typeof v === 'undefined' ? null : v; + }) + ); +} + +export function some(object) { + let json = {}; + + json.id = object.id; + + meta.all(object).forEach((p) => { + switch (true) { + case p.readonly: + return; + case typeof object[p.name] === 'object' && + object[p.name] !== null && + '_some' in object[p.name]: + return (json[p.name] = object[p.name]._some); + default: + return (json[p.name] = object[p.name]); + } + }); + + return JSON.parse( + JSON.stringify(json, (k, v) => { + return typeof v === 'undefined' ? null : v; + }) + ); +} diff --git a/packages/surrealdb/addon/utils/jwt.js b/packages/surrealdb/addon/utils/jwt.js new file mode 100644 index 000000000..b83d3bce9 --- /dev/null +++ b/packages/surrealdb/addon/utils/jwt.js @@ -0,0 +1,15 @@ +import base64 from '../utils/base'; + +export default function (token, options = {}) { + if (typeof token !== 'string') { + return null; + } + + let pos = options.header === true ? 0 : 1; + + try { + return JSON.parse(base64(token.split('.')[pos])); + } catch (e) { + return null; + } +} diff --git a/packages/surrealdb/addon/utils/md5.js b/packages/surrealdb/addon/utils/md5.js new file mode 100644 index 000000000..e17bc5695 --- /dev/null +++ b/packages/surrealdb/addon/utils/md5.js @@ -0,0 +1,200 @@ +const hex_chr = [ + '0', + '1', + '2', + '3', + '4', + '5', + '6', + '7', + '8', + '9', + 'a', + 'b', + 'c', + 'd', + 'e', + 'f', +]; + +export default function (val = '') { + function md5cycle(x, k) { + let a = x[0], + b = x[1], + c = x[2], + d = x[3]; + + a = ff(a, b, c, d, k[0], 7, -680876936); + d = ff(d, a, b, c, k[1], 12, -389564586); + c = ff(c, d, a, b, k[2], 17, 606105819); + b = ff(b, c, d, a, k[3], 22, -1044525330); + a = ff(a, b, c, d, k[4], 7, -176418897); + d = ff(d, a, b, c, k[5], 12, 1200080426); + c = ff(c, d, a, b, k[6], 17, -1473231341); + b = ff(b, c, d, a, k[7], 22, -45705983); + a = ff(a, b, c, d, k[8], 7, 1770035416); + d = ff(d, a, b, c, k[9], 12, -1958414417); + c = ff(c, d, a, b, k[10], 17, -42063); + b = ff(b, c, d, a, k[11], 22, -1990404162); + a = ff(a, b, c, d, k[12], 7, 1804603682); + d = ff(d, a, b, c, k[13], 12, -40341101); + c = ff(c, d, a, b, k[14], 17, -1502002290); + b = ff(b, c, d, a, k[15], 22, 1236535329); + + a = gg(a, b, c, d, k[1], 5, -165796510); + d = gg(d, a, b, c, k[6], 9, -1069501632); + c = gg(c, d, a, b, k[11], 14, 643717713); + b = gg(b, c, d, a, k[0], 20, -373897302); + a = gg(a, b, c, d, k[5], 5, -701558691); + d = gg(d, a, b, c, k[10], 9, 38016083); + c = gg(c, d, a, b, k[15], 14, -660478335); + b = gg(b, c, d, a, k[4], 20, -405537848); + a = gg(a, b, c, d, k[9], 5, 568446438); + d = gg(d, a, b, c, k[14], 9, -1019803690); + c = gg(c, d, a, b, k[3], 14, -187363961); + b = gg(b, c, d, a, k[8], 20, 1163531501); + a = gg(a, b, c, d, k[13], 5, -1444681467); + d = gg(d, a, b, c, k[2], 9, -51403784); + c = gg(c, d, a, b, k[7], 14, 1735328473); + b = gg(b, c, d, a, k[12], 20, -1926607734); + + a = hh(a, b, c, d, k[5], 4, -378558); + d = hh(d, a, b, c, k[8], 11, -2022574463); + c = hh(c, d, a, b, k[11], 16, 1839030562); + b = hh(b, c, d, a, k[14], 23, -35309556); + a = hh(a, b, c, d, k[1], 4, -1530992060); + d = hh(d, a, b, c, k[4], 11, 1272893353); + c = hh(c, d, a, b, k[7], 16, -155497632); + b = hh(b, c, d, a, k[10], 23, -1094730640); + a = hh(a, b, c, d, k[13], 4, 681279174); + d = hh(d, a, b, c, k[0], 11, -358537222); + c = hh(c, d, a, b, k[3], 16, -722521979); + b = hh(b, c, d, a, k[6], 23, 76029189); + a = hh(a, b, c, d, k[9], 4, -640364487); + d = hh(d, a, b, c, k[12], 11, -421815835); + c = hh(c, d, a, b, k[15], 16, 530742520); + b = hh(b, c, d, a, k[2], 23, -995338651); + + a = ii(a, b, c, d, k[0], 6, -198630844); + d = ii(d, a, b, c, k[7], 10, 1126891415); + c = ii(c, d, a, b, k[14], 15, -1416354905); + b = ii(b, c, d, a, k[5], 21, -57434055); + a = ii(a, b, c, d, k[12], 6, 1700485571); + d = ii(d, a, b, c, k[3], 10, -1894986606); + c = ii(c, d, a, b, k[10], 15, -1051523); + b = ii(b, c, d, a, k[1], 21, -2054922799); + a = ii(a, b, c, d, k[8], 6, 1873313359); + d = ii(d, a, b, c, k[15], 10, -30611744); + c = ii(c, d, a, b, k[6], 15, -1560198380); + b = ii(b, c, d, a, k[13], 21, 1309151649); + a = ii(a, b, c, d, k[4], 6, -145523070); + d = ii(d, a, b, c, k[11], 10, -1120210379); + c = ii(c, d, a, b, k[2], 15, 718787259); + b = ii(b, c, d, a, k[9], 21, -343485551); + + x[0] = add32(a, x[0]); + x[1] = add32(b, x[1]); + x[2] = add32(c, x[2]); + x[3] = add32(d, x[3]); + } + + function cmn(q, a, b, x, s, t) { + a = add32(add32(a, q), add32(x, t)); + return add32((a << s) | (a >>> (32 - s)), b); + } + + function ff(a, b, c, d, x, s, t) { + return cmn((b & c) | (~b & d), a, b, x, s, t); + } + + function gg(a, b, c, d, x, s, t) { + return cmn((b & d) | (c & ~d), a, b, x, s, t); + } + + function hh(a, b, c, d, x, s, t) { + return cmn(b ^ c ^ d, a, b, x, s, t); + } + + function ii(a, b, c, d, x, s, t) { + return cmn(c ^ (b | ~d), a, b, x, s, t); + } + + function add32(a, b) { + return (a + b) & 0xffffffff; + } + + function md51(s) { + let n = s.length, + state = [1732584193, -271733879, -1732584194, 271733878], + i; + for (i = 64; i <= s.length; i += 64) { + md5cycle(state, md5blk(s.substring(i - 64, i))); + } + s = s.substring(i - 64); + let tail = [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]; + for (i = 0; i < s.length; i++) { + tail[i >> 2] |= s.charCodeAt(i) << (i % 4 << 3); + } + tail[i >> 2] |= 0x80 << (i % 4 << 3); + if (i > 55) { + md5cycle(state, tail); + for (i = 0; i < 16; i++) { + tail[i] = 0; + } + } + tail[14] = n * 8; + md5cycle(state, tail); + return state; + } + + /* there needs to be support for Unicode here, + * unless we pretend that we can redefine the MD-5 + * algorithm for multi-byte characters (perhaps + * by adding every four 16-bit characters and + * shortening the sum to 32 bits). Otherwise + * I suggest performing MD-5 as if every character + * was two bytes--e.g., 0040 0025 = @%--but then + * how will an ordinary MD-5 sum be matched? + * There is no way to standardize text to something + * like UTF-8 before transformation; speed cost is + * utterly prohibitive. The JavaScript standard + * itself needs to look at this: it should start + * providing access to strings as preformed UTF-8 + * 8-bit unsigned value arrays. + */ + function md5blk(s) { + /* I figured global was faster. */ + let md5blks = [], + i; /* Andy King said do it this way. */ + for (i = 0; i < 64; i += 4) { + md5blks[i >> 2] = + s.charCodeAt(i) + + (s.charCodeAt(i + 1) << 8) + + (s.charCodeAt(i + 2) << 16) + + (s.charCodeAt(i + 3) << 24); + } + return md5blks; + } + + function rhex(n) { + let s = '', + j = 0; + for (; j < 4; j++) { + s += hex_chr[(n >> (j * 8 + 4)) & 0x0f] + hex_chr[(n >> (j * 8)) & 0x0f]; + } + return s; + } + + function hex(x) { + for (let i = 0; i < x.length; i++) { + x[i] = rhex(x[i]); + } + return x.join(''); + } + + function md5(s) { + return hex(md51(s)); + } + + return md5(val); +} diff --git a/packages/surrealdb/addon/utils/test.js b/packages/surrealdb/addon/utils/test.js new file mode 100644 index 000000000..6d5e27000 --- /dev/null +++ b/packages/surrealdb/addon/utils/test.js @@ -0,0 +1,14 @@ +import window from 'ember-window-mock'; + +export default function () { + try { + if (!window.localStorage) throw 'exception'; + if (!window.sessionStorage) throw 'exception'; + localStorage.setItem('test', 'OK'); + localStorage.removeItem('test'); + return true; + } catch (e) { + /**/ + } + return false; +} diff --git a/packages/surrealdb/addon/utils/unid.js b/packages/surrealdb/addon/utils/unid.js new file mode 100644 index 000000000..61df68347 --- /dev/null +++ b/packages/surrealdb/addon/utils/unid.js @@ -0,0 +1,24 @@ +import uniq from './uniq'; +import test from './test'; + +import window from 'ember-window-mock'; + +const persisted = test(); + +export default function () { + if (persisted === false) { + return uniq(64); + } + + if (persisted === true) { + let session = window.localStorage.getItem('session'); + + if (session === null || session.length != 64) { + session = uniq(64); + } + + window.localStorage.setItem('session', session); + + return session; + } +} diff --git a/packages/surrealdb/addon/utils/uniq.js b/packages/surrealdb/addon/utils/uniq.js new file mode 100644 index 000000000..b3d31cfde --- /dev/null +++ b/packages/surrealdb/addon/utils/uniq.js @@ -0,0 +1,25 @@ +const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; +import window from 'ember-window-mock'; + +const crypto = window.crypto || window.msCrypto; + +const uint32 = 4294967295; + +const random = function (size) { + if (crypto !== undefined && crypto.getRandomValues !== undefined) { + return crypto.getRandomValues(new Uint32Array(size)); + } + + return [...Array(size)].map(() => { + return Math.abs((Math.random() * uint32) | 0); + }); +}; + +export default function (size = 64) { + return [].slice + .call(random(size)) + .map((v) => { + return chars[v % chars.length]; + }) + .join(''); +} diff --git a/packages/surrealdb/app/instance-initializers/session.js b/packages/surrealdb/app/instance-initializers/session.js new file mode 100644 index 000000000..acf45d11e --- /dev/null +++ b/packages/surrealdb/app/instance-initializers/session.js @@ -0,0 +1 @@ +export { default } from '@ascua/surrealdb/instance-initializers/session'; diff --git a/packages/surrealdb/app/instance-initializers/store.js b/packages/surrealdb/app/instance-initializers/store.js new file mode 100644 index 000000000..9d23d2492 --- /dev/null +++ b/packages/surrealdb/app/instance-initializers/store.js @@ -0,0 +1 @@ +export { default } from '@ascua/surrealdb/instance-initializers/store'; diff --git a/packages/surrealdb/app/instance-initializers/surreal.js b/packages/surrealdb/app/instance-initializers/surreal.js new file mode 100644 index 000000000..9ff6e8247 --- /dev/null +++ b/packages/surrealdb/app/instance-initializers/surreal.js @@ -0,0 +1 @@ +export { default } from '@ascua/surrealdb/instance-initializers/surreal'; diff --git a/packages/surrealdb/app/services/session.js b/packages/surrealdb/app/services/session.js new file mode 100644 index 000000000..e4bf5e02e --- /dev/null +++ b/packages/surrealdb/app/services/session.js @@ -0,0 +1 @@ +export { default } from '@ascua/surrealdb/services/session'; diff --git a/packages/surrealdb/app/services/store.js b/packages/surrealdb/app/services/store.js new file mode 100644 index 000000000..5c7455656 --- /dev/null +++ b/packages/surrealdb/app/services/store.js @@ -0,0 +1 @@ +export { default } from '@ascua/surrealdb/services/store'; diff --git a/packages/surrealdb/app/services/surreal.js b/packages/surrealdb/app/services/surreal.js new file mode 100644 index 000000000..3c95679f3 --- /dev/null +++ b/packages/surrealdb/app/services/surreal.js @@ -0,0 +1 @@ +export { default } from '@ascua/surrealdb/services/surreal'; diff --git a/packages/surrealdb/config/optional-features.json b/packages/surrealdb/config/optional-features.json new file mode 100644 index 000000000..b26286e2e --- /dev/null +++ b/packages/surrealdb/config/optional-features.json @@ -0,0 +1,6 @@ +{ + "application-template-wrapper": false, + "default-async-observers": true, + "jquery-integration": false, + "template-only-glimmer-components": true +} diff --git a/packages/surrealdb/ember-cli-build.js b/packages/surrealdb/ember-cli-build.js new file mode 100644 index 000000000..366cbe507 --- /dev/null +++ b/packages/surrealdb/ember-cli-build.js @@ -0,0 +1,25 @@ +'use strict'; + +const EmberAddon = require('ember-cli/lib/broccoli/ember-addon'); + +module.exports = function (defaults) { + const app = new EmberAddon(defaults, { + // Add options here + }); + + /* + This build file specifies the options for the dummy test app of this + addon, located in `/tests/dummy` + This build file does *not* influence how the addon or the app using it + behave. You most likely want to be modifying `./index.js` or app's build file + */ + + const { maybeEmbroider } = require('@embroider/test-setup'); + return maybeEmbroider(app, { + skipBabel: [ + { + package: 'qunit', + }, + ], + }); +}; diff --git a/packages/surrealdb/index.js b/packages/surrealdb/index.js new file mode 100644 index 000000000..e87567961 --- /dev/null +++ b/packages/surrealdb/index.js @@ -0,0 +1,47 @@ +'use strict'; + +const Filter = require('broccoli-persistent-filter'); + +class SQLFilter extends Filter { + constructor(inputNode, options) { + super(inputNode, options); + this.extensions = ['sql']; + this.targetExtension = 'js'; + } + + processString(source) { + return 'export default ' + JSON.stringify(source) + ';'; + } +} + +module.exports = { + name: require('./package').name, + + included(app) { + this._super.included.apply(this, ...arguments); + + app.import('vendor/diffmatchpatch.js'); + + app.import('vendor/dmp.js', { + exports: { dmp: ['default'] }, + }); + }, + + setupPreprocessorRegistry(type, registry) { + if (type === 'parent') { + registry.add('js', { + name: 'surrealdb', + ext: ['sql'], + toTree(tree) { + return new SQLFilter(tree); + }, + }); + } + }, + + contentFor(type) { + if (type === 'head') { + return ''; + } + }, +}; diff --git a/packages/surrealdb/package.json b/packages/surrealdb/package.json new file mode 100644 index 000000000..27e700805 --- /dev/null +++ b/packages/surrealdb/package.json @@ -0,0 +1,111 @@ +{ + "name": "@ascua/surrealdb", + "version": "0.0.237", + "description": "Small description for @ascua/surrealdb goes here", + "keywords": [ + "ember-addon" + ], + "homepage": "https://abcum.github.io/ascua", + "repository": { + "type": "git", + "url": "https://github.com/abcum/ascua.git" + }, + "license": "MIT", + "author": { + "name": "Tobie Morgan Hitchcock", + "url": "https://abcum.com" + }, + "scripts": { + "build": "ember build --environment=production", + "lint": "concurrently \"npm:lint:*(!fix)\" --names \"lint:\"", + "lint:css": "stylelint \"**/*.css\"", + "lint:css:fix": "concurrently \"npm:lint:css -- --fix\"", + "lint:fix": "concurrently \"npm:lint:*:fix\" --names \"fix:\"", + "lint:hbs": "ember-template-lint .", + "lint:hbs:fix": "ember-template-lint . --fix", + "lint:js": "eslint . --cache", + "lint:js:fix": "eslint . --fix", + "start": "ember serve", + "test": "concurrently \"npm:lint\" \"npm:test:*\" --names \"lint,test:\"", + "test:ember": "ember test", + "test:ember-compatibility": "ember try:each" + }, + "dependencies": { + "@ascua/config": "file:../config", + "@ascua/context": "file:../context", + "@ascua/decorators": "file:../decorators", + "@ascua/proxy": "file:../proxy", + "@ascua/queue": "file:../queue", + "@ascua/service": "file:../service", + "@babel/core": "^7.23.2", + "broccoli-persistent-filter": "^3.1.2", + "ember-auto-import": "2.6.3", + "ember-cli-babel": "^8.2.0", + "ember-cli-htmlbars": "^6.3.0", + "ember-window-mock": "^0.8.1", + "surrealdb.js": "^0.11.0", + "tracked-built-ins": "^3.2.0", + "ws": "^8.5.0" + }, + "devDependencies": { + "@babel/eslint-parser": "^7.22.15", + "@babel/plugin-proposal-decorators": "^7.23.2", + "@ember/optional-features": "^2.0.0", + "@ember/test-helpers": "^3.2.0", + "@embroider/test-setup": "^3.0.2", + "@glimmer/component": "^1.1.2", + "@glimmer/tracking": "^1.1.2", + "broccoli-asset-rev": "^3.0.0", + "concurrently": "^8.2.2", + "ember-cli": "~5.4.0", + "ember-cli-clean-css": "^3.0.0", + "ember-cli-dependency-checker": "^3.3.2", + "ember-cli-inject-live-reload": "^2.1.0", + "ember-cli-sri": "^2.1.1", + "ember-cli-terser": "^4.0.2", + "ember-load-initializers": "^2.1.2", + "ember-page-title": "^8.0.0", + "ember-qunit": "^8.0.1", + "ember-resolver": "^11.0.1", + "ember-source": "~5.4.0", + "ember-source-channel-url": "^3.0.0", + "ember-template-lint": "^5.11.2", + "ember-truth-helpers": "^4.0.3", + "ember-try": "^3.0.0", + "eslint": "^8.52.0", + "eslint-config-prettier": "^9.0.0", + "eslint-plugin-ember": "^11.11.1", + "eslint-plugin-n": "^16.2.0", + "eslint-plugin-prettier": "^5.0.1", + "eslint-plugin-qunit": "^8.0.1", + "loader.js": "^4.7.0", + "prettier": "^3.0.3", + "qunit": "^2.20.0", + "qunit-dom": "^2.0.0", + "stylelint": "^15.11.0", + "stylelint-config-standard": "^34.0.0", + "stylelint-prettier": "^4.0.2", + "webpack": "^5.89.0" + }, + "peerDependencies": { + "ember-source": ">= 4.0.0" + }, + "publishConfig": { + "access": "public" + }, + "ember": { + "edition": "octane" + }, + "ember-addon": { + "demoURL": "https://abcum.github.io/ascua", + "before": [ + "ember-cli-babel" + ], + "fastbootDependencies": [ + "ws" + ] + }, + "fastbootDependencies": [ + "ws" + ] +} diff --git a/packages/surrealdb/testem.js b/packages/surrealdb/testem.js new file mode 100644 index 000000000..ed2f37124 --- /dev/null +++ b/packages/surrealdb/testem.js @@ -0,0 +1,23 @@ +'use strict'; + +module.exports = { + test_page: 'tests/index.html?hidepassed', + disable_watching: true, + launch_in_ci: ['Chrome'], + launch_in_dev: ['Chrome'], + browser_start_timeout: 120, + browser_args: { + Chrome: { + ci: [ + // --no-sandbox is needed when running Chrome inside a container + process.env.CI ? '--no-sandbox' : null, + '--headless', + '--disable-dev-shm-usage', + '--disable-software-rasterizer', + '--mute-audio', + '--remote-debugging-port=0', + '--window-size=1440,900', + ].filter(Boolean), + }, + }, +}; diff --git a/packages/surrealdb/tests/dummy/app/app.js b/packages/surrealdb/tests/dummy/app/app.js new file mode 100644 index 000000000..523bad60c --- /dev/null +++ b/packages/surrealdb/tests/dummy/app/app.js @@ -0,0 +1,12 @@ +import Application from '@ember/application'; +import Resolver from 'ember-resolver'; +import loadInitializers from 'ember-load-initializers'; +import config from 'dummy/config/environment'; + +export default class App extends Application { + modulePrefix = config.modulePrefix; + podModulePrefix = config.podModulePrefix; + Resolver = Resolver; +} + +loadInitializers(App, config.modulePrefix); diff --git a/packages/surrealdb/tests/dummy/app/application/controller.js b/packages/surrealdb/tests/dummy/app/application/controller.js new file mode 100644 index 000000000..f72611723 --- /dev/null +++ b/packages/surrealdb/tests/dummy/app/application/controller.js @@ -0,0 +1,19 @@ +import Controller from '@ember/controller'; +import { inject as service } from '@ember/service'; +import { action } from '@ember/object'; + +export default class ApplicationController extends Controller { + @service surreal; + @service router; + + @action + async signout() { + try { + this.surreal.invalidate().then(() => { + return this.router.replaceWith('signin'); + }); + } catch (e) { + // Signin failed + } + } +} diff --git a/packages/surrealdb/tests/dummy/app/application/route.js b/packages/surrealdb/tests/dummy/app/application/route.js new file mode 100644 index 000000000..b9e88a6be --- /dev/null +++ b/packages/surrealdb/tests/dummy/app/application/route.js @@ -0,0 +1,36 @@ +import Route from '@ember/routing/route'; +import { inject as service } from '@ember/service'; +import { attempted } from '@ascua/surrealdb'; +import Storage from '@ascua/surrealdb/classes/storage'; + +export default +@attempted +class ApplicationRoute extends Route { + @service surreal; + @service router; + + #ls = new Storage(); + + beforeModel(transition) { + this.token = this.#ls.get('surreal'); + + let person_id = transition?.to?.params?.person_id; + + if (this.token && !this.surreal.authenticated) { + return this.surreal + .authenticate(this.token) + .then(() => { + if (person_id) { + this.router.transitionTo('surreal.person', person_id); + } else { + this.router.transitionTo('surreal'); + } + }) + .catch(() => { + this.router.transitionTo('signin'); + }); + } + + this.router.transitionTo('signin'); + } +} diff --git a/packages/surrealdb/tests/dummy/app/application/template.hbs b/packages/surrealdb/tests/dummy/app/application/template.hbs new file mode 100644 index 000000000..8ad1410f9 --- /dev/null +++ b/packages/surrealdb/tests/dummy/app/application/template.hbs @@ -0,0 +1,17 @@ +{{page-title "SurrealDB"}} + +

SurrealDB Ember Demo

+ + + +
{{outlet}}
\ No newline at end of file diff --git a/packages/surrealdb/tests/dummy/app/components/.gitkeep b/packages/surrealdb/tests/dummy/app/components/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/packages/surrealdb/tests/dummy/app/components/sign-in-form.hbs b/packages/surrealdb/tests/dummy/app/components/sign-in-form.hbs new file mode 100644 index 000000000..c93b81755 --- /dev/null +++ b/packages/surrealdb/tests/dummy/app/components/sign-in-form.hbs @@ -0,0 +1,15 @@ +
+ + + +
\ No newline at end of file diff --git a/packages/surrealdb/tests/dummy/app/components/sign-in-form.js b/packages/surrealdb/tests/dummy/app/components/sign-in-form.js new file mode 100644 index 000000000..3277c44bd --- /dev/null +++ b/packages/surrealdb/tests/dummy/app/components/sign-in-form.js @@ -0,0 +1,28 @@ +import Component from '@glimmer/component'; +import { inject } from '@ember/service'; +import { action } from '@ember/object'; +import config from '@ascua/config'; + +export default class SignInForm extends Component { + @inject surreal; + @inject router; + + email = 'email'; + pass = 'pass'; + + @action async signin() { + try { + await this.surreal.signin({ + namespace: config.surreal.namespace, + database: config.surreal.database, + scope: 'account', + email: this.email, + pass: this.pass, + }); + + this.router.transitionTo('surreal.index'); + } catch (e) { + // Signin failed + } + } +} diff --git a/packages/surrealdb/tests/dummy/app/components/sign-up-form.hbs b/packages/surrealdb/tests/dummy/app/components/sign-up-form.hbs new file mode 100644 index 000000000..43f5b8920 --- /dev/null +++ b/packages/surrealdb/tests/dummy/app/components/sign-up-form.hbs @@ -0,0 +1,15 @@ +
+ + + +
\ No newline at end of file diff --git a/packages/surrealdb/tests/dummy/app/components/sign-up-form.js b/packages/surrealdb/tests/dummy/app/components/sign-up-form.js new file mode 100644 index 000000000..fca6597dd --- /dev/null +++ b/packages/surrealdb/tests/dummy/app/components/sign-up-form.js @@ -0,0 +1,28 @@ +import Component from '@glimmer/component'; +import { inject } from '@ember/service'; +import { action } from '@ember/object'; +import config from '@ascua/config'; + +export default class SignUpForm extends Component { + @inject surreal; + @inject router; + + email = ''; + pass = ''; + + @action async signup() { + try { + await this.surreal.signup({ + namespace: config.surreal.namespace, + database: config.surreal.database, + scope: 'account', + email: this.email, + pass: this.pass, + }); + + this.router.transitionTo('surreal.index'); + } catch (e) { + // Signup failed + } + } +} diff --git a/packages/surrealdb/tests/dummy/app/controllers/.gitkeep b/packages/surrealdb/tests/dummy/app/controllers/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/packages/surrealdb/tests/dummy/app/helpers/.gitkeep b/packages/surrealdb/tests/dummy/app/helpers/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/packages/surrealdb/tests/dummy/app/index.html b/packages/surrealdb/tests/dummy/app/index.html new file mode 100644 index 000000000..8c195bc41 --- /dev/null +++ b/packages/surrealdb/tests/dummy/app/index.html @@ -0,0 +1,24 @@ + + + + + Dummy + + + + {{content-for "head"}} + + + + + {{content-for "head-footer"}} + + + {{content-for "body"}} + + + + + {{content-for "body-footer"}} + + diff --git a/packages/surrealdb/tests/dummy/app/models/.gitkeep b/packages/surrealdb/tests/dummy/app/models/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/packages/surrealdb/tests/dummy/app/models/person.js b/packages/surrealdb/tests/dummy/app/models/person.js new file mode 100644 index 000000000..fcb8db854 --- /dev/null +++ b/packages/surrealdb/tests/dummy/app/models/person.js @@ -0,0 +1,6 @@ +import Model from '@ascua/surrealdb/model'; +import { string } from '@ascua/surrealdb/field'; + +export default class Person extends Model { + @string name; +} diff --git a/packages/surrealdb/tests/dummy/app/models/user.js b/packages/surrealdb/tests/dummy/app/models/user.js new file mode 100644 index 000000000..779923801 --- /dev/null +++ b/packages/surrealdb/tests/dummy/app/models/user.js @@ -0,0 +1,6 @@ +import Model from '@ascua/surrealdb/model'; +import { string } from '@ascua/surrealdb/field'; + +export default class User extends Model { + @string email; +} diff --git a/packages/surrealdb/tests/dummy/app/router.js b/packages/surrealdb/tests/dummy/app/router.js new file mode 100644 index 000000000..4baef66c5 --- /dev/null +++ b/packages/surrealdb/tests/dummy/app/router.js @@ -0,0 +1,15 @@ +import EmberRouter from '@ember/routing/router'; +import config from 'dummy/config/environment'; + +export default class Router extends EmberRouter { + location = config.locationType; + rootURL = config.rootURL; +} + +Router.map(function () { + this.route('surreal', function () { + this.route('person', { path: '/:person_id' }); + }); + this.route('signup'); + this.route('signin'); +}); diff --git a/packages/surrealdb/tests/dummy/app/routes/.gitkeep b/packages/surrealdb/tests/dummy/app/routes/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/packages/surrealdb/tests/dummy/app/signin/route.js b/packages/surrealdb/tests/dummy/app/signin/route.js new file mode 100644 index 000000000..6254fb2fd --- /dev/null +++ b/packages/surrealdb/tests/dummy/app/signin/route.js @@ -0,0 +1,8 @@ +import Route from '@ember/routing/route'; +import { invalidated } from '@ascua/surrealdb'; + +export default +@invalidated +class extends Route { + redirectIfAuthenticated = 'surreal'; +} diff --git a/packages/surrealdb/tests/dummy/app/signin/template.hbs b/packages/surrealdb/tests/dummy/app/signin/template.hbs new file mode 100644 index 000000000..3bb46cb26 --- /dev/null +++ b/packages/surrealdb/tests/dummy/app/signin/template.hbs @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/surrealdb/tests/dummy/app/signup/route.js b/packages/surrealdb/tests/dummy/app/signup/route.js new file mode 100644 index 000000000..6254fb2fd --- /dev/null +++ b/packages/surrealdb/tests/dummy/app/signup/route.js @@ -0,0 +1,8 @@ +import Route from '@ember/routing/route'; +import { invalidated } from '@ascua/surrealdb'; + +export default +@invalidated +class extends Route { + redirectIfAuthenticated = 'surreal'; +} diff --git a/packages/surrealdb/tests/dummy/app/signup/template.hbs b/packages/surrealdb/tests/dummy/app/signup/template.hbs new file mode 100644 index 000000000..4b92971fe --- /dev/null +++ b/packages/surrealdb/tests/dummy/app/signup/template.hbs @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/surrealdb/tests/dummy/app/styles/app.css b/packages/surrealdb/tests/dummy/app/styles/app.css new file mode 100644 index 000000000..2763afa4c --- /dev/null +++ b/packages/surrealdb/tests/dummy/app/styles/app.css @@ -0,0 +1 @@ +/* Ember supports plain CSS out of the box. More info: https://cli.emberjs.com/release/advanced-use/stylesheets/ */ diff --git a/packages/surrealdb/tests/dummy/app/surreal/index/controller.js b/packages/surrealdb/tests/dummy/app/surreal/index/controller.js new file mode 100644 index 000000000..5843304ca --- /dev/null +++ b/packages/surrealdb/tests/dummy/app/surreal/index/controller.js @@ -0,0 +1,57 @@ +import Controller from '@ember/controller'; +import { action } from '@ember/object'; +import { inject as service } from '@ember/service'; +import { tracked } from '@glimmer/tracking'; + +export default class SurrealIndexController extends Controller { + @service surreal; + @service store; + + @tracked data; + @tracked name = ''; + @tracked searchName = ''; + + @action + async loadData(model) { + this.model = await this.store.select(model, { reload: true }); + } + + @action + async signin() { + await this.surreal.signin({ + user: 'root', + pass: 'root', + }); + } + + @action + async addPerson() { + if (!this.name) { + return; + } + + await this.store.create('person', { + id: `person:${this.name}`, + name: this.name, + }); + + this.name = ''; + } + + @action + async deletePerson(person) { + await person.delete(); + } + + @action + async searchPerson() { + await this.store.search('person', { + where: [`name = "${this.searchName}"`], + }); + } + + @action + async closeConnection() { + await this.surreal.close(); + } +} diff --git a/packages/surrealdb/tests/dummy/app/surreal/index/route.js b/packages/surrealdb/tests/dummy/app/surreal/index/route.js new file mode 100644 index 000000000..fd33dcf01 --- /dev/null +++ b/packages/surrealdb/tests/dummy/app/surreal/index/route.js @@ -0,0 +1,14 @@ +import Route from '@ember/routing/route'; +import { inject as service } from '@ember/service'; +import { authenticated } from '@ascua/surrealdb'; + +export default +@authenticated +class SurrealIndexRoute extends Route { + redirectIfInvalidated = 'signin'; + @service store; + + async model() { + return await this.store.select('person'); + } +} diff --git a/packages/surrealdb/tests/dummy/app/surreal/index/template.hbs b/packages/surrealdb/tests/dummy/app/surreal/index/template.hbs new file mode 100644 index 000000000..711f0f9f2 --- /dev/null +++ b/packages/surrealdb/tests/dummy/app/surreal/index/template.hbs @@ -0,0 +1,42 @@ +{{#unless this.surreal.authenticated}} + +{{/unless}} + + + +
+ + + +
+ +
+ + + +
+ +
+ + + + + + + + + {{#each this.model as |item|}} + + + + + + {{else}} + Click load data button to display something here!! + {{/each}} + +
NameAction
{{item.name}}View Person
+
\ No newline at end of file diff --git a/packages/surrealdb/tests/dummy/app/surreal/person/controller.js b/packages/surrealdb/tests/dummy/app/surreal/person/controller.js new file mode 100644 index 000000000..5a32ad46f --- /dev/null +++ b/packages/surrealdb/tests/dummy/app/surreal/person/controller.js @@ -0,0 +1,19 @@ +import Controller from '@ember/controller'; +import { action } from '@ember/object'; +import { inject as service } from '@ember/service'; + +export default class SurrealPersonController extends Controller { + @service store; + + @action + async updatePerson(item) { + await this.store.update(item.id, item); + } + + @action + async modifyPerson(item) { + await this.store.modify(item, [ + { op: 'replace', path: '/name', value: item.name }, + ]); + } +} diff --git a/packages/surrealdb/tests/dummy/app/surreal/person/route.js b/packages/surrealdb/tests/dummy/app/surreal/person/route.js new file mode 100644 index 000000000..8231a008d --- /dev/null +++ b/packages/surrealdb/tests/dummy/app/surreal/person/route.js @@ -0,0 +1,18 @@ +import Route from '@ember/routing/route'; +import { inject as service } from '@ember/service'; +import { authenticated } from '@ascua/surrealdb'; + +export default +@authenticated +class SurrealPersonRoute extends Route { + redirectIfInvalidated = 'signin'; + + @service store; + @service surreal; + + model({ person_id }) { + return this.store.select(person_id, { reload: true }).then((person) => { + return person; + }); + } +} diff --git a/packages/surrealdb/tests/dummy/app/surreal/person/template.hbs b/packages/surrealdb/tests/dummy/app/surreal/person/template.hbs new file mode 100644 index 000000000..f53d9e994 --- /dev/null +++ b/packages/surrealdb/tests/dummy/app/surreal/person/template.hbs @@ -0,0 +1,8 @@ +
+ + + + +
\ No newline at end of file diff --git a/packages/surrealdb/tests/dummy/config/ember-try.js b/packages/surrealdb/tests/dummy/config/ember-try.js new file mode 100644 index 000000000..93b9f51f9 --- /dev/null +++ b/packages/surrealdb/tests/dummy/config/ember-try.js @@ -0,0 +1,53 @@ +'use strict'; + +const getChannelURL = require('ember-source-channel-url'); +const { embroiderSafe, embroiderOptimized } = require('@embroider/test-setup'); + +module.exports = async function () { + return { + scenarios: [ + { + name: 'ember-lts-4.8', + npm: { + devDependencies: { + 'ember-source': '~4.8.0', + }, + }, + }, + { + name: 'ember-lts-4.12', + npm: { + devDependencies: { + 'ember-source': '~4.12.0', + }, + }, + }, + { + name: 'ember-release', + npm: { + devDependencies: { + 'ember-source': await getChannelURL('release'), + }, + }, + }, + { + name: 'ember-beta', + npm: { + devDependencies: { + 'ember-source': await getChannelURL('beta'), + }, + }, + }, + { + name: 'ember-canary', + npm: { + devDependencies: { + 'ember-source': await getChannelURL('canary'), + }, + }, + }, + embroiderSafe(), + embroiderOptimized(), + ], + }; +}; diff --git a/packages/surrealdb/tests/dummy/config/environment.js b/packages/surrealdb/tests/dummy/config/environment.js new file mode 100644 index 000000000..6cabdb7f2 --- /dev/null +++ b/packages/surrealdb/tests/dummy/config/environment.js @@ -0,0 +1,55 @@ +'use strict'; + +module.exports = function (environment) { + const ENV = { + modulePrefix: 'dummy', + environment, + rootURL: '/', + locationType: 'history', + EmberENV: { + EXTEND_PROTOTYPES: false, + FEATURES: { + // Here you can enable experimental features on an ember canary build + // e.g. EMBER_NATIVE_DECORATOR_SUPPORT: true + }, + }, + surreal: { + namespace: 'test', + database: 'test', + uri: 'http://127.0.0.1:8000', + username: 'root', + password: 'root', + }, + + APP: { + // Here you can pass flags/options to your application instance + // when it is created + }, + }; + + if (environment === 'development') { + // ENV.APP.LOG_RESOLVER = true; + // ENV.APP.LOG_ACTIVE_GENERATION = true; + // ENV.APP.LOG_TRANSITIONS = true; + // ENV.APP.LOG_TRANSITIONS_INTERNAL = true; + // ENV.APP.LOG_VIEW_LOOKUPS = true; + } + + if (environment === 'test') { + // Testem prefers this... + ENV.locationType = 'none'; + + // keep test console output quieter + ENV.APP.LOG_ACTIVE_GENERATION = false; + ENV.APP.LOG_VIEW_LOOKUPS = false; + + ENV.APP.rootElement = '#ember-testing'; + ENV.APP.autoboot = false; + } + + if (environment === 'production') { + // here you can enable a production-specific feature + } + + return ENV; +}; diff --git a/packages/surrealdb/tests/dummy/config/optional-features.json b/packages/surrealdb/tests/dummy/config/optional-features.json new file mode 100644 index 000000000..b26286e2e --- /dev/null +++ b/packages/surrealdb/tests/dummy/config/optional-features.json @@ -0,0 +1,6 @@ +{ + "application-template-wrapper": false, + "default-async-observers": true, + "jquery-integration": false, + "template-only-glimmer-components": true +} diff --git a/packages/surrealdb/tests/dummy/config/targets.js b/packages/surrealdb/tests/dummy/config/targets.js new file mode 100644 index 000000000..1e48e0599 --- /dev/null +++ b/packages/surrealdb/tests/dummy/config/targets.js @@ -0,0 +1,11 @@ +'use strict'; + +const browsers = [ + 'last 1 Chrome versions', + 'last 1 Firefox versions', + 'last 1 Safari versions', +]; + +module.exports = { + browsers, +}; diff --git a/packages/surrealdb/tests/dummy/public/robots.txt b/packages/surrealdb/tests/dummy/public/robots.txt new file mode 100644 index 000000000..f5916452e --- /dev/null +++ b/packages/surrealdb/tests/dummy/public/robots.txt @@ -0,0 +1,3 @@ +# http://www.robotstxt.org +User-agent: * +Disallow: diff --git a/packages/surrealdb/tests/helpers/index.js b/packages/surrealdb/tests/helpers/index.js new file mode 100644 index 000000000..d37dd6806 --- /dev/null +++ b/packages/surrealdb/tests/helpers/index.js @@ -0,0 +1,42 @@ +import { + setupApplicationTest as upstreamSetupApplicationTest, + setupRenderingTest as upstreamSetupRenderingTest, + setupTest as upstreamSetupTest, +} from 'ember-qunit'; + +// This file exists to provide wrappers around ember-qunit's +// test setup functions. This way, you can easily extend the setup that is +// needed per test type. + +function setupApplicationTest(hooks, options) { + upstreamSetupApplicationTest(hooks, options); + + // Additional setup for application tests can be done here. + // + // For example, if you need an authenticated session for each + // application test, you could do: + // + // hooks.beforeEach(async function () { + // await authenticateSession(); // ember-simple-auth + // }); + // + // This is also a good place to call test setup functions coming + // from other addons: + // + // setupIntl(hooks); // ember-intl + // setupMirage(hooks); // ember-cli-mirage +} + +function setupRenderingTest(hooks, options) { + upstreamSetupRenderingTest(hooks, options); + + // Additional setup for rendering tests can be done here. +} + +function setupTest(hooks, options) { + upstreamSetupTest(hooks, options); + + // Additional setup for unit tests can be done here. +} + +export { setupApplicationTest, setupRenderingTest, setupTest }; diff --git a/packages/surrealdb/tests/index.html b/packages/surrealdb/tests/index.html new file mode 100644 index 000000000..b74fc8be6 --- /dev/null +++ b/packages/surrealdb/tests/index.html @@ -0,0 +1,39 @@ + + + + + Dummy Tests + + + + {{content-for "head"}} + {{content-for "test-head"}} + + + + + + {{content-for "head-footer"}} + {{content-for "test-head-footer"}} + + + {{content-for "body"}} + {{content-for "test-body"}} + +
+
+
+
+
+
+ + + + + + + + {{content-for "body-footer"}} + {{content-for "test-body-footer"}} + + diff --git a/packages/surrealdb/tests/integration/.gitkeep b/packages/surrealdb/tests/integration/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/packages/surrealdb/tests/test-helper.js b/packages/surrealdb/tests/test-helper.js new file mode 100644 index 000000000..4efd6e58a --- /dev/null +++ b/packages/surrealdb/tests/test-helper.js @@ -0,0 +1,12 @@ +import Application from 'dummy/app'; +import config from 'dummy/config/environment'; +import * as QUnit from 'qunit'; +import { setApplication } from '@ember/test-helpers'; +import { setup } from 'qunit-dom'; +import { start } from 'ember-qunit'; + +setApplication(Application.create(config.APP)); + +setup(QUnit.assert); + +start(); diff --git a/packages/surrealdb/tests/unit/.gitkeep b/packages/surrealdb/tests/unit/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/packages/surrealdb/tests/unit/builders/count-test.js b/packages/surrealdb/tests/unit/builders/count-test.js new file mode 100644 index 000000000..5dddf7eb7 --- /dev/null +++ b/packages/surrealdb/tests/unit/builders/count-test.js @@ -0,0 +1,35 @@ +import { module, test } from 'qunit'; +import { setupTest } from 'ember-qunit'; + +import count from '@ascua/surrealdb/builders/count'; + +module('Unit | Builders | Count', function (hooks) { + setupTest(hooks); + + test('should create a basic SQL query', function (assert) { + const results = count('person'); + + assert.equal( + results.text, + 'SELECT count(*) AS count FROM table($tb) GROUP BY all' + ); + assert.deepEqual(results.vars, { + tb: 'person', + }); + }); + + test('should add a WHERE clause to the query', function (assert) { + const results = count('person', { + where: ['name = John Doe'], + }); + + assert.equal( + results.text, + 'SELECT count(*) AS count FROM table($tb) WHERE name = John Doe GROUP BY all' + ); + + assert.deepEqual(results.vars, { + tb: 'person', + }); + }); +}); diff --git a/packages/surrealdb/tests/unit/builders/hasher-test.js b/packages/surrealdb/tests/unit/builders/hasher-test.js new file mode 100644 index 000000000..1855ea677 --- /dev/null +++ b/packages/surrealdb/tests/unit/builders/hasher-test.js @@ -0,0 +1,31 @@ +import { module, test } from 'qunit'; +import { setupTest } from 'ember-qunit'; + +import hasher from '@ascua/surrealdb/builders/hasher'; +import md5 from '@ascua/surrealdb/utils/md5'; + +module('Unit | Builders | Hasher', function (hooks) { + setupTest(hooks); + + test('should create a basic SQL query', function (assert) { + const results = hasher('person'); + + assert.equal(results, md5('SELECT * FROM person')); + }); + + test('should add a WHERE clause to the query', function (assert) { + const results = hasher('person', { + where: ['name = John Doe'], + }); + + assert.equal(results, md5('SELECT * FROM person WHERE name = John Doe')); + }); + + test('should group the results by all of the columns in the table', function (assert) { + const results = hasher('person', { + group: ['name', 'age'], + }); + + assert.equal(results, md5('SELECT * FROM person GROUP BY name,age')); + }); +}); diff --git a/packages/surrealdb/tests/unit/builders/table-test.js b/packages/surrealdb/tests/unit/builders/table-test.js new file mode 100644 index 000000000..f75d7b126 --- /dev/null +++ b/packages/surrealdb/tests/unit/builders/table-test.js @@ -0,0 +1,53 @@ +import { module, test } from 'qunit'; +import { setupTest } from 'ember-qunit'; + +import table from '@ascua/surrealdb/builders/table'; + +module('Unit | Builders | Table', function (hooks) { + setupTest(hooks); + + test('should create a basic SQL query', function (assert) { + const results = table('person'); + + assert.deepEqual(results, { + text: 'SELECT * FROM table($tb)', + vars: { + tb: 'person', + }, + }); + }); + + test('should add a WHERE clause to the query', function (assert) { + const results = table('person', { + where: ['name = John Doe'], + }); + + assert.equal( + results.text, + 'SELECT * FROM table($tb) WHERE name = John Doe' + ); + assert.deepEqual(results.vars, { + tb: 'person', + }); + }); + + test('should group the results by all of the columns in the table', function (assert) { + const results = table('person', { + group: ['name', 'age'], + }); + + assert.equal(results.text, 'SELECT * FROM table($tb) GROUP BY name,age'); + assert.deepEqual(results.vars, { + tb: 'person', + }); + }); + + test('should handle empty options object', function (assert) { + const results = table('person'); + + assert.equal(results.text, 'SELECT * FROM table($tb)'); + assert.deepEqual(results.vars, { + tb: 'person', + }); + }); +}); diff --git a/packages/surrealdb/tests/unit/classes/storage-test.js b/packages/surrealdb/tests/unit/classes/storage-test.js new file mode 100644 index 000000000..207aeb4dc --- /dev/null +++ b/packages/surrealdb/tests/unit/classes/storage-test.js @@ -0,0 +1,45 @@ +import { module, test } from 'qunit'; +import { setupTest } from 'ember-qunit'; + +import Storage from '@ascua/surrealdb/classes/storage'; + +module('Unit | Classes | Storage', function (hooks) { + setupTest(hooks); + let storage; + + hooks.beforeEach(function () { + storage = new Storage(); + }); + + hooks.afterEach(function () { + storage.clear(); + }); + + test('should be able to set a value', async function (assert) { + await storage.set('key', 'value'); + + assert.equal(storage.get('key'), 'value'); + }); + + test('should be able to get a value', async function (assert) { + await storage.set('key', 'value'); + + assert.equal(storage.get('key'), 'value'); + }); + + test('should be able to delete a value', async function (assert) { + await storage.set('key', 'value'); + await storage.del('key'); + + assert.equal(storage.get('key'), undefined); + }); + + test('should be able to clear all values', async function (assert) { + await storage.set('key1', 'value1'); + await storage.set('key2', 'value2'); + await storage.clear(); + + assert.equal(storage.get('key1'), undefined); + assert.equal(storage.get('key2'), undefined); + }); +}); diff --git a/packages/surrealdb/tests/unit/decorators/attempted-test.js b/packages/surrealdb/tests/unit/decorators/attempted-test.js new file mode 100644 index 000000000..79c2aed8a --- /dev/null +++ b/packages/surrealdb/tests/unit/decorators/attempted-test.js @@ -0,0 +1,33 @@ +import { module, test } from 'qunit'; +import { setupTest } from 'ember-qunit'; + +import Route from '@ember/routing/route'; +import attempted from '@ascua/surrealdb/decorators/attempted'; + +module('Unit | Decorators | Attempted', function (hooks) { + setupTest(hooks); + + class MyClass {} + + class MyRoute extends Route {} + + test('should only be applied to a Route class', function (assert) { + assert.expect(2); + try { + attempted(MyClass); + } catch (e) { + assert.equal( + e.message, + 'Assertion Failed: The @attempted decorator can only be applied to a Route' + ); + + assert.notOk(MyClass.prototype.beforeModel); + } + }); + + test('should add a beforeModel method that waits for authentication', function (assert) { + attempted(MyRoute); + + assert.ok(MyRoute.prototype.beforeModel); + }); +}); diff --git a/packages/surrealdb/tests/unit/decorators/authenticated-test.js b/packages/surrealdb/tests/unit/decorators/authenticated-test.js new file mode 100644 index 000000000..030e272e2 --- /dev/null +++ b/packages/surrealdb/tests/unit/decorators/authenticated-test.js @@ -0,0 +1,22 @@ +import { module, test } from 'qunit'; +import { setupTest } from 'ember-qunit'; + +import authenticated from '@ascua/surrealdb/decorators/authenticated'; + +module('Unit | Decorators | Authenticated', function (hooks) { + setupTest(hooks); + + class MyClass {} + + test('should only be applied to a Route class', function (assert) { + assert.expect(1); + try { + authenticated(MyClass); + } catch (e) { + assert.equal( + e.message, + 'Assertion Failed: The @authenticated decorator can only be applied to a Route' + ); + } + }); +}); diff --git a/packages/surrealdb/tests/unit/decorators/autosave-test.js b/packages/surrealdb/tests/unit/decorators/autosave-test.js new file mode 100644 index 000000000..b1acfe784 --- /dev/null +++ b/packages/surrealdb/tests/unit/decorators/autosave-test.js @@ -0,0 +1,22 @@ +import { module, test } from 'qunit'; +import { setupTest } from 'ember-qunit'; + +import autosave from '@ascua/surrealdb/decorators/autosave'; + +module('Unit | Decorators | Autosave', function (hooks) { + setupTest(hooks); + + class MyClass {} + + test('should only be applied to a Route class', function (assert) { + assert.expect(1); + try { + autosave(MyClass); + } catch (e) { + assert.equal( + e.message, + 'Assertion Failed: The @autosave decorator can only be applied to a Model' + ); + } + }); +}); diff --git a/packages/surrealdb/tests/unit/decorators/closed-test.js b/packages/surrealdb/tests/unit/decorators/closed-test.js new file mode 100644 index 000000000..eb5c560ed --- /dev/null +++ b/packages/surrealdb/tests/unit/decorators/closed-test.js @@ -0,0 +1,22 @@ +import { module, test } from 'qunit'; +import { setupTest } from 'ember-qunit'; + +import closed from '@ascua/surrealdb/decorators/closed'; + +module('Unit | Decorators | Closed', function (hooks) { + setupTest(hooks); + + class MyClass {} + + test('should only be applied to a Route class', function (assert) { + assert.expect(1); + try { + closed(MyClass); + } catch (e) { + assert.equal( + e.message, + 'Assertion Failed: The @closed decorator can only be applied to a Route' + ); + } + }); +}); diff --git a/packages/surrealdb/tests/unit/decorators/invalidated-test.js b/packages/surrealdb/tests/unit/decorators/invalidated-test.js new file mode 100644 index 000000000..d40109410 --- /dev/null +++ b/packages/surrealdb/tests/unit/decorators/invalidated-test.js @@ -0,0 +1,22 @@ +import { module, test } from 'qunit'; +import { setupTest } from 'ember-qunit'; + +import invalidated from '@ascua/surrealdb/decorators/invalidated'; + +module('Unit | Decorators | Invalidated', function (hooks) { + setupTest(hooks); + + class MyClass {} + + test('should only be applied to a Route class', function (assert) { + assert.expect(1); + try { + invalidated(MyClass); + } catch (e) { + assert.equal( + e.message, + 'Assertion Failed: The @invalidated decorator can only be applied to a Route' + ); + } + }); +}); diff --git a/packages/surrealdb/tests/unit/decorators/opened-test.js b/packages/surrealdb/tests/unit/decorators/opened-test.js new file mode 100644 index 000000000..67610cb50 --- /dev/null +++ b/packages/surrealdb/tests/unit/decorators/opened-test.js @@ -0,0 +1,22 @@ +import { module, test } from 'qunit'; +import { setupTest } from 'ember-qunit'; + +import opened from '@ascua/surrealdb/decorators/opened'; + +module('Unit | Decorators | Opened', function (hooks) { + setupTest(hooks); + + class MyClass {} + + test('should only be applied to a Route class', function (assert) { + assert.expect(1); + try { + opened(MyClass); + } catch (e) { + assert.equal( + e.message, + 'Assertion Failed: The @opened decorator can only be applied to a Route' + ); + } + }); +}); diff --git a/packages/surrealdb/tests/unit/decorators/signout-test.js b/packages/surrealdb/tests/unit/decorators/signout-test.js new file mode 100644 index 000000000..c3488071d --- /dev/null +++ b/packages/surrealdb/tests/unit/decorators/signout-test.js @@ -0,0 +1,22 @@ +import { module, test } from 'qunit'; +import { setupTest } from 'ember-qunit'; + +import signout from '@ascua/surrealdb/decorators/signout'; + +module('Unit | Decorators | Signout', function (hooks) { + setupTest(hooks); + + class MyClass {} + + test('should only be applied to a Route class', function (assert) { + assert.expect(1); + try { + signout(MyClass); + } catch (e) { + assert.equal( + e.message, + 'Assertion Failed: The @signout decorator can only be applied to a Route' + ); + } + }); +}); diff --git a/packages/surrealdb/tests/unit/services/session-test.js b/packages/surrealdb/tests/unit/services/session-test.js new file mode 100644 index 000000000..9f4240625 --- /dev/null +++ b/packages/surrealdb/tests/unit/services/session-test.js @@ -0,0 +1,10 @@ +import { module, test } from 'qunit'; +import { setupTest } from 'ember-qunit'; + +module('Unit | Service | Session', function (hooks) { + setupTest(hooks); + + test('session is authenticated', async function () { + this.owner.lookup('service:session'); + }); +}); diff --git a/packages/surrealdb/tests/unit/services/store-test.js b/packages/surrealdb/tests/unit/services/store-test.js new file mode 100644 index 000000000..80ca8b6c1 --- /dev/null +++ b/packages/surrealdb/tests/unit/services/store-test.js @@ -0,0 +1,10 @@ +import { module, test } from 'qunit'; +import { setupTest } from 'ember-qunit'; + +module('Unit | Service | Store', function (hooks) { + setupTest(hooks); + + test('store is ready', async function () { + this.owner.lookup('service:store'); + }); +}); diff --git a/packages/surrealdb/tests/unit/services/surreal-test.js b/packages/surrealdb/tests/unit/services/surreal-test.js new file mode 100644 index 000000000..be961b4a9 --- /dev/null +++ b/packages/surrealdb/tests/unit/services/surreal-test.js @@ -0,0 +1,18 @@ +import { module, test } from 'qunit'; +import { setupTest } from 'ember-qunit'; + +module('Unit | Service | Surreal', function (hooks) { + setupTest(hooks); + + test.skip('sign up', async function () { + const service = this.owner.lookup('service:surreal'); + + await service.signup({ + NS: 'test', + DB: 'test', + SC: 'test', + email: 'test@test.co', + pass: 'pass', + }); + }); +}); diff --git a/packages/surrealdb/tests/unit/utils/base-test.js b/packages/surrealdb/tests/unit/utils/base-test.js new file mode 100644 index 000000000..1c4060369 --- /dev/null +++ b/packages/surrealdb/tests/unit/utils/base-test.js @@ -0,0 +1,50 @@ +import { module, test } from 'qunit'; +import { setupTest } from 'ember-qunit'; + +import base from '@ascua/surrealdb/utils/base'; + +module('Unit | Utils | Base', function (hooks) { + setupTest(hooks); + + test('should decode a valid base64url string', function (assert) { + var expected = 'Hello, world!'; + + var actual = base('SGVsbG8sIHdvcmxkIQ=='); + + assert.equal(actual, expected); + }); + + test('should decode a longer base64url string', function (assert) { + var expected = 'This is a longer string.'; + + var actual = base('VGhpcyBpcyBhIGxvbmdlciBzdHJpbmcu'); + + assert.equal(actual, expected); + }); + + test('should pad a non-multiple-of-4 base64url string with = characters', function (assert) { + var expected = + 'This string is not a multiple of 4, so it should be padded with = characters.'; + + var actual = base( + 'VGhpcyBzdHJpbmcgaXMgbm90IGEgbXVsdGlwbGUgb2YgNCwgc28gaXQgc2hvdWxkIGJlIHBhZGRlZCB3aXRoID0gY2hhcmFjdGVycy4=' + ); + + assert.equal(actual, expected); + }); + + test('should not pad a non-multiple-of-4 base64url string that already has = characters at the end', function (assert) { + var expected = + 'This string is not a multiple of 4, but it already has = characters at the end, so it should not be padded.'; + + var actual = base( + 'VGhpcyBzdHJpbmcgaXMgbm90IGEgbXVsdGlwbGUgb2YgNCwgYnV0IGl0IGFscmVhZHkgaGFzID0gY2hhcmFjdGVycyBhdCB0aGUgZW5kLCBzbyBpdCBzaG91bGQgbm90IGJlIHBhZGRlZC4=' + ); + + assert.equal(actual, expected); + }); + + test('should throw an error for an invalid base64url string', function (assert) { + assert.throws(base, 'Illegal base64url string!'); + }); +}); diff --git a/packages/surrealdb/tests/unit/utils/json-test.js b/packages/surrealdb/tests/unit/utils/json-test.js new file mode 100644 index 000000000..e9b92bc2d --- /dev/null +++ b/packages/surrealdb/tests/unit/utils/json-test.js @@ -0,0 +1,80 @@ +import { module, test } from 'qunit'; +import { setupTest } from 'ember-qunit'; + +import { full, some } from '@ascua/surrealdb/utils/json'; + +module('Unit | Utils | Json', function (hooks) { + setupTest(hooks); + + test('Returns a JSON object with all of the properties of the input object', function (assert) { + const object = { + id: 1, + name: 'John Doe', + age: 30, + address: { + street: '123 Main Street', + city: 'Anytown', + state: 'CA', + zip: '12345', + }, + }; + const expected = { + id: 1, + name: 'John Doe', + age: 30, + address: { + street: '123 Main Street', + city: 'Anytown', + state: 'CA', + zip: '12345', + }, + }; + const result = full(object); + assert.deepEqual(result, expected); + }); + + test('Returns a JSON object with the properties that are not marked as readonly', function (assert) { + const object = { + id: 1, + name: 'John Doe', + age: 30, + address: { + street: '123 Main Street', + city: 'Anytown', + state: 'CA', + zip: '12345', + }, + readonly: 'This property is readonly', + }; + const expected = { + id: 1, + name: 'John Doe', + age: 30, + address: { + street: '123 Main Street', + city: 'Anytown', + state: 'CA', + zip: '12345', + }, + }; + const result = some(object); + assert.deepEqual(result, expected); + }); + + test('Returns the same JSON object if the input object does not have any readonly properties', function (assert) { + const object = { + id: 1, + name: 'John Doe', + age: 30, + address: { + street: '123 Main Street', + city: 'Anytown', + state: 'CA', + zip: '12345', + }, + }; + const result1 = full(object); + const result2 = some(object); + assert.deepEqual(result1, result2); + }); +}); diff --git a/packages/surrealdb/tests/unit/utils/jwt-test.js b/packages/surrealdb/tests/unit/utils/jwt-test.js new file mode 100644 index 000000000..1e9c45013 --- /dev/null +++ b/packages/surrealdb/tests/unit/utils/jwt-test.js @@ -0,0 +1,49 @@ +import { module, test } from 'qunit'; +import { setupTest } from 'ember-qunit'; + +import jwt from '@ascua/surrealdb/utils/jwt'; + +module('Unit | Utils | Jwt', function (hooks) { + setupTest(hooks); + + test('Returns null if the token is not a string', function (assert) { + const result = jwt(123); + assert.isNull(result); + }); + + test('Returns null if the token is not a valid JWT token', function (assert) { + const result = jwt('not a valid JWT token'); + assert.isNull(result); + }); + + test('Returns the correct JSON object if the token is a valid JWT token', function (assert) { + const token = + 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjMiLCJleHAiOjE1MDMwMDAwMDAsIm5hbWUiOiJhZG1pbjpwcm9kdWN0aW9uIn0.QkZGRkZGRkZGRkZGRkZG'; + const expected = { + sub: '1234567890', + name: 'John Doe', + iat: 1568325870, + exp: 1568329470, + }; + const result = jwt(token); + assert.deepEqual(result, expected); + }); + + test('Includes the header in the decoded JSON object if the header option is set to true', function (assert) { + const token = + 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjMiLCJleHAiOjE1MDMwMDAwMDAsIm5hbWUiOiJhZG1pbjpwcm9kdWN0aW9uIn0.QkZGRkZGRkZGRkZGRkZG.eyJ1c2VybmFtZSI6IkhlbGxvIFdvcmxkIn0'; + const expected = { + alg: 'HS256', + typ: 'JWT', + sub: '1234567890', + name: 'John Doe', + iat: 1568325870, + exp: 1568329470, + }; + const options = { + header: true, + }; + const result = jwt(token, options); + assert.deepEqual(result, expected); + }); +}); diff --git a/packages/surrealdb/tests/unit/utils/md5-test.js b/packages/surrealdb/tests/unit/utils/md5-test.js new file mode 100644 index 000000000..49c5671ce --- /dev/null +++ b/packages/surrealdb/tests/unit/utils/md5-test.js @@ -0,0 +1,42 @@ +import { module, test } from 'qunit'; +import { setupTest } from 'ember-qunit'; + +import md5 from '@ascua/surrealdb/utils/uniq'; + +module('Unit | Utils | md5', function (hooks) { + setupTest(hooks); + + test('should decode a valid base64url string', function (assert) { + var expected = 'Hello, world!'; + var actual = md5('Zm9vCg=='); + assert.equal(actual, expected); + }); + + test('should decode a longer base64url string', function (assert) { + var expected = 'This is a longer string.'; + var actual = md5( + 'SGVsbG8gd29ybGRIZWxsbyB3b3JsZEhlbGxvIHdvcmxkSGVsbG8gd29ybGRIZWxsbyB3b3JsZEhlbGxvIHdvcmxk' + ); + assert.equal(actual, expected); + }); + + test('should pad a non-multiple-of-4 base64url string with = characters', function (assert) { + var expected = + 'This string is not a multiple of 4, so it should be padded with = characters.'; + var actual = md5('SGVsbG8gd29ybGRIZWxsbyB3b3JsZEhlbGxvIHdvcmxk=='); + assert.equal(actual, expected); + }); + + test('should not pad a non-multiple-of-4 base64url string that already has = characters at the end', function (assert) { + var expected = + 'This string is not a multiple of 4, but it already has = characters at the end, so it should not be padded.'; + var actual = md5('SGVsbG8gd29ybGRIZWxsbyB3b3JsZEhlbGxvIHdvcmxk='); + assert.equal(actual, expected); + }); + + test('should throw an error for an invalid base64url string', function (assert) { + assert + .expect(md5('This string is an invalid base64url string!')) + .toThrow('Invalid base64url string!'); + }); +}); diff --git a/packages/surrealdb/tests/unit/utils/test-test.js b/packages/surrealdb/tests/unit/utils/test-test.js new file mode 100644 index 000000000..bf89340dd --- /dev/null +++ b/packages/surrealdb/tests/unit/utils/test-test.js @@ -0,0 +1,36 @@ +import { module, test } from 'qunit'; +import { setupTest } from 'ember-qunit'; + +import utilTest from '@ascua/surrealdb/utils/test'; +import window from 'ember-window-mock'; +import { setupWindowMock } from 'ember-window-mock/test-support'; + +module('Unit | Utils | Test', function (hooks) { + setupTest(hooks); + setupWindowMock(hooks); + + test('Returns true if the browser has localStorage and sessionStorage', function (assert) { + const result = utilTest(); + assert.true(result); + }); + + test('Throws an exception if the browser does not have localStorage', function (assert) { + assert.expect(1); + try { + window.localStorage = undefined; + utilTest(); + } catch (e) { + assert.equal(e.message, 'exception'); + } + }); + + test('Throws an exception if the browser does not have sessionStorage', function (assert) { + assert.expect(1); + try { + window.sessionStorage = undefined; + utilTest(); + } catch (e) { + assert.equal(e.message, 'exception'); + } + }); +}); diff --git a/packages/surrealdb/tests/unit/utils/unid-test.js b/packages/surrealdb/tests/unit/utils/unid-test.js new file mode 100644 index 000000000..dc2009488 --- /dev/null +++ b/packages/surrealdb/tests/unit/utils/unid-test.js @@ -0,0 +1,61 @@ +import { module, test } from 'qunit'; +import { setupTest } from 'ember-qunit'; + +import unid from '@ascua/surrealdb/utils/unid'; +import utilTest from '@ascua/surrealdb/utils/test'; +import window from 'ember-window-mock'; +import { setupWindowMock } from 'ember-window-mock/test-support'; + +const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; + +module('Unit | Utils | Unid', function (hooks) { + setupTest(hooks); + setupWindowMock(hooks); + + test('Returns a string of the correct length', function (assert) { + const result = unid(); + assert.equal(result.length, 64); + }); + + test('Returns a string of random characters', function (assert) { + assert.expect(64); + const result = unid(); + for (let i = 0; i < result.length; i++) { + assert.ok(chars.includes(result[i])); + } + }); + + test('Returns a different string each time it is called', function (assert) { + const result1 = unid(); + const result2 = unid(); + assert.notEqual(result1, result2); + }); + + test('Stores the session string in localStorage if it is not already stored there', function (assert) { + assert.expect(1); + const persisted = utilTest(); + const session1 = unid(); + const session2 = unid(); + + if (persisted === false) { + assert.equal(window.localStorage.getItem('session'), session1); + } else { + assert.equal(window.localStorage.getItem('session'), session2); + } + }); + + test('Returns the session string from localStorage if it is already stored there', function (assert) { + assert.expect(1); + const persisted = utilTest(); + const session1 = unid(); + const session2 = unid(); + + window.localStorage.setItem('session', session1); + + if (persisted === false) { + assert.equal(unid(), session1); + } else { + assert.equal(unid(), session2); + } + }); +}); diff --git a/packages/surrealdb/tests/unit/utils/uniq-test.js b/packages/surrealdb/tests/unit/utils/uniq-test.js new file mode 100644 index 000000000..79d37d5de --- /dev/null +++ b/packages/surrealdb/tests/unit/utils/uniq-test.js @@ -0,0 +1,30 @@ +import { module, test } from 'qunit'; +import { setupTest } from 'ember-qunit'; + +import uniq from '@ascua/surrealdb/utils/uniq'; + +const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; + +module('Unit | Utils | Uniq', function (hooks) { + setupTest(hooks); + + test('Returns a string of the correct length', function (assert) { + const result = uniq(64); + assert.equal(result.length, 64); + }); + + test('Returns a string of random characters', function (assert) { + assert.expect(64); + + const result = uniq(64); + for (let i = 0; i < result.length; i++) { + assert.ok(chars.includes(result[i])); + } + }); + + test('Returns a different string each time it is called', function (assert) { + const result1 = uniq(64); + const result2 = uniq(64); + assert.notEqual(result1, result2); + }); +}); diff --git a/packages/surrealdb/vendor/diffmatchpatch.js b/packages/surrealdb/vendor/diffmatchpatch.js new file mode 100644 index 000000000..84ddcd599 --- /dev/null +++ b/packages/surrealdb/vendor/diffmatchpatch.js @@ -0,0 +1,2480 @@ +/** + * Diff Match and Patch + * Copyright 2018 The diff-match-patch Authors. + * https://github.com/google/diff-match-patch + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * @fileoverview Computes the difference between two texts to create a patch. + * Applies the patch onto another text, allowing for errors. + * @author fraser@google.com (Neil Fraser) + */ + +/** + * Class containing the diff, match and patch methods. + * @constructor + */ +var diff_match_patch = function () { + // Defaults. + // Redefine these in your program to override the defaults. + + // Number of seconds to map a diff before giving up (0 for infinity). + this.Diff_Timeout = 1.0; + // Cost of an empty edit operation in terms of edit characters. + this.Diff_EditCost = 4; + // At what point is no match declared (0.0 = perfection, 1.0 = very loose). + this.Match_Threshold = 0.5; + // How far to search for a match (0 = exact location, 1000+ = broad match). + // A match this many characters away from the expected location will add + // 1.0 to the score (0.0 is a perfect match). + this.Match_Distance = 1000; + // When deleting a large block of text (over ~64 characters), how close do + // the contents have to be to match the expected contents. (0.0 = perfection, + // 1.0 = very loose). Note that Match_Threshold controls how closely the + // end points of a delete need to match. + this.Patch_DeleteThreshold = 0.5; + // Chunk size for context length. + this.Patch_Margin = 4; + + // The number of bits in an int. + this.Match_MaxBits = 32; +}; + +// DIFF FUNCTIONS + +/** + * The data structure representing a diff is an array of tuples: + * [[DIFF_DELETE, 'Hello'], [DIFF_INSERT, 'Goodbye'], [DIFF_EQUAL, ' world.']] + * which means: delete 'Hello', add 'Goodbye' and keep ' world.' + */ +var DIFF_DELETE = -1; +var DIFF_INSERT = 1; +var DIFF_EQUAL = 0; + +/** + * Class representing one diff tuple. + * Attempts to look like a two-element array (which is what this used to be). + * @param {number} op Operation, one of: DIFF_DELETE, DIFF_INSERT, DIFF_EQUAL. + * @param {string} text Text to be deleted, inserted, or retained. + * @constructor + */ +diff_match_patch.Diff = function (op, text) { + this[0] = op; + this[1] = text; +}; + +diff_match_patch.Diff.prototype.length = 2; + +/** + * Emulate the output of a two-element array. + * @return {string} Diff operation as a string. + */ +diff_match_patch.Diff.prototype.toString = function () { + return this[0] + ',' + this[1]; +}; + +/** + * Find the differences between two texts. Simplifies the problem by stripping + * any common prefix or suffix off the texts before diffing. + * @param {string} text1 Old string to be diffed. + * @param {string} text2 New string to be diffed. + * @param {boolean=} opt_checklines Optional speedup flag. If present and false, + * then don't run a line-level diff first to identify the changed areas. + * Defaults to true, which does a faster, slightly less optimal diff. + * @param {number=} opt_deadline Optional time when the diff should be complete + * by. Used internally for recursive calls. Users should set DiffTimeout + * instead. + * @return {!Array.} Array of diff tuples. + */ +diff_match_patch.prototype.diff_main = function ( + text1, + text2, + opt_checklines, + opt_deadline +) { + // Set a deadline by which time the diff must be complete. + if (typeof opt_deadline == 'undefined') { + if (this.Diff_Timeout <= 0) { + opt_deadline = Number.MAX_VALUE; + } else { + opt_deadline = new Date().getTime() + this.Diff_Timeout * 1000; + } + } + var deadline = opt_deadline; + + // Check for null inputs. + if (text1 == null || text2 == null) { + throw new Error('Null input. (diff_main)'); + } + + // Check for equality (speedup). + if (text1 == text2) { + if (text1) { + return [new diff_match_patch.Diff(DIFF_EQUAL, text1)]; + } + return []; + } + + if (typeof opt_checklines == 'undefined') { + opt_checklines = true; + } + var checklines = opt_checklines; + + // Trim off common prefix (speedup). + var commonlength = this.diff_commonPrefix(text1, text2); + var commonprefix = text1.substring(0, commonlength); + text1 = text1.substring(commonlength); + text2 = text2.substring(commonlength); + + // Trim off common suffix (speedup). + commonlength = this.diff_commonSuffix(text1, text2); + var commonsuffix = text1.substring(text1.length - commonlength); + text1 = text1.substring(0, text1.length - commonlength); + text2 = text2.substring(0, text2.length - commonlength); + + // Compute the diff on the middle block. + var diffs = this.diff_compute_(text1, text2, checklines, deadline); + + // Restore the prefix and suffix. + if (commonprefix) { + diffs.unshift(new diff_match_patch.Diff(DIFF_EQUAL, commonprefix)); + } + if (commonsuffix) { + diffs.push(new diff_match_patch.Diff(DIFF_EQUAL, commonsuffix)); + } + this.diff_cleanupMerge(diffs); + return diffs; +}; + +/** + * Find the differences between two texts. Assumes that the texts do not + * have any common prefix or suffix. + * @param {string} text1 Old string to be diffed. + * @param {string} text2 New string to be diffed. + * @param {boolean} checklines Speedup flag. If false, then don't run a + * line-level diff first to identify the changed areas. + * If true, then run a faster, slightly less optimal diff. + * @param {number} deadline Time when the diff should be complete by. + * @return {!Array.} Array of diff tuples. + * @private + */ +diff_match_patch.prototype.diff_compute_ = function ( + text1, + text2, + checklines, + deadline +) { + var diffs; + + if (!text1) { + // Just add some text (speedup). + return [new diff_match_patch.Diff(DIFF_INSERT, text2)]; + } + + if (!text2) { + // Just delete some text (speedup). + return [new diff_match_patch.Diff(DIFF_DELETE, text1)]; + } + + var longtext = text1.length > text2.length ? text1 : text2; + var shorttext = text1.length > text2.length ? text2 : text1; + var i = longtext.indexOf(shorttext); + if (i != -1) { + // Shorter text is inside the longer text (speedup). + diffs = [ + new diff_match_patch.Diff(DIFF_INSERT, longtext.substring(0, i)), + new diff_match_patch.Diff(DIFF_EQUAL, shorttext), + new diff_match_patch.Diff( + DIFF_INSERT, + longtext.substring(i + shorttext.length) + ), + ]; + // Swap insertions for deletions if diff is reversed. + if (text1.length > text2.length) { + diffs[0][0] = diffs[2][0] = DIFF_DELETE; + } + return diffs; + } + + if (shorttext.length == 1) { + // Single character string. + // After the previous speedup, the character can't be an equality. + return [ + new diff_match_patch.Diff(DIFF_DELETE, text1), + new diff_match_patch.Diff(DIFF_INSERT, text2), + ]; + } + + // Check to see if the problem can be split in two. + var hm = this.diff_halfMatch_(text1, text2); + if (hm) { + // A half-match was found, sort out the return data. + var text1_a = hm[0]; + var text1_b = hm[1]; + var text2_a = hm[2]; + var text2_b = hm[3]; + var mid_common = hm[4]; + // Send both pairs off for separate processing. + var diffs_a = this.diff_main(text1_a, text2_a, checklines, deadline); + var diffs_b = this.diff_main(text1_b, text2_b, checklines, deadline); + // Merge the results. + return diffs_a.concat( + [new diff_match_patch.Diff(DIFF_EQUAL, mid_common)], + diffs_b + ); + } + + if (checklines && text1.length > 100 && text2.length > 100) { + return this.diff_lineMode_(text1, text2, deadline); + } + + return this.diff_bisect_(text1, text2, deadline); +}; + +/** + * Do a quick line-level diff on both strings, then rediff the parts for + * greater accuracy. + * This speedup can produce non-minimal diffs. + * @param {string} text1 Old string to be diffed. + * @param {string} text2 New string to be diffed. + * @param {number} deadline Time when the diff should be complete by. + * @return {!Array.} Array of diff tuples. + * @private + */ +diff_match_patch.prototype.diff_lineMode_ = function (text1, text2, deadline) { + // Scan the text on a line-by-line basis first. + var a = this.diff_linesToChars_(text1, text2); + text1 = a.chars1; + text2 = a.chars2; + var linearray = a.lineArray; + + var diffs = this.diff_main(text1, text2, false, deadline); + + // Convert the diff back to original text. + this.diff_charsToLines_(diffs, linearray); + // Eliminate freak matches (e.g. blank lines) + this.diff_cleanupSemantic(diffs); + + // Rediff any replacement blocks, this time character-by-character. + // Add a dummy entry at the end. + diffs.push(new diff_match_patch.Diff(DIFF_EQUAL, '')); + var pointer = 0; + var count_delete = 0; + var count_insert = 0; + var text_delete = ''; + var text_insert = ''; + while (pointer < diffs.length) { + switch (diffs[pointer][0]) { + case DIFF_INSERT: + count_insert++; + text_insert += diffs[pointer][1]; + break; + case DIFF_DELETE: + count_delete++; + text_delete += diffs[pointer][1]; + break; + case DIFF_EQUAL: + // Upon reaching an equality, check for prior redundancies. + if (count_delete >= 1 && count_insert >= 1) { + // Delete the offending records and add the merged ones. + diffs.splice( + pointer - count_delete - count_insert, + count_delete + count_insert + ); + pointer = pointer - count_delete - count_insert; + var subDiff = this.diff_main( + text_delete, + text_insert, + false, + deadline + ); + for (var j = subDiff.length - 1; j >= 0; j--) { + diffs.splice(pointer, 0, subDiff[j]); + } + pointer = pointer + subDiff.length; + } + count_insert = 0; + count_delete = 0; + text_delete = ''; + text_insert = ''; + break; + } + pointer++; + } + diffs.pop(); // Remove the dummy entry at the end. + + return diffs; +}; + +/** + * Find the 'middle snake' of a diff, split the problem in two + * and return the recursively constructed diff. + * See Myers 1986 paper: An O(ND) Difference Algorithm and Its Variations. + * @param {string} text1 Old string to be diffed. + * @param {string} text2 New string to be diffed. + * @param {number} deadline Time at which to bail if not yet complete. + * @return {!Array.} Array of diff tuples. + * @private + */ +diff_match_patch.prototype.diff_bisect_ = function (text1, text2, deadline) { + // Cache the text lengths to prevent multiple calls. + var text1_length = text1.length; + var text2_length = text2.length; + var max_d = Math.ceil((text1_length + text2_length) / 2); + var v_offset = max_d; + var v_length = 2 * max_d; + var v1 = new Array(v_length); + var v2 = new Array(v_length); + // Setting all elements to -1 is faster in Chrome & Firefox than mixing + // integers and undefined. + for (var x = 0; x < v_length; x++) { + v1[x] = -1; + v2[x] = -1; + } + v1[v_offset + 1] = 0; + v2[v_offset + 1] = 0; + var delta = text1_length - text2_length; + // If the total number of characters is odd, then the front path will collide + // with the reverse path. + var front = delta % 2 != 0; + // Offsets for start and end of k loop. + // Prevents mapping of space beyond the grid. + var k1start = 0; + var k1end = 0; + var k2start = 0; + var k2end = 0; + for (var d = 0; d < max_d; d++) { + // Bail out if deadline is reached. + if (new Date().getTime() > deadline) { + break; + } + + // Walk the front path one step. + for (var k1 = -d + k1start; k1 <= d - k1end; k1 += 2) { + var k1_offset = v_offset + k1; + var x1; + if ( + k1 == -d || + (k1 != d && v1[k1_offset - 1] < v1[k1_offset + 1]) + ) { + x1 = v1[k1_offset + 1]; + } else { + x1 = v1[k1_offset - 1] + 1; + } + var y1 = x1 - k1; + while ( + x1 < text1_length && + y1 < text2_length && + text1.charAt(x1) == text2.charAt(y1) + ) { + x1++; + y1++; + } + v1[k1_offset] = x1; + if (x1 > text1_length) { + // Ran off the right of the graph. + k1end += 2; + } else if (y1 > text2_length) { + // Ran off the bottom of the graph. + k1start += 2; + } else if (front) { + var k2_offset = v_offset + delta - k1; + if ( + k2_offset >= 0 && + k2_offset < v_length && + v2[k2_offset] != -1 + ) { + // Mirror x2 onto top-left coordinate system. + var x2 = text1_length - v2[k2_offset]; + if (x1 >= x2) { + // Overlap detected. + return this.diff_bisectSplit_( + text1, + text2, + x1, + y1, + deadline + ); + } + } + } + } + + // Walk the reverse path one step. + for (var k2 = -d + k2start; k2 <= d - k2end; k2 += 2) { + var k2_offset = v_offset + k2; + var x2; + if ( + k2 == -d || + (k2 != d && v2[k2_offset - 1] < v2[k2_offset + 1]) + ) { + x2 = v2[k2_offset + 1]; + } else { + x2 = v2[k2_offset - 1] + 1; + } + var y2 = x2 - k2; + while ( + x2 < text1_length && + y2 < text2_length && + text1.charAt(text1_length - x2 - 1) == + text2.charAt(text2_length - y2 - 1) + ) { + x2++; + y2++; + } + v2[k2_offset] = x2; + if (x2 > text1_length) { + // Ran off the left of the graph. + k2end += 2; + } else if (y2 > text2_length) { + // Ran off the top of the graph. + k2start += 2; + } else if (!front) { + var k1_offset = v_offset + delta - k2; + if ( + k1_offset >= 0 && + k1_offset < v_length && + v1[k1_offset] != -1 + ) { + var x1 = v1[k1_offset]; + var y1 = v_offset + x1 - k1_offset; + // Mirror x2 onto top-left coordinate system. + x2 = text1_length - x2; + if (x1 >= x2) { + // Overlap detected. + return this.diff_bisectSplit_( + text1, + text2, + x1, + y1, + deadline + ); + } + } + } + } + } + // Diff took too long and hit the deadline or + // number of diffs equals number of characters, no commonality at all. + return [ + new diff_match_patch.Diff(DIFF_DELETE, text1), + new diff_match_patch.Diff(DIFF_INSERT, text2), + ]; +}; + +/** + * Given the location of the 'middle snake', split the diff in two parts + * and recurse. + * @param {string} text1 Old string to be diffed. + * @param {string} text2 New string to be diffed. + * @param {number} x Index of split point in text1. + * @param {number} y Index of split point in text2. + * @param {number} deadline Time at which to bail if not yet complete. + * @return {!Array.} Array of diff tuples. + * @private + */ +diff_match_patch.prototype.diff_bisectSplit_ = function ( + text1, + text2, + x, + y, + deadline +) { + var text1a = text1.substring(0, x); + var text2a = text2.substring(0, y); + var text1b = text1.substring(x); + var text2b = text2.substring(y); + + // Compute both diffs serially. + var diffs = this.diff_main(text1a, text2a, false, deadline); + var diffsb = this.diff_main(text1b, text2b, false, deadline); + + return diffs.concat(diffsb); +}; + +/** + * Split two texts into an array of strings. Reduce the texts to a string of + * hashes where each Unicode character represents one line. + * @param {string} text1 First string. + * @param {string} text2 Second string. + * @return {{chars1: string, chars2: string, lineArray: !Array.}} + * An object containing the encoded text1, the encoded text2 and + * the array of unique strings. + * The zeroth element of the array of unique strings is intentionally blank. + * @private + */ +diff_match_patch.prototype.diff_linesToChars_ = function (text1, text2) { + var lineArray = []; // e.g. lineArray[4] == 'Hello\n' + var lineHash = {}; // e.g. lineHash['Hello\n'] == 4 + + // '\x00' is a valid character, but various debuggers don't like it. + // So we'll insert a junk entry to avoid generating a null character. + lineArray[0] = ''; + + /** + * Split a text into an array of strings. Reduce the texts to a string of + * hashes where each Unicode character represents one line. + * Modifies linearray and linehash through being a closure. + * @param {string} text String to encode. + * @return {string} Encoded string. + * @private + */ + function diff_linesToCharsMunge_(text) { + var chars = ''; + // Walk the text, pulling out a substring for each line. + // text.split('\n') would would temporarily double our memory footprint. + // Modifying text would create many large strings to garbage collect. + var lineStart = 0; + var lineEnd = -1; + // Keeping our own length variable is faster than looking it up. + var lineArrayLength = lineArray.length; + while (lineEnd < text.length - 1) { + lineEnd = text.indexOf('\n', lineStart); + if (lineEnd == -1) { + lineEnd = text.length - 1; + } + var line = text.substring(lineStart, lineEnd + 1); + + if ( + lineHash.hasOwnProperty + ? lineHash.hasOwnProperty(line) + : lineHash[line] !== undefined + ) { + chars += String.fromCharCode(lineHash[line]); + } else { + if (lineArrayLength == maxLines) { + // Bail out at 65535 because + // String.fromCharCode(65536) == String.fromCharCode(0) + line = text.substring(lineStart); + lineEnd = text.length; + } + chars += String.fromCharCode(lineArrayLength); + lineHash[line] = lineArrayLength; + lineArray[lineArrayLength++] = line; + } + lineStart = lineEnd + 1; + } + return chars; + } + // Allocate 2/3rds of the space for text1, the rest for text2. + var maxLines = 40000; + var chars1 = diff_linesToCharsMunge_(text1); + maxLines = 65535; + var chars2 = diff_linesToCharsMunge_(text2); + return { chars1: chars1, chars2: chars2, lineArray: lineArray }; +}; + +/** + * Rehydrate the text in a diff from a string of line hashes to real lines of + * text. + * @param {!Array.} diffs Array of diff tuples. + * @param {!Array.} lineArray Array of unique strings. + * @private + */ +diff_match_patch.prototype.diff_charsToLines_ = function (diffs, lineArray) { + for (var i = 0; i < diffs.length; i++) { + var chars = diffs[i][1]; + var text = []; + for (var j = 0; j < chars.length; j++) { + text[j] = lineArray[chars.charCodeAt(j)]; + } + diffs[i][1] = text.join(''); + } +}; + +/** + * Determine the common prefix of two strings. + * @param {string} text1 First string. + * @param {string} text2 Second string. + * @return {number} The number of characters common to the start of each + * string. + */ +diff_match_patch.prototype.diff_commonPrefix = function (text1, text2) { + // Quick check for common null cases. + if (!text1 || !text2 || text1.charAt(0) != text2.charAt(0)) { + return 0; + } + // Binary search. + // Performance analysis: https://neil.fraser.name/news/2007/10/09/ + var pointermin = 0; + var pointermax = Math.min(text1.length, text2.length); + var pointermid = pointermax; + var pointerstart = 0; + while (pointermin < pointermid) { + if ( + text1.substring(pointerstart, pointermid) == + text2.substring(pointerstart, pointermid) + ) { + pointermin = pointermid; + pointerstart = pointermin; + } else { + pointermax = pointermid; + } + pointermid = Math.floor((pointermax - pointermin) / 2 + pointermin); + } + return pointermid; +}; + +/** + * Determine the common suffix of two strings. + * @param {string} text1 First string. + * @param {string} text2 Second string. + * @return {number} The number of characters common to the end of each string. + */ +diff_match_patch.prototype.diff_commonSuffix = function (text1, text2) { + // Quick check for common null cases. + if ( + !text1 || + !text2 || + text1.charAt(text1.length - 1) != text2.charAt(text2.length - 1) + ) { + return 0; + } + // Binary search. + // Performance analysis: https://neil.fraser.name/news/2007/10/09/ + var pointermin = 0; + var pointermax = Math.min(text1.length, text2.length); + var pointermid = pointermax; + var pointerend = 0; + while (pointermin < pointermid) { + if ( + text1.substring( + text1.length - pointermid, + text1.length - pointerend + ) == + text2.substring( + text2.length - pointermid, + text2.length - pointerend + ) + ) { + pointermin = pointermid; + pointerend = pointermin; + } else { + pointermax = pointermid; + } + pointermid = Math.floor((pointermax - pointermin) / 2 + pointermin); + } + return pointermid; +}; + +/** + * Determine if the suffix of one string is the prefix of another. + * @param {string} text1 First string. + * @param {string} text2 Second string. + * @return {number} The number of characters common to the end of the first + * string and the start of the second string. + * @private + */ +diff_match_patch.prototype.diff_commonOverlap_ = function (text1, text2) { + // Cache the text lengths to prevent multiple calls. + var text1_length = text1.length; + var text2_length = text2.length; + // Eliminate the null case. + if (text1_length == 0 || text2_length == 0) { + return 0; + } + // Truncate the longer string. + if (text1_length > text2_length) { + text1 = text1.substring(text1_length - text2_length); + } else if (text1_length < text2_length) { + text2 = text2.substring(0, text1_length); + } + var text_length = Math.min(text1_length, text2_length); + // Quick check for the worst case. + if (text1 == text2) { + return text_length; + } + + // Start by looking for a single character match + // and increase length until no match is found. + // Performance analysis: https://neil.fraser.name/news/2010/11/04/ + var best = 0; + var length = 1; + while (true) { + var pattern = text1.substring(text_length - length); + var found = text2.indexOf(pattern); + if (found == -1) { + return best; + } + length += found; + if ( + found == 0 || + text1.substring(text_length - length) == text2.substring(0, length) + ) { + best = length; + length++; + } + } +}; + +/** + * Do the two texts share a substring which is at least half the length of the + * longer text? + * This speedup can produce non-minimal diffs. + * @param {string} text1 First string. + * @param {string} text2 Second string. + * @return {Array.} Five element Array, containing the prefix of + * text1, the suffix of text1, the prefix of text2, the suffix of + * text2 and the common middle. Or null if there was no match. + * @private + */ +diff_match_patch.prototype.diff_halfMatch_ = function (text1, text2) { + if (this.Diff_Timeout <= 0) { + // Don't risk returning a non-optimal diff if we have unlimited time. + return null; + } + var longtext = text1.length > text2.length ? text1 : text2; + var shorttext = text1.length > text2.length ? text2 : text1; + if (longtext.length < 4 || shorttext.length * 2 < longtext.length) { + return null; // Pointless. + } + var dmp = this; // 'this' becomes 'window' in a closure. + + /** + * Does a substring of shorttext exist within longtext such that the substring + * is at least half the length of longtext? + * Closure, but does not reference any external variables. + * @param {string} longtext Longer string. + * @param {string} shorttext Shorter string. + * @param {number} i Start index of quarter length substring within longtext. + * @return {Array.} Five element Array, containing the prefix of + * longtext, the suffix of longtext, the prefix of shorttext, the suffix + * of shorttext and the common middle. Or null if there was no match. + * @private + */ + function diff_halfMatchI_(longtext, shorttext, i) { + // Start with a 1/4 length substring at position i as a seed. + var seed = longtext.substring(i, i + Math.floor(longtext.length / 4)); + var j = -1; + var best_common = ''; + var best_longtext_a, + best_longtext_b, + best_shorttext_a, + best_shorttext_b; + while ((j = shorttext.indexOf(seed, j + 1)) != -1) { + var prefixLength = dmp.diff_commonPrefix( + longtext.substring(i), + shorttext.substring(j) + ); + var suffixLength = dmp.diff_commonSuffix( + longtext.substring(0, i), + shorttext.substring(0, j) + ); + if (best_common.length < suffixLength + prefixLength) { + best_common = + shorttext.substring(j - suffixLength, j) + + shorttext.substring(j, j + prefixLength); + best_longtext_a = longtext.substring(0, i - suffixLength); + best_longtext_b = longtext.substring(i + prefixLength); + best_shorttext_a = shorttext.substring(0, j - suffixLength); + best_shorttext_b = shorttext.substring(j + prefixLength); + } + } + if (best_common.length * 2 >= longtext.length) { + return [ + best_longtext_a, + best_longtext_b, + best_shorttext_a, + best_shorttext_b, + best_common, + ]; + } else { + return null; + } + } + + // First check if the second quarter is the seed for a half-match. + var hm1 = diff_halfMatchI_( + longtext, + shorttext, + Math.ceil(longtext.length / 4) + ); + // Check again based on the third quarter. + var hm2 = diff_halfMatchI_( + longtext, + shorttext, + Math.ceil(longtext.length / 2) + ); + var hm; + if (!hm1 && !hm2) { + return null; + } else if (!hm2) { + hm = hm1; + } else if (!hm1) { + hm = hm2; + } else { + // Both matched. Select the longest. + hm = hm1[4].length > hm2[4].length ? hm1 : hm2; + } + + // A half-match was found, sort out the return data. + var text1_a, text1_b, text2_a, text2_b; + if (text1.length > text2.length) { + text1_a = hm[0]; + text1_b = hm[1]; + text2_a = hm[2]; + text2_b = hm[3]; + } else { + text2_a = hm[0]; + text2_b = hm[1]; + text1_a = hm[2]; + text1_b = hm[3]; + } + var mid_common = hm[4]; + return [text1_a, text1_b, text2_a, text2_b, mid_common]; +}; + +/** + * Reduce the number of edits by eliminating semantically trivial equalities. + * @param {!Array.} diffs Array of diff tuples. + */ +diff_match_patch.prototype.diff_cleanupSemantic = function (diffs) { + var changes = false; + var equalities = []; // Stack of indices where equalities are found. + var equalitiesLength = 0; // Keeping our own length var is faster in JS. + /** @type {?string} */ + var lastEquality = null; + // Always equal to diffs[equalities[equalitiesLength - 1]][1] + var pointer = 0; // Index of current position. + // Number of characters that changed prior to the equality. + var length_insertions1 = 0; + var length_deletions1 = 0; + // Number of characters that changed after the equality. + var length_insertions2 = 0; + var length_deletions2 = 0; + while (pointer < diffs.length) { + if (diffs[pointer][0] == DIFF_EQUAL) { + // Equality found. + equalities[equalitiesLength++] = pointer; + length_insertions1 = length_insertions2; + length_deletions1 = length_deletions2; + length_insertions2 = 0; + length_deletions2 = 0; + lastEquality = diffs[pointer][1]; + } else { + // An insertion or deletion. + if (diffs[pointer][0] == DIFF_INSERT) { + length_insertions2 += diffs[pointer][1].length; + } else { + length_deletions2 += diffs[pointer][1].length; + } + // Eliminate an equality that is smaller or equal to the edits on both + // sides of it. + if ( + lastEquality && + lastEquality.length <= + Math.max(length_insertions1, length_deletions1) && + lastEquality.length <= + Math.max(length_insertions2, length_deletions2) + ) { + // Duplicate record. + diffs.splice( + equalities[equalitiesLength - 1], + 0, + new diff_match_patch.Diff(DIFF_DELETE, lastEquality) + ); + // Change second copy to insert. + diffs[equalities[equalitiesLength - 1] + 1][0] = DIFF_INSERT; + // Throw away the equality we just deleted. + equalitiesLength--; + // Throw away the previous equality (it needs to be reevaluated). + equalitiesLength--; + pointer = + equalitiesLength > 0 + ? equalities[equalitiesLength - 1] + : -1; + length_insertions1 = 0; // Reset the counters. + length_deletions1 = 0; + length_insertions2 = 0; + length_deletions2 = 0; + lastEquality = null; + changes = true; + } + } + pointer++; + } + + // Normalize the diff. + if (changes) { + this.diff_cleanupMerge(diffs); + } + this.diff_cleanupSemanticLossless(diffs); + + // Find any overlaps between deletions and insertions. + // e.g: abcxxxxxxdef + // -> abcxxxdef + // e.g: xxxabcdefxxx + // -> defxxxabc + // Only extract an overlap if it is as big as the edit ahead or behind it. + pointer = 1; + while (pointer < diffs.length) { + if ( + diffs[pointer - 1][0] == DIFF_DELETE && + diffs[pointer][0] == DIFF_INSERT + ) { + var deletion = diffs[pointer - 1][1]; + var insertion = diffs[pointer][1]; + var overlap_length1 = this.diff_commonOverlap_(deletion, insertion); + var overlap_length2 = this.diff_commonOverlap_(insertion, deletion); + if (overlap_length1 >= overlap_length2) { + if ( + overlap_length1 >= deletion.length / 2 || + overlap_length1 >= insertion.length / 2 + ) { + // Overlap found. Insert an equality and trim the surrounding edits. + diffs.splice( + pointer, + 0, + new diff_match_patch.Diff( + DIFF_EQUAL, + insertion.substring(0, overlap_length1) + ) + ); + diffs[pointer - 1][1] = deletion.substring( + 0, + deletion.length - overlap_length1 + ); + diffs[pointer + 1][1] = + insertion.substring(overlap_length1); + pointer++; + } + } else { + if ( + overlap_length2 >= deletion.length / 2 || + overlap_length2 >= insertion.length / 2 + ) { + // Reverse overlap found. + // Insert an equality and swap and trim the surrounding edits. + diffs.splice( + pointer, + 0, + new diff_match_patch.Diff( + DIFF_EQUAL, + deletion.substring(0, overlap_length2) + ) + ); + diffs[pointer - 1][0] = DIFF_INSERT; + diffs[pointer - 1][1] = insertion.substring( + 0, + insertion.length - overlap_length2 + ); + diffs[pointer + 1][0] = DIFF_DELETE; + diffs[pointer + 1][1] = deletion.substring(overlap_length2); + pointer++; + } + } + pointer++; + } + pointer++; + } +}; + +/** + * Look for single edits surrounded on both sides by equalities + * which can be shifted sideways to align the edit to a word boundary. + * e.g: The cat came. -> The cat came. + * @param {!Array.} diffs Array of diff tuples. + */ +diff_match_patch.prototype.diff_cleanupSemanticLossless = function (diffs) { + /** + * Given two strings, compute a score representing whether the internal + * boundary falls on logical boundaries. + * Scores range from 6 (best) to 0 (worst). + * Closure, but does not reference any external variables. + * @param {string} one First string. + * @param {string} two Second string. + * @return {number} The score. + * @private + */ + function diff_cleanupSemanticScore_(one, two) { + if (!one || !two) { + // Edges are the best. + return 6; + } + + // Each port of this function behaves slightly differently due to + // subtle differences in each language's definition of things like + // 'whitespace'. Since this function's purpose is largely cosmetic, + // the choice has been made to use each language's native features + // rather than force total conformity. + var char1 = one.charAt(one.length - 1); + var char2 = two.charAt(0); + var nonAlphaNumeric1 = char1.match( + diff_match_patch.nonAlphaNumericRegex_ + ); + var nonAlphaNumeric2 = char2.match( + diff_match_patch.nonAlphaNumericRegex_ + ); + var whitespace1 = + nonAlphaNumeric1 && char1.match(diff_match_patch.whitespaceRegex_); + var whitespace2 = + nonAlphaNumeric2 && char2.match(diff_match_patch.whitespaceRegex_); + var lineBreak1 = + whitespace1 && char1.match(diff_match_patch.linebreakRegex_); + var lineBreak2 = + whitespace2 && char2.match(diff_match_patch.linebreakRegex_); + var blankLine1 = + lineBreak1 && one.match(diff_match_patch.blanklineEndRegex_); + var blankLine2 = + lineBreak2 && two.match(diff_match_patch.blanklineStartRegex_); + + if (blankLine1 || blankLine2) { + // Five points for blank lines. + return 5; + } else if (lineBreak1 || lineBreak2) { + // Four points for line breaks. + return 4; + } else if (nonAlphaNumeric1 && !whitespace1 && whitespace2) { + // Three points for end of sentences. + return 3; + } else if (whitespace1 || whitespace2) { + // Two points for whitespace. + return 2; + } else if (nonAlphaNumeric1 || nonAlphaNumeric2) { + // One point for non-alphanumeric. + return 1; + } + return 0; + } + + var pointer = 1; + // Intentionally ignore the first and last element (don't need checking). + while (pointer < diffs.length - 1) { + if ( + diffs[pointer - 1][0] == DIFF_EQUAL && + diffs[pointer + 1][0] == DIFF_EQUAL + ) { + // This is a single edit surrounded by equalities. + var equality1 = diffs[pointer - 1][1]; + var edit = diffs[pointer][1]; + var equality2 = diffs[pointer + 1][1]; + + // First, shift the edit as far left as possible. + var commonOffset = this.diff_commonSuffix(equality1, edit); + if (commonOffset) { + var commonString = edit.substring(edit.length - commonOffset); + equality1 = equality1.substring( + 0, + equality1.length - commonOffset + ); + edit = + commonString + + edit.substring(0, edit.length - commonOffset); + equality2 = commonString + equality2; + } + + // Second, step character by character right, looking for the best fit. + var bestEquality1 = equality1; + var bestEdit = edit; + var bestEquality2 = equality2; + var bestScore = + diff_cleanupSemanticScore_(equality1, edit) + + diff_cleanupSemanticScore_(edit, equality2); + while (edit.charAt(0) === equality2.charAt(0)) { + equality1 += edit.charAt(0); + edit = edit.substring(1) + equality2.charAt(0); + equality2 = equality2.substring(1); + var score = + diff_cleanupSemanticScore_(equality1, edit) + + diff_cleanupSemanticScore_(edit, equality2); + // The >= encourages trailing rather than leading whitespace on edits. + if (score >= bestScore) { + bestScore = score; + bestEquality1 = equality1; + bestEdit = edit; + bestEquality2 = equality2; + } + } + + if (diffs[pointer - 1][1] != bestEquality1) { + // We have an improvement, save it back to the diff. + if (bestEquality1) { + diffs[pointer - 1][1] = bestEquality1; + } else { + diffs.splice(pointer - 1, 1); + pointer--; + } + diffs[pointer][1] = bestEdit; + if (bestEquality2) { + diffs[pointer + 1][1] = bestEquality2; + } else { + diffs.splice(pointer + 1, 1); + pointer--; + } + } + } + pointer++; + } +}; + +// Define some regex patterns for matching boundaries. +diff_match_patch.nonAlphaNumericRegex_ = /[^a-zA-Z0-9]/; +diff_match_patch.whitespaceRegex_ = /\s/; +diff_match_patch.linebreakRegex_ = /[\r\n]/; +diff_match_patch.blanklineEndRegex_ = /\n\r?\n$/; +diff_match_patch.blanklineStartRegex_ = /^\r?\n\r?\n/; + +/** + * Reduce the number of edits by eliminating operationally trivial equalities. + * @param {!Array.} diffs Array of diff tuples. + */ +diff_match_patch.prototype.diff_cleanupEfficiency = function (diffs) { + var changes = false; + var equalities = []; // Stack of indices where equalities are found. + var equalitiesLength = 0; // Keeping our own length var is faster in JS. + /** @type {?string} */ + var lastEquality = null; + // Always equal to diffs[equalities[equalitiesLength - 1]][1] + var pointer = 0; // Index of current position. + // Is there an insertion operation before the last equality. + var pre_ins = false; + // Is there a deletion operation before the last equality. + var pre_del = false; + // Is there an insertion operation after the last equality. + var post_ins = false; + // Is there a deletion operation after the last equality. + var post_del = false; + while (pointer < diffs.length) { + if (diffs[pointer][0] == DIFF_EQUAL) { + // Equality found. + if ( + diffs[pointer][1].length < this.Diff_EditCost && + (post_ins || post_del) + ) { + // Candidate found. + equalities[equalitiesLength++] = pointer; + pre_ins = post_ins; + pre_del = post_del; + lastEquality = diffs[pointer][1]; + } else { + // Not a candidate, and can never become one. + equalitiesLength = 0; + lastEquality = null; + } + post_ins = post_del = false; + } else { + // An insertion or deletion. + if (diffs[pointer][0] == DIFF_DELETE) { + post_del = true; + } else { + post_ins = true; + } + /* + * Five types to be split: + * ABXYCD + * AXCD + * ABXC + * AXCD + * ABXC + */ + if ( + lastEquality && + ((pre_ins && pre_del && post_ins && post_del) || + (lastEquality.length < this.Diff_EditCost / 2 && + pre_ins + pre_del + post_ins + post_del == 3)) + ) { + // Duplicate record. + diffs.splice( + equalities[equalitiesLength - 1], + 0, + new diff_match_patch.Diff(DIFF_DELETE, lastEquality) + ); + // Change second copy to insert. + diffs[equalities[equalitiesLength - 1] + 1][0] = DIFF_INSERT; + equalitiesLength--; // Throw away the equality we just deleted; + lastEquality = null; + if (pre_ins && pre_del) { + // No changes made which could affect previous entry, keep going. + post_ins = post_del = true; + equalitiesLength = 0; + } else { + equalitiesLength--; // Throw away the previous equality. + pointer = + equalitiesLength > 0 + ? equalities[equalitiesLength - 1] + : -1; + post_ins = post_del = false; + } + changes = true; + } + } + pointer++; + } + + if (changes) { + this.diff_cleanupMerge(diffs); + } +}; + +/** + * Reorder and merge like edit sections. Merge equalities. + * Any edit section can move as long as it doesn't cross an equality. + * @param {!Array.} diffs Array of diff tuples. + */ +diff_match_patch.prototype.diff_cleanupMerge = function (diffs) { + // Add a dummy entry at the end. + diffs.push(new diff_match_patch.Diff(DIFF_EQUAL, '')); + var pointer = 0; + var count_delete = 0; + var count_insert = 0; + var text_delete = ''; + var text_insert = ''; + var commonlength; + while (pointer < diffs.length) { + switch (diffs[pointer][0]) { + case DIFF_INSERT: + count_insert++; + text_insert += diffs[pointer][1]; + pointer++; + break; + case DIFF_DELETE: + count_delete++; + text_delete += diffs[pointer][1]; + pointer++; + break; + case DIFF_EQUAL: + // Upon reaching an equality, check for prior redundancies. + if (count_delete + count_insert > 1) { + if (count_delete !== 0 && count_insert !== 0) { + // Factor out any common prefixies. + commonlength = this.diff_commonPrefix( + text_insert, + text_delete + ); + if (commonlength !== 0) { + if ( + pointer - count_delete - count_insert > 0 && + diffs[ + pointer - count_delete - count_insert - 1 + ][0] == DIFF_EQUAL + ) { + diffs[ + pointer - count_delete - count_insert - 1 + ][1] += text_insert.substring(0, commonlength); + } else { + diffs.splice( + 0, + 0, + new diff_match_patch.Diff( + DIFF_EQUAL, + text_insert.substring(0, commonlength) + ) + ); + pointer++; + } + text_insert = text_insert.substring(commonlength); + text_delete = text_delete.substring(commonlength); + } + // Factor out any common suffixies. + commonlength = this.diff_commonSuffix( + text_insert, + text_delete + ); + if (commonlength !== 0) { + diffs[pointer][1] = + text_insert.substring( + text_insert.length - commonlength + ) + diffs[pointer][1]; + text_insert = text_insert.substring( + 0, + text_insert.length - commonlength + ); + text_delete = text_delete.substring( + 0, + text_delete.length - commonlength + ); + } + } + // Delete the offending records and add the merged ones. + pointer -= count_delete + count_insert; + diffs.splice(pointer, count_delete + count_insert); + if (text_delete.length) { + diffs.splice( + pointer, + 0, + new diff_match_patch.Diff(DIFF_DELETE, text_delete) + ); + pointer++; + } + if (text_insert.length) { + diffs.splice( + pointer, + 0, + new diff_match_patch.Diff(DIFF_INSERT, text_insert) + ); + pointer++; + } + pointer++; + } else if ( + pointer !== 0 && + diffs[pointer - 1][0] == DIFF_EQUAL + ) { + // Merge this equality with the previous one. + diffs[pointer - 1][1] += diffs[pointer][1]; + diffs.splice(pointer, 1); + } else { + pointer++; + } + count_insert = 0; + count_delete = 0; + text_delete = ''; + text_insert = ''; + break; + } + } + if (diffs[diffs.length - 1][1] === '') { + diffs.pop(); // Remove the dummy entry at the end. + } + + // Second pass: look for single edits surrounded on both sides by equalities + // which can be shifted sideways to eliminate an equality. + // e.g: ABAC -> ABAC + var changes = false; + pointer = 1; + // Intentionally ignore the first and last element (don't need checking). + while (pointer < diffs.length - 1) { + if ( + diffs[pointer - 1][0] == DIFF_EQUAL && + diffs[pointer + 1][0] == DIFF_EQUAL + ) { + // This is a single edit surrounded by equalities. + if ( + diffs[pointer][1].substring( + diffs[pointer][1].length - diffs[pointer - 1][1].length + ) == diffs[pointer - 1][1] + ) { + // Shift the edit over the previous equality. + diffs[pointer][1] = + diffs[pointer - 1][1] + + diffs[pointer][1].substring( + 0, + diffs[pointer][1].length - diffs[pointer - 1][1].length + ); + diffs[pointer + 1][1] = + diffs[pointer - 1][1] + diffs[pointer + 1][1]; + diffs.splice(pointer - 1, 1); + changes = true; + } else if ( + diffs[pointer][1].substring(0, diffs[pointer + 1][1].length) == + diffs[pointer + 1][1] + ) { + // Shift the edit over the next equality. + diffs[pointer - 1][1] += diffs[pointer + 1][1]; + diffs[pointer][1] = + diffs[pointer][1].substring(diffs[pointer + 1][1].length) + + diffs[pointer + 1][1]; + diffs.splice(pointer + 1, 1); + changes = true; + } + } + pointer++; + } + // If shifts were made, the diff needs reordering and another shift sweep. + if (changes) { + this.diff_cleanupMerge(diffs); + } +}; + +/** + * loc is a location in text1, compute and return the equivalent location in + * text2. + * e.g. 'The cat' vs 'The big cat', 1->1, 5->8 + * @param {!Array.} diffs Array of diff tuples. + * @param {number} loc Location within text1. + * @return {number} Location within text2. + */ +diff_match_patch.prototype.diff_xIndex = function (diffs, loc) { + var chars1 = 0; + var chars2 = 0; + var last_chars1 = 0; + var last_chars2 = 0; + var x; + for (x = 0; x < diffs.length; x++) { + if (diffs[x][0] !== DIFF_INSERT) { + // Equality or deletion. + chars1 += diffs[x][1].length; + } + if (diffs[x][0] !== DIFF_DELETE) { + // Equality or insertion. + chars2 += diffs[x][1].length; + } + if (chars1 > loc) { + // Overshot the location. + break; + } + last_chars1 = chars1; + last_chars2 = chars2; + } + // Was the location was deleted? + if (diffs.length != x && diffs[x][0] === DIFF_DELETE) { + return last_chars2; + } + // Add the remaining character length. + return last_chars2 + (loc - last_chars1); +}; + +/** + * Convert a diff array into a pretty HTML report. + * @param {!Array.} diffs Array of diff tuples. + * @return {string} HTML representation. + */ +diff_match_patch.prototype.diff_prettyHtml = function (diffs) { + var html = []; + var pattern_amp = /&/g; + var pattern_lt = //g; + var pattern_para = /\n/g; + for (var x = 0; x < diffs.length; x++) { + var op = diffs[x][0]; // Operation (insert, delete, equal) + var data = diffs[x][1]; // Text of change. + var text = data + .replace(pattern_amp, '&') + .replace(pattern_lt, '<') + .replace(pattern_gt, '>') + .replace(pattern_para, '¶
'); + switch (op) { + case DIFF_INSERT: + html[x] = '' + text + ''; + break; + case DIFF_DELETE: + html[x] = '' + text + ''; + break; + case DIFF_EQUAL: + html[x] = '' + text + ''; + break; + } + } + return html.join(''); +}; + +/** + * Compute and return the source text (all equalities and deletions). + * @param {!Array.} diffs Array of diff tuples. + * @return {string} Source text. + */ +diff_match_patch.prototype.diff_text1 = function (diffs) { + var text = []; + for (var x = 0; x < diffs.length; x++) { + if (diffs[x][0] !== DIFF_INSERT) { + text[x] = diffs[x][1]; + } + } + return text.join(''); +}; + +/** + * Compute and return the destination text (all equalities and insertions). + * @param {!Array.} diffs Array of diff tuples. + * @return {string} Destination text. + */ +diff_match_patch.prototype.diff_text2 = function (diffs) { + var text = []; + for (var x = 0; x < diffs.length; x++) { + if (diffs[x][0] !== DIFF_DELETE) { + text[x] = diffs[x][1]; + } + } + return text.join(''); +}; + +/** + * Compute the Levenshtein distance; the number of inserted, deleted or + * substituted characters. + * @param {!Array.} diffs Array of diff tuples. + * @return {number} Number of changes. + */ +diff_match_patch.prototype.diff_levenshtein = function (diffs) { + var levenshtein = 0; + var insertions = 0; + var deletions = 0; + for (var x = 0; x < diffs.length; x++) { + var op = diffs[x][0]; + var data = diffs[x][1]; + switch (op) { + case DIFF_INSERT: + insertions += data.length; + break; + case DIFF_DELETE: + deletions += data.length; + break; + case DIFF_EQUAL: + // A deletion and an insertion is one substitution. + levenshtein += Math.max(insertions, deletions); + insertions = 0; + deletions = 0; + break; + } + } + levenshtein += Math.max(insertions, deletions); + return levenshtein; +}; + +/** + * Crush the diff into an encoded string which describes the operations + * required to transform text1 into text2. + * E.g. =3\t-2\t+ing -> Keep 3 chars, delete 2 chars, insert 'ing'. + * Operations are tab-separated. Inserted text is escaped using %xx notation. + * @param {!Array.} diffs Array of diff tuples. + * @return {string} Delta text. + */ +diff_match_patch.prototype.diff_toDelta = function (diffs) { + var text = []; + for (var x = 0; x < diffs.length; x++) { + switch (diffs[x][0]) { + case DIFF_INSERT: + text[x] = '+' + encodeURI(diffs[x][1]); + break; + case DIFF_DELETE: + text[x] = '-' + diffs[x][1].length; + break; + case DIFF_EQUAL: + text[x] = '=' + diffs[x][1].length; + break; + } + } + return text.join('\t').replace(/%20/g, ' '); +}; + +/** + * Given the original text1, and an encoded string which describes the + * operations required to transform text1 into text2, compute the full diff. + * @param {string} text1 Source string for the diff. + * @param {string} delta Delta text. + * @return {!Array.} Array of diff tuples. + * @throws {!Error} If invalid input. + */ +diff_match_patch.prototype.diff_fromDelta = function (text1, delta) { + var diffs = []; + var diffsLength = 0; // Keeping our own length var is faster in JS. + var pointer = 0; // Cursor in text1 + var tokens = delta.split(/\t/g); + for (var x = 0; x < tokens.length; x++) { + // Each token begins with a one character parameter which specifies the + // operation of this token (delete, insert, equality). + var param = tokens[x].substring(1); + switch (tokens[x].charAt(0)) { + case '+': + try { + diffs[diffsLength++] = new diff_match_patch.Diff( + DIFF_INSERT, + decodeURI(param) + ); + } catch (ex) { + // Malformed URI sequence. + throw new Error( + 'Illegal escape in diff_fromDelta: ' + param + ); + } + break; + case '-': + // Fall through. + case '=': + var n = parseInt(param, 10); + if (isNaN(n) || n < 0) { + throw new Error( + 'Invalid number in diff_fromDelta: ' + param + ); + } + var text = text1.substring(pointer, (pointer += n)); + if (tokens[x].charAt(0) == '=') { + diffs[diffsLength++] = new diff_match_patch.Diff( + DIFF_EQUAL, + text + ); + } else { + diffs[diffsLength++] = new diff_match_patch.Diff( + DIFF_DELETE, + text + ); + } + break; + default: + // Blank tokens are ok (from a trailing \t). + // Anything else is an error. + if (tokens[x]) { + throw new Error( + 'Invalid diff operation in diff_fromDelta: ' + tokens[x] + ); + } + } + } + if (pointer != text1.length) { + throw new Error( + 'Delta length (' + + pointer + + ') does not equal source text length (' + + text1.length + + ').' + ); + } + return diffs; +}; + +// MATCH FUNCTIONS + +/** + * Locate the best instance of 'pattern' in 'text' near 'loc'. + * @param {string} text The text to search. + * @param {string} pattern The pattern to search for. + * @param {number} loc The location to search around. + * @return {number} Best match index or -1. + */ +diff_match_patch.prototype.match_main = function (text, pattern, loc) { + // Check for null inputs. + if (text == null || pattern == null || loc == null) { + throw new Error('Null input. (match_main)'); + } + + loc = Math.max(0, Math.min(loc, text.length)); + if (text == pattern) { + // Shortcut (potentially not guaranteed by the algorithm) + return 0; + } else if (!text.length) { + // Nothing to match. + return -1; + } else if (text.substring(loc, loc + pattern.length) == pattern) { + // Perfect match at the perfect spot! (Includes case of null pattern) + return loc; + } else { + // Do a fuzzy compare. + return this.match_bitap_(text, pattern, loc); + } +}; + +/** + * Locate the best instance of 'pattern' in 'text' near 'loc' using the + * Bitap algorithm. + * @param {string} text The text to search. + * @param {string} pattern The pattern to search for. + * @param {number} loc The location to search around. + * @return {number} Best match index or -1. + * @private + */ +diff_match_patch.prototype.match_bitap_ = function (text, pattern, loc) { + if (pattern.length > this.Match_MaxBits) { + throw new Error('Pattern too long for this browser.'); + } + + // Initialise the alphabet. + var s = this.match_alphabet_(pattern); + + var dmp = this; // 'this' becomes 'window' in a closure. + + /** + * Compute and return the score for a match with e errors and x location. + * Accesses loc and pattern through being a closure. + * @param {number} e Number of errors in match. + * @param {number} x Location of match. + * @return {number} Overall score for match (0.0 = good, 1.0 = bad). + * @private + */ + function match_bitapScore_(e, x) { + var accuracy = e / pattern.length; + var proximity = Math.abs(loc - x); + if (!dmp.Match_Distance) { + // Dodge divide by zero error. + return proximity ? 1.0 : accuracy; + } + return accuracy + proximity / dmp.Match_Distance; + } + + // Highest score beyond which we give up. + var score_threshold = this.Match_Threshold; + // Is there a nearby exact match? (speedup) + var best_loc = text.indexOf(pattern, loc); + if (best_loc != -1) { + score_threshold = Math.min( + match_bitapScore_(0, best_loc), + score_threshold + ); + // What about in the other direction? (speedup) + best_loc = text.lastIndexOf(pattern, loc + pattern.length); + if (best_loc != -1) { + score_threshold = Math.min( + match_bitapScore_(0, best_loc), + score_threshold + ); + } + } + + // Initialise the bit arrays. + var matchmask = 1 << (pattern.length - 1); + best_loc = -1; + + var bin_min, bin_mid; + var bin_max = pattern.length + text.length; + var last_rd; + for (var d = 0; d < pattern.length; d++) { + // Scan for the best match; each iteration allows for one more error. + // Run a binary search to determine how far from 'loc' we can stray at this + // error level. + bin_min = 0; + bin_mid = bin_max; + while (bin_min < bin_mid) { + if (match_bitapScore_(d, loc + bin_mid) <= score_threshold) { + bin_min = bin_mid; + } else { + bin_max = bin_mid; + } + bin_mid = Math.floor((bin_max - bin_min) / 2 + bin_min); + } + // Use the result from this iteration as the maximum for the next. + bin_max = bin_mid; + var start = Math.max(1, loc - bin_mid + 1); + var finish = Math.min(loc + bin_mid, text.length) + pattern.length; + + var rd = Array(finish + 2); + rd[finish + 1] = (1 << d) - 1; + for (var j = finish; j >= start; j--) { + // The alphabet (s) is a sparse hash, so the following line generates + // warnings. + var charMatch = s[text.charAt(j - 1)]; + if (d === 0) { + // First pass: exact match. + rd[j] = ((rd[j + 1] << 1) | 1) & charMatch; + } else { + // Subsequent passes: fuzzy match. + rd[j] = + (((rd[j + 1] << 1) | 1) & charMatch) | + (((last_rd[j + 1] | last_rd[j]) << 1) | 1) | + last_rd[j + 1]; + } + if (rd[j] & matchmask) { + var score = match_bitapScore_(d, j - 1); + // This match will almost certainly be better than any existing match. + // But check anyway. + if (score <= score_threshold) { + // Told you so. + score_threshold = score; + best_loc = j - 1; + if (best_loc > loc) { + // When passing loc, don't exceed our current distance from loc. + start = Math.max(1, 2 * loc - best_loc); + } else { + // Already passed loc, downhill from here on in. + break; + } + } + } + } + // No hope for a (better) match at greater error levels. + if (match_bitapScore_(d + 1, loc) > score_threshold) { + break; + } + last_rd = rd; + } + return best_loc; +}; + +/** + * Initialise the alphabet for the Bitap algorithm. + * @param {string} pattern The text to encode. + * @return {!Object} Hash of character locations. + * @private + */ +diff_match_patch.prototype.match_alphabet_ = function (pattern) { + var s = {}; + for (var i = 0; i < pattern.length; i++) { + s[pattern.charAt(i)] = 0; + } + for (var i = 0; i < pattern.length; i++) { + s[pattern.charAt(i)] |= 1 << (pattern.length - i - 1); + } + return s; +}; + +// PATCH FUNCTIONS + +/** + * Increase the context until it is unique, + * but don't let the pattern expand beyond Match_MaxBits. + * @param {!diff_match_patch.patch_obj} patch The patch to grow. + * @param {string} text Source text. + * @private + */ +diff_match_patch.prototype.patch_addContext_ = function (patch, text) { + if (text.length == 0) { + return; + } + if (patch.start2 === null) { + throw Error('patch not initialized'); + } + var pattern = text.substring(patch.start2, patch.start2 + patch.length1); + var padding = 0; + + // Look for the first and last matches of pattern in text. If two different + // matches are found, increase the pattern length. + while ( + text.indexOf(pattern) != text.lastIndexOf(pattern) && + pattern.length < + this.Match_MaxBits - this.Patch_Margin - this.Patch_Margin + ) { + padding += this.Patch_Margin; + pattern = text.substring( + patch.start2 - padding, + patch.start2 + patch.length1 + padding + ); + } + // Add one chunk for good luck. + padding += this.Patch_Margin; + + // Add the prefix. + var prefix = text.substring(patch.start2 - padding, patch.start2); + if (prefix) { + patch.diffs.unshift(new diff_match_patch.Diff(DIFF_EQUAL, prefix)); + } + // Add the suffix. + var suffix = text.substring( + patch.start2 + patch.length1, + patch.start2 + patch.length1 + padding + ); + if (suffix) { + patch.diffs.push(new diff_match_patch.Diff(DIFF_EQUAL, suffix)); + } + + // Roll back the start points. + patch.start1 -= prefix.length; + patch.start2 -= prefix.length; + // Extend the lengths. + patch.length1 += prefix.length + suffix.length; + patch.length2 += prefix.length + suffix.length; +}; + +/** + * Compute a list of patches to turn text1 into text2. + * Use diffs if provided, otherwise compute it ourselves. + * There are four ways to call this function, depending on what data is + * available to the caller: + * Method 1: + * a = text1, b = text2 + * Method 2: + * a = diffs + * Method 3 (optimal): + * a = text1, b = diffs + * Method 4 (deprecated, use method 3): + * a = text1, b = text2, c = diffs + * + * @param {string|!Array.} a text1 (methods 1,3,4) or + * Array of diff tuples for text1 to text2 (method 2). + * @param {string|!Array.} opt_b text2 (methods 1,4) or + * Array of diff tuples for text1 to text2 (method 3) or undefined (method 2). + * @param {string|!Array.} opt_c Array of diff tuples + * for text1 to text2 (method 4) or undefined (methods 1,2,3). + * @return {!Array.} Array of Patch objects. + */ +diff_match_patch.prototype.patch_make = function (a, opt_b, opt_c) { + var text1, diffs; + if ( + typeof a == 'string' && + typeof opt_b == 'string' && + typeof opt_c == 'undefined' + ) { + // Method 1: text1, text2 + // Compute diffs from text1 and text2. + text1 = /** @type {string} */ (a); + diffs = this.diff_main(text1, /** @type {string} */ (opt_b), true); + if (diffs.length > 2) { + this.diff_cleanupSemantic(diffs); + this.diff_cleanupEfficiency(diffs); + } + } else if ( + a && + typeof a == 'object' && + typeof opt_b == 'undefined' && + typeof opt_c == 'undefined' + ) { + // Method 2: diffs + // Compute text1 from diffs. + diffs = /** @type {!Array.} */ (a); + text1 = this.diff_text1(diffs); + } else if ( + typeof a == 'string' && + opt_b && + typeof opt_b == 'object' && + typeof opt_c == 'undefined' + ) { + // Method 3: text1, diffs + text1 = /** @type {string} */ (a); + diffs = /** @type {!Array.} */ (opt_b); + } else if ( + typeof a == 'string' && + typeof opt_b == 'string' && + opt_c && + typeof opt_c == 'object' + ) { + // Method 4: text1, text2, diffs + // text2 is not used. + text1 = /** @type {string} */ (a); + diffs = /** @type {!Array.} */ (opt_c); + } else { + throw new Error('Unknown call format to patch_make.'); + } + + if (diffs.length === 0) { + return []; // Get rid of the null case. + } + var patches = []; + var patch = new diff_match_patch.patch_obj(); + var patchDiffLength = 0; // Keeping our own length var is faster in JS. + var char_count1 = 0; // Number of characters into the text1 string. + var char_count2 = 0; // Number of characters into the text2 string. + // Start with text1 (prepatch_text) and apply the diffs until we arrive at + // text2 (postpatch_text). We recreate the patches one by one to determine + // context info. + var prepatch_text = text1; + var postpatch_text = text1; + for (var x = 0; x < diffs.length; x++) { + var diff_type = diffs[x][0]; + var diff_text = diffs[x][1]; + + if (!patchDiffLength && diff_type !== DIFF_EQUAL) { + // A new patch starts here. + patch.start1 = char_count1; + patch.start2 = char_count2; + } + + switch (diff_type) { + case DIFF_INSERT: + patch.diffs[patchDiffLength++] = diffs[x]; + patch.length2 += diff_text.length; + postpatch_text = + postpatch_text.substring(0, char_count2) + + diff_text + + postpatch_text.substring(char_count2); + break; + case DIFF_DELETE: + patch.length1 += diff_text.length; + patch.diffs[patchDiffLength++] = diffs[x]; + postpatch_text = + postpatch_text.substring(0, char_count2) + + postpatch_text.substring(char_count2 + diff_text.length); + break; + case DIFF_EQUAL: + if ( + diff_text.length <= 2 * this.Patch_Margin && + patchDiffLength && + diffs.length != x + 1 + ) { + // Small equality inside a patch. + patch.diffs[patchDiffLength++] = diffs[x]; + patch.length1 += diff_text.length; + patch.length2 += diff_text.length; + } else if (diff_text.length >= 2 * this.Patch_Margin) { + // Time for a new patch. + if (patchDiffLength) { + this.patch_addContext_(patch, prepatch_text); + patches.push(patch); + patch = new diff_match_patch.patch_obj(); + patchDiffLength = 0; + // Unlike Unidiff, our patch lists have a rolling context. + // https://github.com/google/diff-match-patch/wiki/Unidiff + // Update prepatch text & pos to reflect the application of the + // just completed patch. + prepatch_text = postpatch_text; + char_count1 = char_count2; + } + } + break; + } + + // Update the current character count. + if (diff_type !== DIFF_INSERT) { + char_count1 += diff_text.length; + } + if (diff_type !== DIFF_DELETE) { + char_count2 += diff_text.length; + } + } + // Pick up the leftover patch if not empty. + if (patchDiffLength) { + this.patch_addContext_(patch, prepatch_text); + patches.push(patch); + } + + return patches; +}; + +/** + * Given an array of patches, return another array that is identical. + * @param {!Array.} patches Array of Patch objects. + * @return {!Array.} Array of Patch objects. + */ +diff_match_patch.prototype.patch_deepCopy = function (patches) { + // Making deep copies is hard in JavaScript. + var patchesCopy = []; + for (var x = 0; x < patches.length; x++) { + var patch = patches[x]; + var patchCopy = new diff_match_patch.patch_obj(); + patchCopy.diffs = []; + for (var y = 0; y < patch.diffs.length; y++) { + patchCopy.diffs[y] = new diff_match_patch.Diff( + patch.diffs[y][0], + patch.diffs[y][1] + ); + } + patchCopy.start1 = patch.start1; + patchCopy.start2 = patch.start2; + patchCopy.length1 = patch.length1; + patchCopy.length2 = patch.length2; + patchesCopy[x] = patchCopy; + } + return patchesCopy; +}; + +/** + * Merge a set of patches onto the text. Return a patched text, as well + * as a list of true/false values indicating which patches were applied. + * @param {!Array.} patches Array of Patch objects. + * @param {string} text Old text. + * @return {!Array.>} Two element Array, containing the + * new text and an array of boolean values. + */ +diff_match_patch.prototype.patch_apply = function (patches, text) { + if (patches.length == 0) { + return [text, []]; + } + + // Deep copy the patches so that no changes are made to originals. + patches = this.patch_deepCopy(patches); + + var nullPadding = this.patch_addPadding(patches); + text = nullPadding + text + nullPadding; + + this.patch_splitMax(patches); + // delta keeps track of the offset between the expected and actual location + // of the previous patch. If there are patches expected at positions 10 and + // 20, but the first patch was found at 12, delta is 2 and the second patch + // has an effective expected position of 22. + var delta = 0; + var results = []; + for (var x = 0; x < patches.length; x++) { + var expected_loc = patches[x].start2 + delta; + var text1 = this.diff_text1(patches[x].diffs); + var start_loc; + var end_loc = -1; + if (text1.length > this.Match_MaxBits) { + // patch_splitMax will only provide an oversized pattern in the case of + // a monster delete. + start_loc = this.match_main( + text, + text1.substring(0, this.Match_MaxBits), + expected_loc + ); + if (start_loc != -1) { + end_loc = this.match_main( + text, + text1.substring(text1.length - this.Match_MaxBits), + expected_loc + text1.length - this.Match_MaxBits + ); + if (end_loc == -1 || start_loc >= end_loc) { + // Can't find valid trailing context. Drop this patch. + start_loc = -1; + } + } + } else { + start_loc = this.match_main(text, text1, expected_loc); + } + if (start_loc == -1) { + // No match found. :( + results[x] = false; + // Subtract the delta for this failed patch from subsequent patches. + delta -= patches[x].length2 - patches[x].length1; + } else { + // Found a match. :) + results[x] = true; + delta = start_loc - expected_loc; + var text2; + if (end_loc == -1) { + text2 = text.substring(start_loc, start_loc + text1.length); + } else { + text2 = text.substring(start_loc, end_loc + this.Match_MaxBits); + } + if (text1 == text2) { + // Perfect match, just shove the replacement text in. + text = + text.substring(0, start_loc) + + this.diff_text2(patches[x].diffs) + + text.substring(start_loc + text1.length); + } else { + // Imperfect match. Run a diff to get a framework of equivalent + // indices. + var diffs = this.diff_main(text1, text2, false); + if ( + text1.length > this.Match_MaxBits && + this.diff_levenshtein(diffs) / text1.length > + this.Patch_DeleteThreshold + ) { + // The end points match, but the content is unacceptably bad. + results[x] = false; + } else { + this.diff_cleanupSemanticLossless(diffs); + var index1 = 0; + var index2; + for (var y = 0; y < patches[x].diffs.length; y++) { + var mod = patches[x].diffs[y]; + if (mod[0] !== DIFF_EQUAL) { + index2 = this.diff_xIndex(diffs, index1); + } + if (mod[0] === DIFF_INSERT) { + // Insertion + text = + text.substring(0, start_loc + index2) + + mod[1] + + text.substring(start_loc + index2); + } else if (mod[0] === DIFF_DELETE) { + // Deletion + text = + text.substring(0, start_loc + index2) + + text.substring( + start_loc + + this.diff_xIndex( + diffs, + index1 + mod[1].length + ) + ); + } + if (mod[0] !== DIFF_DELETE) { + index1 += mod[1].length; + } + } + } + } + } + } + // Strip the padding off. + text = text.substring(nullPadding.length, text.length - nullPadding.length); + return [text, results]; +}; + +/** + * Add some padding on text start and end so that edges can match something. + * Intended to be called only from within patch_apply. + * @param {!Array.} patches Array of Patch objects. + * @return {string} The padding string added to each side. + */ +diff_match_patch.prototype.patch_addPadding = function (patches) { + var paddingLength = this.Patch_Margin; + var nullPadding = ''; + for (var x = 1; x <= paddingLength; x++) { + nullPadding += String.fromCharCode(x); + } + + // Bump all the patches forward. + for (var x = 0; x < patches.length; x++) { + patches[x].start1 += paddingLength; + patches[x].start2 += paddingLength; + } + + // Add some padding on start of first diff. + var patch = patches[0]; + var diffs = patch.diffs; + if (diffs.length == 0 || diffs[0][0] != DIFF_EQUAL) { + // Add nullPadding equality. + diffs.unshift(new diff_match_patch.Diff(DIFF_EQUAL, nullPadding)); + patch.start1 -= paddingLength; // Should be 0. + patch.start2 -= paddingLength; // Should be 0. + patch.length1 += paddingLength; + patch.length2 += paddingLength; + } else if (paddingLength > diffs[0][1].length) { + // Grow first equality. + var extraLength = paddingLength - diffs[0][1].length; + diffs[0][1] = nullPadding.substring(diffs[0][1].length) + diffs[0][1]; + patch.start1 -= extraLength; + patch.start2 -= extraLength; + patch.length1 += extraLength; + patch.length2 += extraLength; + } + + // Add some padding on end of last diff. + patch = patches[patches.length - 1]; + diffs = patch.diffs; + if (diffs.length == 0 || diffs[diffs.length - 1][0] != DIFF_EQUAL) { + // Add nullPadding equality. + diffs.push(new diff_match_patch.Diff(DIFF_EQUAL, nullPadding)); + patch.length1 += paddingLength; + patch.length2 += paddingLength; + } else if (paddingLength > diffs[diffs.length - 1][1].length) { + // Grow last equality. + var extraLength = paddingLength - diffs[diffs.length - 1][1].length; + diffs[diffs.length - 1][1] += nullPadding.substring(0, extraLength); + patch.length1 += extraLength; + patch.length2 += extraLength; + } + + return nullPadding; +}; + +/** + * Look through the patches and break up any which are longer than the maximum + * limit of the match algorithm. + * Intended to be called only from within patch_apply. + * @param {!Array.} patches Array of Patch objects. + */ +diff_match_patch.prototype.patch_splitMax = function (patches) { + var patch_size = this.Match_MaxBits; + for (var x = 0; x < patches.length; x++) { + if (patches[x].length1 <= patch_size) { + continue; + } + var bigpatch = patches[x]; + // Remove the big old patch. + patches.splice(x--, 1); + var start1 = bigpatch.start1; + var start2 = bigpatch.start2; + var precontext = ''; + while (bigpatch.diffs.length !== 0) { + // Create one of several smaller patches. + var patch = new diff_match_patch.patch_obj(); + var empty = true; + patch.start1 = start1 - precontext.length; + patch.start2 = start2 - precontext.length; + if (precontext !== '') { + patch.length1 = patch.length2 = precontext.length; + patch.diffs.push( + new diff_match_patch.Diff(DIFF_EQUAL, precontext) + ); + } + while ( + bigpatch.diffs.length !== 0 && + patch.length1 < patch_size - this.Patch_Margin + ) { + var diff_type = bigpatch.diffs[0][0]; + var diff_text = bigpatch.diffs[0][1]; + if (diff_type === DIFF_INSERT) { + // Insertions are harmless. + patch.length2 += diff_text.length; + start2 += diff_text.length; + patch.diffs.push(bigpatch.diffs.shift()); + empty = false; + } else if ( + diff_type === DIFF_DELETE && + patch.diffs.length == 1 && + patch.diffs[0][0] == DIFF_EQUAL && + diff_text.length > 2 * patch_size + ) { + // This is a large deletion. Let it pass in one chunk. + patch.length1 += diff_text.length; + start1 += diff_text.length; + empty = false; + patch.diffs.push( + new diff_match_patch.Diff(diff_type, diff_text) + ); + bigpatch.diffs.shift(); + } else { + // Deletion or equality. Only take as much as we can stomach. + diff_text = diff_text.substring( + 0, + patch_size - patch.length1 - this.Patch_Margin + ); + patch.length1 += diff_text.length; + start1 += diff_text.length; + if (diff_type === DIFF_EQUAL) { + patch.length2 += diff_text.length; + start2 += diff_text.length; + } else { + empty = false; + } + patch.diffs.push( + new diff_match_patch.Diff(diff_type, diff_text) + ); + if (diff_text == bigpatch.diffs[0][1]) { + bigpatch.diffs.shift(); + } else { + bigpatch.diffs[0][1] = bigpatch.diffs[0][1].substring( + diff_text.length + ); + } + } + } + // Compute the head context for the next patch. + precontext = this.diff_text2(patch.diffs); + precontext = precontext.substring( + precontext.length - this.Patch_Margin + ); + // Append the end context for this patch. + var postcontext = this.diff_text1(bigpatch.diffs).substring( + 0, + this.Patch_Margin + ); + if (postcontext !== '') { + patch.length1 += postcontext.length; + patch.length2 += postcontext.length; + if ( + patch.diffs.length !== 0 && + patch.diffs[patch.diffs.length - 1][0] === DIFF_EQUAL + ) { + patch.diffs[patch.diffs.length - 1][1] += postcontext; + } else { + patch.diffs.push( + new diff_match_patch.Diff(DIFF_EQUAL, postcontext) + ); + } + } + if (!empty) { + patches.splice(++x, 0, patch); + } + } + } +}; + +/** + * Take a list of patches and return a textual representation. + * @param {!Array.} patches Array of Patch objects. + * @return {string} Text representation of patches. + */ +diff_match_patch.prototype.patch_toText = function (patches) { + var text = []; + for (var x = 0; x < patches.length; x++) { + text[x] = patches[x]; + } + return text.join(''); +}; + +/** + * Parse a textual representation of patches and return a list of Patch objects. + * @param {string} textline Text representation of patches. + * @return {!Array.} Array of Patch objects. + * @throws {!Error} If invalid input. + */ +diff_match_patch.prototype.patch_fromText = function (textline) { + var patches = []; + if (!textline) { + return patches; + } + var text = textline.split('\n'); + var textPointer = 0; + var patchHeader = /^@@ -(\d+),?(\d*) \+(\d+),?(\d*) @@$/; + while (textPointer < text.length) { + var m = text[textPointer].match(patchHeader); + if (!m) { + throw new Error('Invalid patch string: ' + text[textPointer]); + } + var patch = new diff_match_patch.patch_obj(); + patches.push(patch); + patch.start1 = parseInt(m[1], 10); + if (m[2] === '') { + patch.start1--; + patch.length1 = 1; + } else if (m[2] == '0') { + patch.length1 = 0; + } else { + patch.start1--; + patch.length1 = parseInt(m[2], 10); + } + + patch.start2 = parseInt(m[3], 10); + if (m[4] === '') { + patch.start2--; + patch.length2 = 1; + } else if (m[4] == '0') { + patch.length2 = 0; + } else { + patch.start2--; + patch.length2 = parseInt(m[4], 10); + } + textPointer++; + + while (textPointer < text.length) { + var sign = text[textPointer].charAt(0); + try { + var line = decodeURI(text[textPointer].substring(1)); + } catch (ex) { + // Malformed URI sequence. + throw new Error('Illegal escape in patch_fromText: ' + line); + } + if (sign == '-') { + // Deletion. + patch.diffs.push(new diff_match_patch.Diff(DIFF_DELETE, line)); + } else if (sign == '+') { + // Insertion. + patch.diffs.push(new diff_match_patch.Diff(DIFF_INSERT, line)); + } else if (sign == ' ') { + // Minor equality. + patch.diffs.push(new diff_match_patch.Diff(DIFF_EQUAL, line)); + } else if (sign == '@') { + // Start of next patch. + break; + } else if (sign === '') { + // Blank line? Whatever. + } else { + // WTF? + throw new Error( + 'Invalid patch mode "' + sign + '" in: ' + line + ); + } + textPointer++; + } + } + return patches; +}; + +/** + * Class representing one patch operation. + * @constructor + */ +diff_match_patch.patch_obj = function () { + /** @type {!Array.} */ + this.diffs = []; + /** @type {?number} */ + this.start1 = null; + /** @type {?number} */ + this.start2 = null; + /** @type {number} */ + this.length1 = 0; + /** @type {number} */ + this.length2 = 0; +}; + +/** + * Emulate GNU diff's format. + * Header: @@ -382,8 +481,9 @@ + * Indices are printed as 1-based, not 0-based. + * @return {string} The GNU diff string. + */ +diff_match_patch.patch_obj.prototype.toString = function () { + var coords1, coords2; + if (this.length1 === 0) { + coords1 = this.start1 + ',0'; + } else if (this.length1 == 1) { + coords1 = this.start1 + 1; + } else { + coords1 = this.start1 + 1 + ',' + this.length1; + } + if (this.length2 === 0) { + coords2 = this.start2 + ',0'; + } else if (this.length2 == 1) { + coords2 = this.start2 + 1; + } else { + coords2 = this.start2 + 1 + ',' + this.length2; + } + var text = ['@@ -' + coords1 + ' +' + coords2 + ' @@\n']; + var op; + // Escape the body of the patch with %xx notation. + for (var x = 0; x < this.diffs.length; x++) { + switch (this.diffs[x][0]) { + case DIFF_INSERT: + op = '+'; + break; + case DIFF_DELETE: + op = '-'; + break; + case DIFF_EQUAL: + op = ' '; + break; + } + text[x + 1] = op + encodeURI(this.diffs[x][1]) + '\n'; + } + return text.join('').replace(/%20/g, ' '); +}; diff --git a/packages/surrealdb/vendor/dmp.js b/packages/surrealdb/vendor/dmp.js new file mode 100644 index 000000000..24db36d41 --- /dev/null +++ b/packages/surrealdb/vendor/dmp.js @@ -0,0 +1,15 @@ +(function () { + /* globals define, diff_match_patch */ + + function generateModule(name, values) { + define(name, [], function () { + 'use strict'; + return values; + }); + } + + generateModule('dmp', { + default: + typeof diff_match_patch === 'undefined' ? null : diff_match_patch, + }); +})(); diff --git a/packages/surrealdb/vendor/surrealdb.js b/packages/surrealdb/vendor/surrealdb.js new file mode 100644 index 000000000..4705022ad --- /dev/null +++ b/packages/surrealdb/vendor/surrealdb.js @@ -0,0 +1,14 @@ +(function () { + /* globals define, Surreal */ + + function generateModule(name, values) { + define(name, [], function () { + 'use strict'; + return values; + }); + } + + generateModule('surrealdb', { + default: typeof Surreal === 'undefined' ? null : Surreal, + }); +})();