diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml index db9f273..c6fc4b3 100644 --- a/.github/workflows/quality.yml +++ b/.github/workflows/quality.yml @@ -4,9 +4,10 @@ on: pull_request: branches: - main + - release-* jobs: - quality_pipeline_lint: + quality_pipeline_lint_and_typecheck: runs-on: ubuntu-24.04 timeout-minutes: 5 @@ -25,6 +26,9 @@ jobs: - name: Lint run: npm run lint + - name: Type Check + run: npm run test-types + quality_pipeline_nodejs: runs-on: ubuntu-24.04 timeout-minutes: 5 @@ -68,6 +72,9 @@ jobs: - name: Install run: npm ci && npx playwright install --with-deps chromium + - name: Build + run: npm run build + - name: Test run: npm run test:chromium @@ -98,6 +105,9 @@ jobs: - name: Install run: npm ci && npx playwright install --with-deps firefox + - name: Build + run: npm run build + - name: Test run: npm run test:firefox @@ -122,6 +132,9 @@ jobs: - name: Install run: npm ci && npx playwright install --with-deps webkit + - name: Build + run: npm run build + - name: Test run: npm run test:webkit diff --git a/ci/eslint.config.mjs b/ci/eslint.config.mjs index af98e0d..6440f1b 100644 --- a/ci/eslint.config.mjs +++ b/ci/eslint.config.mjs @@ -1,29 +1,23 @@ -import globals from 'globals'; -import path from 'node:path'; -import { fileURLToPath } from 'node:url'; import js from '@eslint/js'; -import { FlatCompat } from '@eslint/eslintrc'; - -const __filename = fileURLToPath(import.meta.url); -const __dirname = path.dirname(__filename); -const compat = new FlatCompat({ - baseDirectory: __dirname, - recommendedConfig: js.configs.recommended, - allConfig: js.configs.all -}); +import { defineConfig } from 'eslint/config'; +import tseslint from 'typescript-eslint'; +import globals from 'globals'; -export default [...compat.extends('eslint:recommended'), { - languageOptions: { - globals: { - ...globals.browser, - ...globals.node +export default defineConfig( + js.configs.recommended, + tseslint.configs.recommended, + { + languageOptions: { + globals: { + ...globals.browser, + ...globals.node + }, + ecmaVersion: 'latest', + sourceType: 'module' }, - - ecmaVersion: 'latest', - sourceType: 'module' - }, - - rules: { - 'no-shadow': 2 + rules: { + 'no-shadow': 'error', + semi: 'error' + } } -}]; \ No newline at end of file +); \ No newline at end of file diff --git a/ci/tools/build-utils.js b/ci/tools/build-utils.js index a40da00..0b74622 100644 --- a/ci/tools/build-utils.js +++ b/ci/tools/build-utils.js @@ -1,87 +1,62 @@ import path from 'node:path'; import fs from 'node:fs/promises'; - import esbuild from 'esbuild'; - import { calcIntegrity } from './integrity-utils.js'; import * as stdout from './stdout.js'; const SRC_DIR = 'src'; const DIST_DIR = 'dist'; +const MAIN_FILE = 'object-observer.ts'; +const ENTRY_POINT = path.join(SRC_DIR, MAIN_FILE); stdout.writeGreen('Starting the build...'); stdout.writeNewline(); -stdout.writeNewline(); try { await cleanDistDir(); await buildESModule(); - await buildCJSModule(); await buildCDNResources(); } catch (e) { console.error(e); } -stdout.writeGreen('... build done'); +stdout.writeGreen('... done'); stdout.writeNewline(); stdout.writeNewline(); async function cleanDistDir() { - stdout.write(`\tcleaning "dist"...`); + stdout.write(`- cleaning "dist"...`); await fs.rm(DIST_DIR, { recursive: true, force: true }); await fs.mkdir(DIST_DIR); - stdout.writeGreen('\tOK'); + stdout.writeGreen('\t\tOK'); stdout.writeNewline(); } async function buildESModule() { - stdout.write('\tbuilding ESM resources...'); + stdout.write('- building ESM resources...'); - await fs.copyFile(path.join(SRC_DIR, 'object-observer.d.ts'), path.join(DIST_DIR, 'object-observer.d.ts')); - await fs.copyFile(path.join(SRC_DIR, 'object-observer.js'), path.join(DIST_DIR, 'object-observer.js')); - await esbuild.build({ - entryPoints: [path.join(DIST_DIR, 'object-observer.js')], + const config = { + entryPoints: [ENTRY_POINT], + bundle: true, outdir: DIST_DIR, - minify: true, + format: 'esm', + minify: false, sourcemap: true, - sourcesContent: false, - outExtension: { '.js': '.min.js' } - }); - - stdout.writeGreen('\tOK'); - stdout.writeNewline(); -} - -async function buildCJSModule() { - stdout.write('\tbuilding CJS resources...'); - - const baseConfig = { - entryPoints: [path.join(SRC_DIR, 'object-observer.js')], - outdir: path.join(DIST_DIR, 'cjs'), - format: 'cjs', - outExtension: { '.js': '.cjs' } + sourcesContent: false }; - await esbuild.build(baseConfig); - await esbuild.build({ - ...baseConfig, - entryPoints: [path.join(DIST_DIR, 'cjs', 'object-observer.cjs')], - minify: true, - sourcemap: true, - sourcesContent: false, - outExtension: { '.js': '.min.cjs' } - }); + await esbuild.build(config); + await esbuild.build({ ...config, minify: true, outExtension: { '.js': '.min.js' } }); stdout.writeGreen('\tOK'); stdout.writeNewline(); } async function buildCDNResources() { - stdout.write('\tbuilding CDN resources...'); + stdout.write('- building CDN resources...'); const CDN_DIR = path.join(DIST_DIR, 'cdn'); - await fs.mkdir(CDN_DIR); const files = (await fs.readdir(DIST_DIR)) @@ -92,7 +67,6 @@ async function buildCDNResources() { } const sriMap = await calcIntegrity(CDN_DIR); - await fs.writeFile('sri.json', JSON.stringify(sriMap, null, '\t'), { encoding: 'utf-8' }); stdout.writeGreen('\tOK'); diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..7f0a594 --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,12 @@ +# Architecture + +```mermaid +classDiagram + class ObservableBase { + + } + + ObservableBase <|-- ObservableObject + ObservableBase <|-- ObservableArray + ObservableBase <|-- ObservableTypedArray +``` \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 0286cfa..0b24b31 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,21 +17,13 @@ ], "license": "ISC", "devDependencies": { - "@eslint/eslintrc": "^3.3.1", "@eslint/js": "^9.39.1", - "@gullerya/just-test": "^4.0.12", + "@gullerya/just-test": "^4.0.17", "esbuild": "^0.27.0", "eslint": "^9.39.1", - "globals": "^16.5.0" - } - }, - "node_modules/@aashutoshrathi/word-wrap": { - "version": "1.2.6", - "resolved": "https://registry.npmjs.org/@aashutoshrathi/word-wrap/-/word-wrap-1.2.6.tgz", - "integrity": "sha512-1Yjs2SvM8TflER/OD3cOjhWWOZb58A2t7wpE2S9XfBYTiIl+XFhQG2bjy4Pu1I+EAlCNUzRDYDdFwFYUKvXcIA==", - "dev": true, - "engines": { - "node": ">=0.10.0" + "globals": "^16.5.0", + "typescript": "^5.9.3", + "typescript-eslint": "^8.48.0" } }, "node_modules/@esbuild/aix-ppc64": { @@ -509,9 +501,9 @@ } }, "node_modules/@eslint-community/regexpp": { - "version": "4.12.1", - "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.1.tgz", - "integrity": "sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ==", + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", "dev": true, "license": "MIT", "engines": { @@ -533,17 +525,6 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, - "node_modules/@eslint/config-array/node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, "node_modules/@eslint/config-array/node_modules/minimatch": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", @@ -607,17 +588,6 @@ "url": "https://opencollective.com/eslint" } }, - "node_modules/@eslint/eslintrc/node_modules/brace-expansion": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", - "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, "node_modules/@eslint/eslintrc/node_modules/globals": { "version": "14.0.0", "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", @@ -682,23 +652,39 @@ } }, "node_modules/@gullerya/just-test": { - "version": "4.0.12", - "resolved": "https://registry.npmjs.org/@gullerya/just-test/-/just-test-4.0.12.tgz", - "integrity": "sha512-jyTaatrblzhpySRaT6AT/ihxux3+np20YmLPIwsG2USJ/3MpbHSLOqxvUy0Yk8+GHktqr1ENL4PE+fvX1TvNCA==", + "version": "4.0.17", + "resolved": "https://registry.npmjs.org/@gullerya/just-test/-/just-test-4.0.17.tgz", + "integrity": "sha512-L9naPalogYi0aEQIodeSXuN/p+MGDd0z1WN89oVYdkJrquw2Sjfk14MXtSuNBz7FUvHw8Qwm1RdTi8keQNbyfg==", "dev": true, "license": "MIT", "dependencies": { "data-tier": "^3.6.6", "data-tier-list": "^2.2.1", - "glob": "^11.0.3", + "glob": "^12.0.0", "minimatch": "^10.1.1", "playwright": "^1.56.1", - "rich-component": "^1.8.0" + "rich-component": "^1.8.0", + "typescript": "^5.9.3" }, "funding": { "url": "https://paypal.me/gullerya?locale.x=en_US" } }, + "node_modules/@gullerya/object-observer": { + "version": "6.1.4", + "resolved": "https://registry.npmjs.org/@gullerya/object-observer/-/object-observer-6.1.4.tgz", + "integrity": "sha512-taINf8vEnq6iwXpX/tuOcXpT+3Y3H3Hqi9mH5v+DL/D52wYP8lc3OXgpxO8hb+UXwChEUw8n9Nz3Q/htRYl7Gg==", + "dev": true, + "funding": [ + { + "url": "https://paypal.me/gullerya?locale.x=en_US" + }, + { + "url": "https://tidelift.com/funding/github/npm/object-observer" + } + ], + "license": "ISC" + }, "node_modules/@humanfs/core": { "version": "0.19.1", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", @@ -710,38 +696,25 @@ } }, "node_modules/@humanfs/node": { - "version": "0.16.6", - "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.6.tgz", - "integrity": "sha512-YuI2ZHQL78Q5HbhDiBA1X4LmYdXCKCMQIfw0pw7piHJwyREFebJUvrQN4cMssyES6x+vfUbx1CIpaQUKYdQZOw==", + "version": "0.16.7", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz", + "integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==", "dev": true, "license": "Apache-2.0", "dependencies": { "@humanfs/core": "^0.19.1", - "@humanwhocodes/retry": "^0.3.0" + "@humanwhocodes/retry": "^0.4.0" }, "engines": { "node": ">=18.18.0" } }, - "node_modules/@humanfs/node/node_modules/@humanwhocodes/retry": { - "version": "0.3.1", - "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.3.1.tgz", - "integrity": "sha512-JBxkERygn7Bv/GbN5Rv8Ul6LVknS+5Bp6RgDC/O8gEBU/yeH5Ui5C/OlWrTb6qct7LjjfT6Re2NxB0ln0yYybA==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=18.18" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/nzakas" - } - }, "node_modules/@humanwhocodes/module-importer": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", "dev": true, + "license": "Apache-2.0", "engines": { "node": ">=12.22" }, @@ -806,9 +779,9 @@ } }, "node_modules/@types/estree": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.6.tgz", - "integrity": "sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw==", + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", "dev": true, "license": "MIT" }, @@ -819,6 +792,264 @@ "dev": true, "license": "MIT" }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.48.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.48.0.tgz", + "integrity": "sha512-XxXP5tL1txl13YFtrECECQYeZjBZad4fyd3cFV4a19LkAY/bIp9fev3US4S5fDVV2JaYFiKAZ/GRTOLer+mbyQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.10.0", + "@typescript-eslint/scope-manager": "8.48.0", + "@typescript-eslint/type-utils": "8.48.0", + "@typescript-eslint/utils": "8.48.0", + "@typescript-eslint/visitor-keys": "8.48.0", + "graphemer": "^1.4.0", + "ignore": "^7.0.0", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.1.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.48.0", + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.48.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.48.0.tgz", + "integrity": "sha512-jCzKdm/QK0Kg4V4IK/oMlRZlY+QOcdjv89U2NgKHZk1CYTj82/RVSx1mV/0gqCVMJ/DA+Zf/S4NBWNF8GQ+eqQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@typescript-eslint/scope-manager": "8.48.0", + "@typescript-eslint/types": "8.48.0", + "@typescript-eslint/typescript-estree": "8.48.0", + "@typescript-eslint/visitor-keys": "8.48.0", + "debug": "^4.3.4" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.48.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.48.0.tgz", + "integrity": "sha512-Ne4CTZyRh1BecBf84siv42wv5vQvVmgtk8AuiEffKTUo3DrBaGYZueJSxxBZ8fjk/N3DrgChH4TOdIOwOwiqqw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.48.0", + "@typescript-eslint/types": "^8.48.0", + "debug": "^4.3.4" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.48.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.48.0.tgz", + "integrity": "sha512-uGSSsbrtJrLduti0Q1Q9+BF1/iFKaxGoQwjWOIVNJv0o6omrdyR8ct37m4xIl5Zzpkp69Kkmvom7QFTtue89YQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.48.0", + "@typescript-eslint/visitor-keys": "8.48.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.48.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.48.0.tgz", + "integrity": "sha512-WNebjBdFdyu10sR1M4OXTt2OkMd5KWIL+LLfeH9KhgP+jzfDV/LI3eXzwJ1s9+Yc0Kzo2fQCdY/OpdusCMmh6w==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.48.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.48.0.tgz", + "integrity": "sha512-zbeVaVqeXhhab6QNEKfK96Xyc7UQuoFWERhEnj3mLVnUWrQnv15cJNseUni7f3g557gm0e46LZ6IJ4NJVOgOpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.48.0", + "@typescript-eslint/typescript-estree": "8.48.0", + "@typescript-eslint/utils": "8.48.0", + "debug": "^4.3.4", + "ts-api-utils": "^2.1.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.48.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.48.0.tgz", + "integrity": "sha512-cQMcGQQH7kwKoVswD1xdOytxQR60MWKM1di26xSUtxehaDs/32Zpqsu5WJlXTtTTqyAVK8R7hvsUnIXRS+bjvA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.48.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.48.0.tgz", + "integrity": "sha512-ljHab1CSO4rGrQIAyizUS6UGHHCiAYhbfcIZ1zVJr5nMryxlXMVWS3duFPSKvSUbFPwkXMFk1k0EMIjub4sRRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.48.0", + "@typescript-eslint/tsconfig-utils": "8.48.0", + "@typescript-eslint/types": "8.48.0", + "@typescript-eslint/visitor-keys": "8.48.0", + "debug": "^4.3.4", + "minimatch": "^9.0.4", + "semver": "^7.6.0", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.1.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.48.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.48.0.tgz", + "integrity": "sha512-yTJO1XuGxCsSfIVt1+1UrLHtue8xz16V8apzPYI06W0HbEbEWHxHXgZaAgavIkoh+GeV6hKKd5jm0sS6OYxWXQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.7.0", + "@typescript-eslint/scope-manager": "8.48.0", + "@typescript-eslint/types": "8.48.0", + "@typescript-eslint/typescript-estree": "8.48.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.48.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.48.0.tgz", + "integrity": "sha512-T0XJMaRPOH3+LBbAfzR2jalckP1MSG/L9eUtY0DEzUyVaXJ/t6zN0nR7co5kz0Jko/nkSYCBRkz1djvjajVTTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.48.0", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, "node_modules/acorn": { "version": "8.15.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", @@ -878,6 +1109,7 @@ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", "dev": true, + "license": "MIT", "dependencies": { "color-convert": "^2.0.1" }, @@ -899,7 +1131,19 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true + "dev": true, + "license": "MIT" + }, + "node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } }, "node_modules/callsites": { "version": "3.1.0", @@ -916,6 +1160,7 @@ "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", "dev": true, + "license": "MIT", "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" @@ -932,6 +1177,7 @@ "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", "dev": true, + "license": "MIT", "dependencies": { "color-name": "~1.1.4" }, @@ -943,13 +1189,15 @@ "version": "1.1.4", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/cross-spawn": { "version": "7.0.6", @@ -980,6 +1228,7 @@ "url": "https://tidelift.com/funding/github/npm/data-tier" } ], + "license": "ISC", "dependencies": { "@gullerya/object-observer": "^6.0.4" } @@ -989,6 +1238,7 @@ "resolved": "https://registry.npmjs.org/data-tier-list/-/data-tier-list-2.2.1.tgz", "integrity": "sha512-KLpdHDY2PvR6lFUsHA5NVqfCNND/gUU/8Tf7sD0D44wFKhHXt04sVWlKk/kJfcr6U7qxBumG1cAnBPOJSz6s9g==", "dev": true, + "license": "ISC", "dependencies": { "data-tier": "^3.6.1" }, @@ -996,24 +1246,10 @@ "url": "https://paypal.me/gullerya?locale.x=en_US" } }, - "node_modules/data-tier/node_modules/@gullerya/object-observer": { - "version": "6.1.1", - "resolved": "https://registry.npmjs.org/@gullerya/object-observer/-/object-observer-6.1.1.tgz", - "integrity": "sha512-bEfCYXWHbv3p10gnxTUGNOoTh96yQHl/uRCp0zXT+BgYJyV3Rte/bpFvBNp7SXYZvbsLjCi6jx6+/w7buNP0aw==", - "dev": true, - "funding": [ - { - "url": "https://paypal.me/gullerya?locale.x=en_US" - }, - { - "url": "https://tidelift.com/funding/github/npm/object-observer" - } - ] - }, "node_modules/debug": { - "version": "4.4.0", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz", - "integrity": "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==", + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", "dev": true, "license": "MIT", "dependencies": { @@ -1032,7 +1268,8 @@ "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/eastasianwidth": { "version": "0.2.0", @@ -1095,6 +1332,7 @@ "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", "dev": true, + "license": "MIT", "engines": { "node": ">=10" }, @@ -1193,21 +1431,12 @@ "url": "https://opencollective.com/eslint" } }, - "node_modules/eslint/node_modules/brace-expansion": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", - "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", - "dev": true, - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, "node_modules/eslint/node_modules/minimatch": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", "dev": true, + "license": "ISC", "dependencies": { "brace-expansion": "^1.1.7" }, @@ -1234,10 +1463,11 @@ } }, "node_modules/esquery": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.5.0.tgz", - "integrity": "sha512-YQLXUplAwJgCydQ78IMJywZCceoqk1oH01OERdSAJc/7U2AylwjhSCLDEtqwg811idIS/9fIU5GjG73IgjKMVg==", + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.6.0.tgz", + "integrity": "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==", "dev": true, + "license": "BSD-3-Clause", "dependencies": { "estraverse": "^5.1.0" }, @@ -1263,6 +1493,7 @@ "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", "dev": true, + "license": "BSD-2-Clause", "engines": { "node": ">=4.0" } @@ -1295,7 +1526,26 @@ "version": "2.0.6", "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", - "dev": true + "dev": true, + "license": "MIT" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } }, "node_modules/file-entry-cache": { "version": "8.0.0", @@ -1315,6 +1565,7 @@ "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", "dev": true, + "license": "MIT", "dependencies": { "locate-path": "^6.0.0", "path-exists": "^4.0.0" @@ -1341,9 +1592,9 @@ } }, "node_modules/flatted": { - "version": "3.3.2", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.2.tgz", - "integrity": "sha512-AiwGJM8YcNOaobumgtng+6NHuOqC3A7MixFeDafM3X9cIUM+xUXoS5Vfgf+OihAYe20fxqNM9yPBXJzRtZ/4eA==", + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", + "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", "dev": true, "license": "ISC" }, @@ -1380,15 +1631,15 @@ } }, "node_modules/glob": { - "version": "11.0.3", - "resolved": "https://registry.npmjs.org/glob/-/glob-11.0.3.tgz", - "integrity": "sha512-2Nim7dha1KVkaiF4q6Dj+ngPPMdfvLJEOpZk/jKiUAkqKebpGAWQXAq9z1xu9HKu5lWfqw/FASuccEjyznjPaA==", + "version": "12.0.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-12.0.0.tgz", + "integrity": "sha512-5Qcll1z7IKgHr5g485ePDdHcNQY0k2dtv/bjYy0iuyGxQw2qSOiiXUXJ+AYQpg3HNoUMHqAruX478Jeev7UULw==", "dev": true, - "license": "ISC", + "license": "BlueOak-1.0.0", "dependencies": { "foreground-child": "^3.3.1", "jackspeak": "^4.1.1", - "minimatch": "^10.0.3", + "minimatch": "^10.1.1", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^2.0.0" @@ -1408,6 +1659,7 @@ "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", "dev": true, + "license": "ISC", "dependencies": { "is-glob": "^4.0.3" }, @@ -1428,11 +1680,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/graphemer": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", + "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", + "dev": true, + "license": "MIT" + }, "node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", "dev": true, + "license": "MIT", "engines": { "node": ">=8" } @@ -1469,6 +1729,7 @@ "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", "dev": true, + "license": "MIT", "engines": { "node": ">=0.8.19" } @@ -1478,6 +1739,7 @@ "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", "dev": true, + "license": "MIT", "engines": { "node": ">=0.10.0" } @@ -1497,6 +1759,7 @@ "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", "dev": true, + "license": "MIT", "dependencies": { "is-extglob": "^2.1.1" }, @@ -1528,9 +1791,9 @@ } }, "node_modules/js-yaml": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", - "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", "dev": true, "license": "MIT", "dependencies": { @@ -1558,7 +1821,8 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/keyv": { "version": "4.5.4", @@ -1575,6 +1839,7 @@ "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", "dev": true, + "license": "MIT", "dependencies": { "prelude-ls": "^1.2.1", "type-check": "~0.4.0" @@ -1588,6 +1853,7 @@ "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", "dev": true, + "license": "MIT", "dependencies": { "p-locate": "^5.0.0" }, @@ -1602,7 +1868,8 @@ "version": "4.6.2", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/lru-cache": { "version": "11.2.2", @@ -1651,20 +1918,22 @@ "version": "1.4.0", "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/optionator": { - "version": "0.9.3", - "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.3.tgz", - "integrity": "sha512-JjCoypp+jKn1ttEFExxhetCKeJt9zhAgAve5FXHixTvFDW/5aEktX9bufBKLRRMdU7bNtpLfcGu94B3cdEJgjg==", + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", "dev": true, + "license": "MIT", "dependencies": { - "@aashutoshrathi/word-wrap": "^1.2.3", "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", "levn": "^0.4.1", "prelude-ls": "^1.2.1", - "type-check": "^0.4.0" + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" }, "engines": { "node": ">= 0.8.0" @@ -1675,6 +1944,7 @@ "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", "dev": true, + "license": "MIT", "dependencies": { "yocto-queue": "^0.1.0" }, @@ -1690,6 +1960,7 @@ "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", "dev": true, + "license": "MIT", "dependencies": { "p-limit": "^3.0.2" }, @@ -1725,6 +1996,7 @@ "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", "dev": true, + "license": "MIT", "engines": { "node": ">=8" } @@ -1756,6 +2028,20 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/playwright": { "version": "1.56.1", "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.56.1.tgz", @@ -1793,6 +2079,7 @@ "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", "dev": true, + "license": "MIT", "engines": { "node": ">= 0.8.0" } @@ -1829,7 +2116,21 @@ { "url": "https://tidelift.com/funding/github/npm/sign-pad" } - ] + ], + "license": "ISC" + }, + "node_modules/semver": { + "version": "7.7.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", + "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } }, "node_modules/shebang-command": { "version": "2.0.0", @@ -1989,6 +2290,7 @@ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", "dev": true, + "license": "MIT", "dependencies": { "has-flag": "^4.0.0" }, @@ -1996,11 +2298,42 @@ "node": ">=8" } }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/ts-api-utils": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.1.0.tgz", + "integrity": "sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", "dev": true, + "license": "MIT", "dependencies": { "prelude-ls": "^1.2.1" }, @@ -2008,6 +2341,45 @@ "node": ">= 0.8.0" } }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "peer": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/typescript-eslint": { + "version": "8.48.0", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.48.0.tgz", + "integrity": "sha512-fcKOvQD9GUn3Xw63EgiDqhvWJ5jsyZUaekl3KVpGsDJnN46WJTe3jWxtQP9lMZm1LJNkFLlTaWAxK2vUQR+cqw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.48.0", + "@typescript-eslint/parser": "8.48.0", + "@typescript-eslint/typescript-estree": "8.48.0", + "@typescript-eslint/utils": "8.48.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, "node_modules/uri-js": { "version": "4.4.1", "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", @@ -2034,6 +2406,16 @@ "node": ">= 8" } }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/wrap-ansi": { "version": "8.1.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", @@ -2134,6 +2516,7 @@ "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", "dev": true, + "license": "MIT", "engines": { "node": ">=10" }, diff --git a/package.json b/package.json index fd69d20..8e1e9d8 100644 --- a/package.json +++ b/package.json @@ -61,22 +61,24 @@ ], "scripts": { "build": "node ./ci/tools/build-utils.js", - "lint": "eslint -c ./ci/eslint.config.mjs ./src/*.js ./tests/*.js ./ci/**/*.js", + "lint": "eslint -c ./ci/eslint.config.mjs ./src/* ./tests/*.js ./ci/**/*.js", + "test-types": "tsc", "test": "node node_modules/@gullerya/just-test/bin/local-runner.js config_file=./tests/configs/tests-config-ci-node.json", - "test:chromium": "node node_modules/@gullerya/just-test/bin/local-runner.js config_file=./tests/configs/tests-config-ci-chromium.json", - "test:firefox": "node node_modules/@gullerya/just-test/bin/local-runner.js config_file=./tests/configs/tests-config-ci-firefox.json", - "test:webkit": "node node_modules/@gullerya/just-test/bin/local-runner.js config_file=./tests/configs/tests-config-ci-webkit.json", - "test:nodejs": "node node_modules/@gullerya/just-test/bin/local-runner.js config_file=./tests/configs/tests-config-ci-node.json", + "test:chromium": "node node_modules/@gullerya/just-test/bin/local-runner.js config_file=./tests/configs/tests-config-ci-chromium.js", + "test:firefox": "node node_modules/@gullerya/just-test/bin/local-runner.js config_file=./tests/configs/tests-config-ci-firefox.js", + "test:webkit": "node node_modules/@gullerya/just-test/bin/local-runner.js config_file=./tests/configs/tests-config-ci-webkit.js", + "test:nodejs": "node node_modules/@gullerya/just-test/bin/local-runner.js config_file=./tests/configs/tests-config-ci-node.js", "version": "npm run build && git add --all", "postversion": "git push && git push --tags" }, "devDependencies": { - "@eslint/eslintrc": "^3.3.1", "@eslint/js": "^9.39.1", - "@gullerya/just-test": "^4.0.12", + "@gullerya/just-test": "^4.0.17", "esbuild": "^0.27.0", "eslint": "^9.39.1", - "globals": "^16.5.0" + "globals": "^16.5.0", + "typescript": "^5.9.3", + "typescript-eslint": "^8.48.0" }, "publishConfig": { "access": "public" diff --git a/src/changes-processors/filters.ts b/src/changes-processors/filters.ts new file mode 100644 index 0000000..136f2b2 --- /dev/null +++ b/src/changes-processors/filters.ts @@ -0,0 +1,44 @@ +import { Change } from '../model/change.ts'; + +export class Filter { + static #privateCtorKey = Symbol('FilterPrivateConstructorKey'); + #fn: (changes: Change[]) => void; + + constructor(privateCtorKey, fn: (changes: Change[]) => void) { + if (privateCtorKey !== Filter.#privateCtorKey) { + throw new Error('Filter class cannot be instantiated directly; use provided factory methods'); + } + + this.#fn = fn; + } + + get fn(): (changes: Change[]) => void { return this.#fn; } + + static custom(fn: (changes: Change[]) => void): Filter { + if (typeof fn !== 'function') { + throw new Error('custom Filter requires a function as argument'); + } + return new Filter(Filter.#privateCtorKey, fn); + } + + static exactPaths(paths: string[]): Filter { + if (!Array.isArray(paths) || paths.length === 0) { + throw new Error('exactPaths Filter requires a non-empty array as argument'); + } + const pathsSet = new Set(paths); + return new Filter( + Filter.#privateCtorKey, + changes => changes.filter(change => pathsSet.has(change.pathAsString)) + ); + } + + static pathsStartWith(prefix: string): Filter { + if (typeof prefix !== 'string' || prefix === '') { + throw new Error('pathsStartWith Filter requires a non-empty string as argument'); + } + return new Filter( + Filter.#privateCtorKey, + changes => changes.filter(change => change.pathAsString.startsWith(prefix)) + ); + } +} \ No newline at end of file diff --git a/src/changes-processors/verifiers.ts b/src/changes-processors/verifiers.ts new file mode 100644 index 0000000..e69de29 diff --git a/src/constants.ts b/src/constants.ts new file mode 100644 index 0000000..d5572f9 --- /dev/null +++ b/src/constants.ts @@ -0,0 +1,6 @@ +export const oMetaKey = Symbol.for('object-observer-meta-key-0'); +export const INSERT = 'insert'; +export const UPDATE = 'update'; +export const DELETE = 'delete'; +export const REVERSE = 'reverse'; +export const SHUFFLE = 'shuffle'; \ No newline at end of file diff --git a/src/model/change.ts b/src/model/change.ts new file mode 100644 index 0000000..16ff43e --- /dev/null +++ b/src/model/change.ts @@ -0,0 +1,54 @@ +export class Change { + #type: string; + #path: Array; + #value: unknown; + #oldValue: unknown; + #object: object; + + #pathAsString: string; + + constructor(type: string, path: Array, value: unknown, oldValue: unknown, object: object) { + if (typeof type !== 'string' || type === '') { + throw new Error('Change type must be a non-empty string'); + } + if (!Array.isArray(path)) { + throw new Error('Change path must be an array'); + } + + this.#type = type; + this.#path = path; + this.#value = value; + this.#oldValue = oldValue; + this.#object = object; + } + + get type() { + return this.#type; + } + + get path() { + return this.#path; + } + + get value() { + return this.#value; + } + + get oldValue() { + return this.#oldValue; + } + + get object() { + return this.#object; + } + + /** + * lazily computed string representation of the path + */ + get pathAsString(): string { + if (this.#pathAsString === undefined && Array.isArray(this.#path)) { + this.#pathAsString = this.#path.join('.'); + } + return this.#pathAsString; + } +} \ No newline at end of file diff --git a/src/object-observer.d.ts b/src/object-observer.d.ts deleted file mode 100644 index fb7d010..0000000 --- a/src/object-observer.d.ts +++ /dev/null @@ -1,97 +0,0 @@ -export type ChangeType = 'insert' | 'update' | 'delete' | 'reverse' | 'shuffle'; - -/** - * `Observable` allows to observe any (deep) changes on its underlying object graph - * - * - created by `from` static method, via cloning the target - * - important: the type `T` is not preserved, beside its shape - */ -export abstract class Observable { - - /** - * create Observable from the target - * - target is cloned, remaining unchanged in itself - * - important: the type `T` is NOT preserved, beside its shape - * - * @param target source, to create `Observable` from - * @param options observable options - */ - static from(target: T, options?: ObservableOptions): Observable & T; - - /** - * check input for being `Observable` - * - * @param input any object to be checked as `Observable` - */ - static isObservable(input: unknown): boolean; - - /** - * add observer to handle the observable's changes - * - * @param observable observable to set observer on - * @param observer observer function / logic - * @param options observation options - */ - static observe(observable: Observable, observer: Observer, options?: ObserverOptions): void; - - /** - * remove observer/s from observable - * - * @param observable observable to remove observer/s from - * @param observers 0 to many observers to remove; if none supplied, ALL observers will be removed - */ - static unobserve(observable: Observable, ...observers: Observer[]): void; -} - -export interface ObservableOptions { - async: boolean; -} - -export interface Observer { - (changes: Change[]): void; -} - -export interface ObserverOptions { - path?: string, - pathsOf?: string, - pathsFrom?: string -} - -export interface Change { - type: ChangeType; - path: string[]; - value?: any; - oldValue?: any; - object: object; -} - -/** - * `ObjectObserver` provides observation functionality in a WebAPI-like flavor - * - `observer` created first, with the provided observer function - * - `observer` may then be used to observe different targets - */ -export class ObjectObserver { - - /** - * sets up observer function and options - * @param observer observation logic (function) - * @param options `ObservableOptions` will be applied to any `Observable` down the road - */ - constructor(observer: Observer, options?: ObservableOptions); - - /** - * create `Observable` from the target and starts observation - * - important: the type `T` is NOT preserved, beside its shape - * @param target target to be observed, turned into `Observable` via cloning - * @param options `ObserverOptions` options - */ - observe(target: T, options?: ObserverOptions): Observable & T; - - /** - * un-observes the `Observable`, returning the original undelying plain object - * @param target target to be un-observed - */ - unobserve(target: Observable): void; - - disconnect(): void; -} diff --git a/src/object-observer.js b/src/object-observer.js deleted file mode 100644 index b4c9221..0000000 --- a/src/object-observer.js +++ /dev/null @@ -1,713 +0,0 @@ -export { Observable, ObjectObserver }; - -const - INSERT = 'insert', - UPDATE = 'update', - DELETE = 'delete', - REVERSE = 'reverse', - SHUFFLE = 'shuffle', - oMetaKey = Symbol.for('object-observer-meta-key-0'), - validObservableOptionKeys = { async: 1 }, - processObserveOptions = options => { - if (!options || typeof options !== 'object') { - return null; - } - - const result = {}; - const invalidOptions = []; - for (const [optName, optVal] of Object.entries(options)) { - if (optName === 'path') { - if (typeof optVal !== 'string' || optVal === '') { - throw new Error('"path" option, if/when provided, MUST be a non-empty string'); - } - result[optName] = optVal; - } else if (optName === 'pathsOf') { - if (options.path) { - throw new Error('"pathsOf" option MAY NOT be specified together with "path" option'); - } - if (typeof optVal !== 'string') { - throw new Error('"pathsOf" option, if/when provided, MUST be a string (MAY be empty)'); - } - result[optName] = options.pathsOf.split('.').filter(Boolean); - } else if (optName === 'pathsFrom') { - if (options.path || options.pathsOf) { - throw new Error('"pathsFrom" option MAY NOT be specified together with "path"/"pathsOf" option/s'); - } - if (typeof optVal !== 'string' || optVal === '') { - throw new Error('"pathsFrom" option, if/when provided, MUST be a non-empty string'); - } - result[optName] = optVal; - } else { - invalidOptions.push(optName); - } - } - if (invalidOptions.length) { - throw new Error(`'${invalidOptions.join(', ')}' is/are not a valid observer option/s`); - } - return result; - }, - prepareObject = (source, oMeta, visited) => { - const target = {}; - target[oMetaKey] = oMeta; - for (const key in source) { - target[key] = getObservedOf(source[key], key, oMeta, visited); - } - return target; - }, - prepareArray = (source, oMeta, visited) => { - let l = source.length; - const target = new Array(l); - target[oMetaKey] = oMeta; - for (let i = 0; i < l; i++) { - target[i] = getObservedOf(source[i], i, oMeta, visited); - } - return target; - }, - prepareTypedArray = (source, oMeta) => { - source[oMetaKey] = oMeta; - return source; - }, - filterChanges = (options, changes) => { - if (options === null) { - return changes; - } - - let result = changes; - if (options.path) { - const oPath = options.path; - result = changes.filter(change => - change.path.join('.') === oPath - ); - } else if (options.pathsOf) { - const oPathsOf = options.pathsOf; - const oPathsOfStr = oPathsOf.join('.'); - result = changes.filter(change => - (change.path.length === oPathsOf.length + 1 || - (change.path.length === oPathsOf.length && (change.type === REVERSE || change.type === SHUFFLE))) && - change.path.join('.').startsWith(oPathsOfStr) - ); - } else if (options.pathsFrom) { - const oPathsFrom = options.pathsFrom; - result = changes.filter(change => - change.path.join('.').startsWith(oPathsFrom) - ); - } - return result; - }, - callObserverSafe = (listener, changes) => { - try { - listener(changes); - } catch (e) { - console.error(`failed to notify listener ${listener} with ${changes}`, e); - } - }, - callObserversFromMT = function callObserversFromMT() { - const batches = this.batches; - this.batches = []; - for (const [listener, changes] of batches) { - callObserverSafe(listener, changes); - } - }, - callObservers = (oMeta, changes) => { - let currentObservable = oMeta; - let isAsync, observers, target, options, relevantChanges, i; - const l = changes.length; - do { - isAsync = currentObservable.options.async; - observers = currentObservable.observers; - i = observers.length; - while (i--) { - [target, options] = observers[i]; - relevantChanges = filterChanges(options, changes); - - if (relevantChanges.length) { - if (isAsync) { - // this is the async dispatch handling - if (currentObservable.batches.length === 0) { - queueMicrotask(callObserversFromMT.bind(currentObservable)); - } - let rb; - for (const b of currentObservable.batches) { - if (b[0] === target) { - rb = b; - break; - } - } - if (!rb) { - rb = [target, []]; - currentObservable.batches.push(rb); - } - Array.prototype.push.apply(rb[1], relevantChanges); - } else { - // this is the naive straight forward synchronous dispatch - callObserverSafe(target, relevantChanges); - } - } - } - - // cloning all the changes and notifying in context of parent - const parent = currentObservable.parent; - if (parent) { - for (let j = 0; j < l; j++) { - const change = changes[j]; - changes[j] = new Change( - change.type, - [currentObservable.ownKey, ...change.path], - change.value, - change.oldValue, - change.object - ); - } - currentObservable = parent; - } else { - currentObservable = null; - } - } while (currentObservable); - }, - getObservedOf = (item, key, parent, visited) => { - if (visited !== undefined && visited.has(item)) { - return null; - } else if (typeof item !== 'object' || item === null) { - return item; - } else if (Array.isArray(item)) { - return new ArrayOMeta({ target: item, ownKey: key, parent: parent, visited }).proxy; - } else if (ArrayBuffer.isView(item)) { - return new TypedArrayOMeta({ target: item, ownKey: key, parent: parent }).proxy; - } else if (item instanceof Date) { - return item; - } else { - return new ObjectOMeta({ target: item, ownKey: key, parent: parent, visited }).proxy; - } - }, - proxiedPop = function proxiedPop() { - const oMeta = this[oMetaKey], - target = oMeta.target, - poppedIndex = target.length - 1; - - let popResult = target.pop(); - if (popResult && typeof popResult === 'object') { - const tmpObserved = popResult[oMetaKey]; - if (tmpObserved) { - popResult = tmpObserved.detach(); - } - } - - const changes = [new Change(DELETE, [poppedIndex], undefined, popResult, this)]; - callObservers(oMeta, changes); - - return popResult; - }, - proxiedPush = function proxiedPush() { - const - oMeta = this[oMetaKey], - target = oMeta.target, - l = arguments.length, - pushContent = new Array(l), - initialLength = target.length; - - for (let i = 0; i < l; i++) { - pushContent[i] = getObservedOf(arguments[i], initialLength + i, oMeta); - } - const pushResult = Reflect.apply(target.push, target, pushContent); - - const changes = []; - for (let i = initialLength, j = target.length; i < j; i++) { - changes[i - initialLength] = new Change(INSERT, [i], target[i], undefined, this); - } - callObservers(oMeta, changes); - - return pushResult; - }, - proxiedShift = function proxiedShift() { - const - oMeta = this[oMetaKey], - target = oMeta.target; - let shiftResult, i, l, item, tmpObserved; - - shiftResult = target.shift(); - if (shiftResult && typeof shiftResult === 'object') { - tmpObserved = shiftResult[oMetaKey]; - if (tmpObserved) { - shiftResult = tmpObserved.detach(); - } - } - - // update indices of the remaining items - for (i = 0, l = target.length; i < l; i++) { - item = target[i]; - if (item && typeof item === 'object') { - tmpObserved = item[oMetaKey]; - if (tmpObserved) { - tmpObserved.ownKey = i; - } - } - } - - const changes = [new Change(DELETE, [0], undefined, shiftResult, this)]; - callObservers(oMeta, changes); - - return shiftResult; - }, - proxiedUnshift = function proxiedUnshift() { - const - oMeta = this[oMetaKey], - target = oMeta.target, - al = arguments.length, - unshiftContent = new Array(al); - - for (let i = 0; i < al; i++) { - unshiftContent[i] = getObservedOf(arguments[i], i, oMeta); - } - const unshiftResult = Reflect.apply(target.unshift, target, unshiftContent); - - for (let i = 0, l = target.length, item; i < l; i++) { - item = target[i]; - if (item && typeof item === 'object') { - const tmpObserved = item[oMetaKey]; - if (tmpObserved) { - tmpObserved.ownKey = i; - } - } - } - - // publish changes - const l = unshiftContent.length; - const changes = new Array(l); - for (let i = 0; i < l; i++) { - changes[i] = new Change(INSERT, [i], target[i], undefined, this); - } - callObservers(oMeta, changes); - - return unshiftResult; - }, - proxiedReverse = function proxiedReverse() { - const - oMeta = this[oMetaKey], - target = oMeta.target; - let i, l, item; - - target.reverse(); - for (i = 0, l = target.length; i < l; i++) { - item = target[i]; - if (item && typeof item === 'object') { - const tmpObserved = item[oMetaKey]; - if (tmpObserved) { - tmpObserved.ownKey = i; - } - } - } - - const changes = [new Change(REVERSE, [], undefined, undefined, this)]; - callObservers(oMeta, changes); - - return this; - }, - proxiedSort = function proxiedSort(comparator) { - const - oMeta = this[oMetaKey], - target = oMeta.target; - let i, l, item; - - target.sort(comparator); - for (i = 0, l = target.length; i < l; i++) { - item = target[i]; - if (item && typeof item === 'object') { - const tmpObserved = item[oMetaKey]; - if (tmpObserved) { - tmpObserved.ownKey = i; - } - } - } - - const changes = [new Change(SHUFFLE, [], undefined, undefined, this)]; - callObservers(oMeta, changes); - - return this; - }, - proxiedFill = function proxiedFill(filVal, start, end) { - const - oMeta = this[oMetaKey], - target = oMeta.target, - changes = [], - tarLen = target.length, - prev = target.slice(0); - start = start === undefined ? 0 : (start < 0 ? Math.max(tarLen + start, 0) : Math.min(start, tarLen)); - end = end === undefined ? tarLen : (end < 0 ? Math.max(tarLen + end, 0) : Math.min(end, tarLen)); - - if (start < tarLen && end > start) { - target.fill(filVal, start, end); - - let tmpObserved; - for (let i = start, item, tmpTarget; i < end; i++) { - item = target[i]; - target[i] = getObservedOf(item, i, oMeta); - if (i in prev) { - tmpTarget = prev[i]; - if (tmpTarget && typeof tmpTarget === 'object') { - tmpObserved = tmpTarget[oMetaKey]; - if (tmpObserved) { - tmpTarget = tmpObserved.detach(); - } - } - - changes.push(new Change(UPDATE, [i], target[i], tmpTarget, this)); - } else { - changes.push(new Change(INSERT, [i], target[i], undefined, this)); - } - } - - callObservers(oMeta, changes); - } - - return this; - }, - proxiedCopyWithin = function proxiedCopyWithin(dest, start, end) { - const - oMeta = this[oMetaKey], - target = oMeta.target, - tarLen = target.length; - dest = dest < 0 ? Math.max(tarLen + dest, 0) : dest; - start = start === undefined ? 0 : (start < 0 ? Math.max(tarLen + start, 0) : Math.min(start, tarLen)); - end = end === undefined ? tarLen : (end < 0 ? Math.max(tarLen + end, 0) : Math.min(end, tarLen)); - const len = Math.min(end - start, tarLen - dest); - - if (dest < tarLen && dest !== start && len > 0) { - const - prev = target.slice(0), - changes = []; - - target.copyWithin(dest, start, end); - - for (let i = dest, nItem, oItem, tmpObserved; i < dest + len; i++) { - // update newly placed observables, if any - nItem = target[i]; - if (nItem && typeof nItem === 'object') { - nItem = getObservedOf(nItem, i, oMeta); - target[i] = nItem; - } - - // detach overridden observables, if any - oItem = prev[i]; - if (oItem && typeof oItem === 'object') { - tmpObserved = oItem[oMetaKey]; - if (tmpObserved) { - oItem = tmpObserved.detach(); - } - } - - if (typeof nItem !== 'object' && nItem === oItem) { - continue; - } - changes.push(new Change(UPDATE, [i], nItem, oItem, this)); - } - - callObservers(oMeta, changes); - } - - return this; - }, - proxiedSplice = function proxiedSplice() { - const - oMeta = this[oMetaKey], - target = oMeta.target, - splLen = arguments.length, - spliceContent = new Array(splLen), - tarLen = target.length; - - // observify the newcomers - for (let i = 0; i < splLen; i++) { - spliceContent[i] = getObservedOf(arguments[i], i, oMeta); - } - - // calculate pointers - const - startIndex = splLen === 0 ? 0 : (spliceContent[0] < 0 ? tarLen + spliceContent[0] : spliceContent[0]), - removed = splLen < 2 ? tarLen - startIndex : spliceContent[1], - inserted = Math.max(splLen - 2, 0), - spliceResult = Reflect.apply(target.splice, target, spliceContent), - newTarLen = target.length; - - // reindex the paths - let tmpObserved; - for (let i = 0, item; i < newTarLen; i++) { - item = target[i]; - if (item && typeof item === 'object') { - tmpObserved = item[oMetaKey]; - if (tmpObserved) { - tmpObserved.ownKey = i; - } - } - } - - // detach removed objects - let i, l, item; - for (i = 0, l = spliceResult.length; i < l; i++) { - item = spliceResult[i]; - if (item && typeof item === 'object') { - tmpObserved = item[oMetaKey]; - if (tmpObserved) { - spliceResult[i] = tmpObserved.detach(); - } - } - } - - const changes = []; - let index; - for (index = 0; index < removed; index++) { - if (index < inserted) { - changes.push(new Change(UPDATE, [startIndex + index], target[startIndex + index], spliceResult[index], this)); - } else { - changes.push(new Change(DELETE, [startIndex + index], undefined, spliceResult[index], this)); - } - } - for (; index < inserted; index++) { - changes.push(new Change(INSERT, [startIndex + index], target[startIndex + index], undefined, this)); - } - callObservers(oMeta, changes); - - return spliceResult; - }, - proxiedTypedArraySet = function proxiedTypedArraySet(source, offset) { - const - oMeta = this[oMetaKey], - target = oMeta.target, - souLen = source.length, - prev = target.slice(0); - offset = offset || 0; - - target.set(source, offset); - const changes = new Array(souLen); - for (let i = offset; i < (souLen + offset); i++) { - changes[i - offset] = new Change(UPDATE, [i], target[i], prev[i], this); - } - - callObservers(oMeta, changes); - }, - proxiedArrayMethods = { - pop: proxiedPop, - push: proxiedPush, - shift: proxiedShift, - unshift: proxiedUnshift, - reverse: proxiedReverse, - sort: proxiedSort, - fill: proxiedFill, - copyWithin: proxiedCopyWithin, - splice: proxiedSplice - }, - proxiedTypedArrayMethods = { - reverse: proxiedReverse, - sort: proxiedSort, - fill: proxiedFill, - copyWithin: proxiedCopyWithin, - set: proxiedTypedArraySet - }; - -class Change { - constructor(type, path, value, oldValue, object) { - this.type = type; - this.path = path; - this.value = value; - this.oldValue = oldValue; - this.object = object; - } -} - -class OMetaBase { - constructor(properties, cloningFunction) { - const { target, parent, ownKey, visited = new Set() } = properties; - if (parent && ownKey !== undefined) { - this.parent = parent; - this.ownKey = ownKey; - } else { - this.parent = null; - this.ownKey = null; - } - visited.add(target); - const targetClone = cloningFunction(target, this, visited); - visited.delete(target); - this.observers = []; - this.revocable = Proxy.revocable(targetClone, this); - this.proxy = this.revocable.proxy; - this.target = targetClone; - this.options = this.processOptions(properties.options); - if (this.options.async) { - this.batches = []; - } - } - - processOptions(options) { - if (options) { - if (typeof options !== 'object') { - throw new Error(`Observable options if/when provided, MAY only be an object, got '${options}'`); - } - const invalidOptions = Object.keys(options).filter(option => !(option in validObservableOptionKeys)); - if (invalidOptions.length) { - throw new Error(`'${invalidOptions.join(', ')}' is/are not a valid Observable option/s`); - } - return Object.assign({}, options); - } else { - return {}; - } - } - - detach() { - this.parent = null; - return this.target; - } - - set(target, key, value) { - let oldValue = target[key]; - - if (value !== oldValue) { - const newValue = getObservedOf(value, key, this); - target[key] = newValue; - - if (oldValue && typeof oldValue === 'object') { - const tmpObserved = oldValue[oMetaKey]; - if (tmpObserved) { - oldValue = tmpObserved.detach(); - } - } - - const changes = oldValue === undefined - ? [new Change(INSERT, [key], newValue, undefined, this.proxy)] - : [new Change(UPDATE, [key], newValue, oldValue, this.proxy)]; - callObservers(this, changes); - } - - return true; - } - - deleteProperty(target, key) { - let oldValue = target[key]; - - delete target[key]; - - if (oldValue && typeof oldValue === 'object') { - const tmpObserved = oldValue[oMetaKey]; - if (tmpObserved) { - oldValue = tmpObserved.detach(); - } - } - - const changes = [new Change(DELETE, [key], undefined, oldValue, this.proxy)]; - callObservers(this, changes); - - return true; - } -} - -class ObjectOMeta extends OMetaBase { - constructor(properties) { - super(properties, prepareObject); - } -} - -class ArrayOMeta extends OMetaBase { - constructor(properties) { - super(properties, prepareArray); - } - - get(target, key) { - return proxiedArrayMethods[key] || target[key]; - } -} - -class TypedArrayOMeta extends OMetaBase { - constructor(properties) { - super(properties, prepareTypedArray); - } - - get(target, key) { - return proxiedTypedArrayMethods[key] || target[key]; - } -} - -const Observable = Object.freeze({ - from: (target, options) => { - if (!target || typeof target !== 'object') { - throw new Error('observable MAY ONLY be created from a non-null object'); - } else if (target[oMetaKey]) { - return target; - } else if (Array.isArray(target)) { - return new ArrayOMeta({ target: target, ownKey: null, parent: null, options: options }).proxy; - } else if (ArrayBuffer.isView(target)) { - return new TypedArrayOMeta({ target: target, ownKey: null, parent: null, options: options }).proxy; - } else if (target instanceof Date) { - throw new Error(`${target} found to be one of a non-observable types`); - } else { - return new ObjectOMeta({ target: target, ownKey: null, parent: null, options: options }).proxy; - } - }, - isObservable: input => { - return !!(input && input[oMetaKey]); - }, - observe: (observable, observer, options) => { - if (!Observable.isObservable(observable)) { - throw new Error(`invalid observable parameter`); - } - if (typeof observer !== 'function') { - throw new Error(`observer MUST be a function, got '${observer}'`); - } - - const observers = observable[oMetaKey].observers; - if (!observers.some(o => o[0] === observer)) { - observers.push([observer, processObserveOptions(options)]); - } else { - console.warn('observer may be bound to an observable only once; will NOT rebind'); - } - }, - unobserve: (observable, ...observers) => { - if (!Observable.isObservable(observable)) { - throw new Error(`invalid observable parameter`); - } - - const existingObs = observable[oMetaKey].observers; - let el = existingObs.length; - if (!el) { - return; - } - - if (!observers.length) { - existingObs.splice(0); - return; - } - - while (el) { - let i = observers.indexOf(existingObs[--el][0]); - if (i >= 0) { - existingObs.splice(el, 1); - } - } - } -}); - -class ObjectObserver { - #observer; - #targets; - - constructor(observer) { - this.#observer = observer; - this.#targets = new Set(); - Object.freeze(this); - } - - observe(target, options) { - const r = Observable.from(target); - Observable.observe(r, this.#observer, options); - this.#targets.add(r); - return r; - } - - unobserve(target) { - Observable.unobserve(target, this.#observer); - this.#targets.delete(target); - } - - disconnect() { - for (const t of this.#targets) { - Observable.unobserve(t, this.#observer); - } - this.#targets.clear(); - } -} diff --git a/src/object-observer.ts b/src/object-observer.ts new file mode 100644 index 0000000..fef3e83 --- /dev/null +++ b/src/object-observer.ts @@ -0,0 +1,215 @@ +import { oMetaKey } from './constants.ts'; +import { getObservableFromRoot } from './observables/processors/proc-utils.ts'; + +const + processObserveOptions = options => { + if (!options || typeof options !== 'object') { + return null; + } + + const result = {}; + const invalidOptions = []; + for (const [optName, optVal] of Object.entries(options)) { + if (optName === 'path') { + if (typeof optVal !== 'string' || optVal === '') { + throw new Error('"path" option, if/when provided, MUST be a non-empty string'); + } + result[optName] = optVal; + } else if (optName === 'pathsOf') { + if (options.path) { + throw new Error('"pathsOf" option MAY NOT be specified together with "path" option'); + } + if (typeof optVal !== 'string') { + throw new Error('"pathsOf" option, if/when provided, MUST be a string (MAY be empty)'); + } + result[optName] = options.pathsOf.split('.').filter(Boolean); + } else if (optName === 'pathsFrom') { + if (options.path || options.pathsOf) { + throw new Error('"pathsFrom" option MAY NOT be specified together with "path"/"pathsOf" option/s'); + } + if (typeof optVal !== 'string' || optVal === '') { + throw new Error('"pathsFrom" option, if/when provided, MUST be a non-empty string'); + } + result[optName] = optVal; + } else { + invalidOptions.push(optName); + } + } + if (invalidOptions.length) { + throw new Error(`'${invalidOptions.join(', ')}' is/are not a valid observer option/s`); + } + return result; + }; +// filterChanges = (options, changes) => { +// if (options === null) { +// return changes; +// } + +// let result = changes; +// if (options.path) { +// const oPath = options.path; +// result = changes.filter(change => +// change.path.join('.') === oPath +// ); +// } else if (options.pathsOf) { +// const oPathsOf = options.pathsOf; +// const oPathsOfStr = oPathsOf.join('.'); +// result = changes.filter(change => +// (change.path.length === oPathsOf.length + 1 || +// (change.path.length === oPathsOf.length && (change.type === REVERSE || change.type === SHUFFLE))) && +// change.path.join('.').startsWith(oPathsOfStr) +// ); +// } else if (options.pathsFrom) { +// const oPathsFrom = options.pathsFrom; +// result = changes.filter(change => +// change.path.join('.').startsWith(oPathsFrom) +// ); +// } +// return result; +// }, +// callObserverSafe = (listener, changes) => { +// try { +// listener(changes); +// } catch (e) { +// console.error(`failed to notify listener ${listener} with ${changes}`, e); +// } +// }, +// callObserversFromMT = function callObserversFromMT() { +// const batches = this.batches; +// this.batches = []; +// for (const [listener, changes] of batches) { +// callObserverSafe(listener, changes); +// } +// }; + +// export function callObservers(oMeta: ObservableBase, changes: Change[]) { +// let currentObservable: ObservableBase = oMeta; +// let isAsync, observers, target, options, relevantChanges, i; +// const l = changes.length; +// do { +// isAsync = currentObservable.async; +// observers = currentObservable.observers; +// i = observers.length; +// while (i--) { +// [target, options] = observers[i]; +// relevantChanges = filterChanges(options, changes); + +// if (relevantChanges.length) { +// if (isAsync) { +// // this is the async dispatch handling +// if (currentObservable.batches.length === 0) { +// queueMicrotask(callObserversFromMT.bind(currentObservable)); +// } +// let rb; +// for (const b of currentObservable.batches) { +// if (b[0] === target) { +// rb = b; +// break; +// } +// } +// if (!rb) { +// rb = [target, []]; +// currentObservable.batches.push(rb); +// } +// Array.prototype.push.apply(rb[1], relevantChanges); +// } else { +// // this is the naive straight forward synchronous dispatch +// callObserverSafe(target, relevantChanges); +// } +// } +// } + +// // cloning all the changes and notifying in context of parent +// const parent = currentObservable.parent; +// if (parent) { +// for (let j = 0; j < l; j++) { +// const change = changes[j]; +// changes[j] = new Change( +// change.type, +// [currentObservable.ownKey, ...change.path], +// change.value, +// change.oldValue, +// change.object +// ); +// } +// currentObservable = parent; +// } else { +// break; +// } +// } while (currentObservable); +// }; + +export const Observable = Object.freeze({ + from: getObservableFromRoot, + isObservable: input => { + return !!(input && input[oMetaKey]); + }, + observe: (observable, observer, options) => { + if (!Observable.isObservable(observable)) { + throw new Error(`invalid observable parameter`); + } + if (typeof observer !== 'function') { + throw new Error(`observer MUST be a function, got '${observer}'`); + } + + const observers = observable[oMetaKey].observers; + if (!observers.some(o => o[0] === observer)) { + observers.push([observer, processObserveOptions(options)]); + } else { + console.warn('observer may be bound to an observable only once; will NOT rebind'); + } + }, + unobserve: (observable, ...observers) => { + if (!Observable.isObservable(observable)) { + throw new Error(`invalid observable parameter`); + } + + const existingObs = observable[oMetaKey].observers; + let el = existingObs.length; + if (!el) { + return; + } + + if (!observers.length) { + existingObs.splice(0); + return; + } + + while (el) { + const i = observers.indexOf(existingObs[--el][0]); + if (i >= 0) { + existingObs.splice(el, 1); + } + } + } +}); + +export class ObjectObserver { + #observer; + #targets; + + constructor(observer) { + this.#observer = observer; + this.#targets = new Set(); + Object.freeze(this); + } + + observe(target, options) { + const r = Observable.from(target); + Observable.observe(r, this.#observer, options); + this.#targets.add(r); + return r; + } + + unobserve(target) { + Observable.unobserve(target, this.#observer); + this.#targets.delete(target); + } + + disconnect() { + for (const t of this.#targets) { + Observable.unobserve(t, this.#observer); + } + this.#targets.clear(); + } +} diff --git a/src/observables/abstract-base.ts b/src/observables/abstract-base.ts new file mode 100644 index 0000000..fbb322d --- /dev/null +++ b/src/observables/abstract-base.ts @@ -0,0 +1,88 @@ +import { Change } from '../model/change.ts'; +import { proxiedDeleteProperty } from './methods/delete-property.ts'; +import { proxiedSet } from './methods/set.ts'; + +const validObservableOptionKeys = { async: 1, verifiers: 1 }; + +export interface ObservableOptions { + async?: boolean; + verifiers?: Array; +} +export type ChangesProcessor = (changes: Change[]) => void; + +export class ObservableBase implements ProxyHandler { + #parent: ObservableBase | null; + ownKey: string | null; + #target: object; + #proxy: unknown; + // eslint-disable-next-line no-unused-private-class-members + #revoke: () => void; + #async: boolean = false; + batches = []; + + set; + deleteProperty; + + #verifiers: Array = []; + #observers: Array = []; + + constructor(properties) { + this.set = proxiedSet; + this.deleteProperty = proxiedDeleteProperty; + + const { target, parent, ownKey, options, visited = new Set() } = properties; + if (parent && ownKey !== undefined) { + this.#parent = parent; + this.ownKey = ownKey; + } else { + this.#parent = null; + this.ownKey = null; + } + visited.add(target); + this.#target = this.observedGraphProcessor(target, this, visited); + visited.delete(target); + + const revocableProxy = Proxy.revocable(this.#target, this); + this.#proxy = revocableProxy.proxy; + this.#revoke = revocableProxy.revoke; + this.#processOptions(options); + } + + detach() { + this.#parent = null; + // this.#revoke(); + return this.#target; + } + + get parent(): ObservableBase | null { return this.#parent; } + get target(): object { return this.#target; } + get proxy(): unknown { return this.#proxy; } + get async(): boolean { return this.#async; } + get verifiers(): Array { return this.#verifiers; } + get observers(): Array { return this.#observers; } + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + observedGraphProcessor(source: object, observableWrapper: ObservableBase, visited: Set): object { + throw new Error('observedGraphProcessor MUST be implemented in derived classes'); + } + + #processOptions(options: ObservableOptions | undefined): void { + if (!options) { + return; + } + + if (typeof options !== 'object') { + throw new Error(`Observable options if/when provided, MAY only be an object, got '${options}'`); + } + const invalidOptions = Object.keys(options).filter(option => !(option in validObservableOptionKeys)); + if (invalidOptions.length) { + throw new Error(`'${invalidOptions.join(', ')}' is/are not a valid Observable option/s`); + } + + this.#async = Boolean(options.async); + + if (Array.isArray(options.verifiers)) { + this.#verifiers.push(...options.verifiers); + } + } +} \ No newline at end of file diff --git a/src/observables/array.ts b/src/observables/array.ts new file mode 100644 index 0000000..fed46be --- /dev/null +++ b/src/observables/array.ts @@ -0,0 +1,41 @@ +import { ObservableBase } from './abstract-base.ts'; +import { getObservedOf } from './processors/proc-utils.ts'; +import { oMetaKey } from '../constants.ts'; +import proxiedCopyWithin from './methods/copy-within.ts'; +import proxiedFill from './methods/fill.ts'; +import proxiedPop from './methods/pop.ts'; +import proxiedPush from './methods/push.ts'; +import proxiedReverse from './methods/reverse.ts'; +import proxiedShift from './methods/shift.ts'; +import proxiedSort from './methods/sort.ts'; +import proxiedSplice from './methods/splice.ts'; +import proxiedUnshift from './methods/unshift.ts'; + +const proxiedArrayMethods = { + copyWithin: proxiedCopyWithin, + fill: proxiedFill, + pop: proxiedPop, + push: proxiedPush, + reverse: proxiedReverse, + shift: proxiedShift, + sort: proxiedSort, + splice: proxiedSplice, + unshift: proxiedUnshift +}; + +export class ObservableArray extends ObservableBase { + + get(target: object, key: string): unknown { + return proxiedArrayMethods[key] || target[key]; + } + + observedGraphProcessor(source: Array, observableWrapper: ObservableBase, visited: Set): Array { + const arrayLength = source.length; + const target = new Array(arrayLength); + for (let i = 0; i < arrayLength; i++) { + target[i] = getObservedOf(source[i], i, observableWrapper, visited); + } + target[oMetaKey] = observableWrapper; + return target; + } +} \ No newline at end of file diff --git a/src/observables/methods/copy-within.ts b/src/observables/methods/copy-within.ts new file mode 100644 index 0000000..a3a72eb --- /dev/null +++ b/src/observables/methods/copy-within.ts @@ -0,0 +1,49 @@ +import { Change } from '../../model/change.ts'; +import { UPDATE, oMetaKey } from '../../constants.ts'; +import { getObservedOf } from '../processors/proc-utils.ts'; +import { callObservers } from '../processors/proc-utils.ts'; + +export default function proxiedCopyWithin(dest, start, end) { + const oMeta = this[oMetaKey]; + const target = oMeta.target; + const tarLen = target.length; + dest = dest < 0 ? Math.max(tarLen + dest, 0) : dest; + start = start === undefined ? 0 : (start < 0 ? Math.max(tarLen + start, 0) : Math.min(start, tarLen)); + end = end === undefined ? tarLen : (end < 0 ? Math.max(tarLen + end, 0) : Math.min(end, tarLen)); + const len = Math.min(end - start, tarLen - dest); + + if (dest < tarLen && dest !== start && len > 0) { + const + prev = target.slice(0), + changes = []; + + target.copyWithin(dest, start, end); + + for (let i = dest, nItem, oItem, tmpObserved; i < dest + len; i++) { + // update newly placed observables, if any + nItem = target[i]; + if (nItem && typeof nItem === 'object') { + nItem = getObservedOf(nItem, i, oMeta); + target[i] = nItem; + } + + // detach overridden observables, if any + oItem = prev[i]; + if (oItem && typeof oItem === 'object') { + tmpObserved = oItem[oMetaKey]; + if (tmpObserved) { + oItem = tmpObserved.detach(); + } + } + + if (typeof nItem !== 'object' && nItem === oItem) { + continue; + } + changes.push(new Change(UPDATE, [i], nItem, oItem, this)); + } + + callObservers(oMeta, changes); + } + + return this; +}; \ No newline at end of file diff --git a/src/observables/methods/delete-property.ts b/src/observables/methods/delete-property.ts new file mode 100644 index 0000000..b3aa0cf --- /dev/null +++ b/src/observables/methods/delete-property.ts @@ -0,0 +1,22 @@ +import { Change } from '../../model/change.ts'; +import { DELETE } from '../../constants.ts'; +import { oMetaKey } from '../../constants.ts'; +import { callObservers } from '../processors/proc-utils.ts'; + +export function proxiedDeleteProperty(target: object, key: string | symbol): boolean { + let oldValue = target[key]; + + delete target[key]; + + if (oldValue && typeof oldValue === 'object') { + const tmpObserved = oldValue[oMetaKey]; + if (tmpObserved) { + oldValue = tmpObserved.detach(); + } + } + + const changes = [new Change(DELETE, [key], undefined, oldValue, this.proxy)]; + callObservers(this, changes); + + return true; +}; \ No newline at end of file diff --git a/src/observables/methods/fill.ts b/src/observables/methods/fill.ts new file mode 100644 index 0000000..35725b6 --- /dev/null +++ b/src/observables/methods/fill.ts @@ -0,0 +1,41 @@ +import { Change } from '../../model/change.ts'; +import { INSERT, UPDATE, oMetaKey } from '../../constants.ts'; +import { getObservedOf } from '../processors/proc-utils.ts'; +import { callObservers } from '../processors/proc-utils.ts'; + +export default function proxiedFill(filVal, start, end) { + const oMeta = this[oMetaKey]; + const target = oMeta.target; + const changes = []; + const tarLen = target.length; + const prev = target.slice(0); + start = start === undefined ? 0 : (start < 0 ? Math.max(tarLen + start, 0) : Math.min(start, tarLen)); + end = end === undefined ? tarLen : (end < 0 ? Math.max(tarLen + end, 0) : Math.min(end, tarLen)); + + if (start < tarLen && end > start) { + target.fill(filVal, start, end); + + let tmpObserved; + for (let i = start, item, tmpTarget; i < end; i++) { + item = target[i]; + target[i] = getObservedOf(item, i, oMeta); + if (i in prev) { + tmpTarget = prev[i]; + if (tmpTarget && typeof tmpTarget === 'object') { + tmpObserved = tmpTarget[oMetaKey]; + if (tmpObserved) { + tmpTarget = tmpObserved.detach(); + } + } + + changes.push(new Change(UPDATE, [i], target[i], tmpTarget, this)); + } else { + changes.push(new Change(INSERT, [i], target[i], undefined, this)); + } + } + + callObservers(oMeta, changes); + } + + return this; +}; \ No newline at end of file diff --git a/src/observables/methods/pop.ts b/src/observables/methods/pop.ts new file mode 100644 index 0000000..d8dd22c --- /dev/null +++ b/src/observables/methods/pop.ts @@ -0,0 +1,22 @@ +import { Change } from '../../model/change.ts'; +import { DELETE, oMetaKey } from '../../constants.ts'; +import { callObservers } from '../processors/proc-utils.ts'; + +export default function proxiedPop() { + const oMeta = this[oMetaKey]; + const target = oMeta.target; + const poppedIndex = target.length - 1; + + let popResult = target.pop(); + if (popResult && typeof popResult === 'object') { + const tmpObserved = popResult[oMetaKey]; + if (tmpObserved) { + popResult = tmpObserved.detach(); + } + } + + const changes = [new Change(DELETE, [poppedIndex], undefined, popResult, this)]; + callObservers(oMeta, changes); + + return popResult; +} \ No newline at end of file diff --git a/src/observables/methods/push.ts b/src/observables/methods/push.ts new file mode 100644 index 0000000..56d15bf --- /dev/null +++ b/src/observables/methods/push.ts @@ -0,0 +1,25 @@ +import { Change } from '../../model/change.ts'; +import { INSERT, oMetaKey } from '../../constants.ts'; +import { getObservedOf } from '../processors/proc-utils.ts'; +import { callObservers } from '../processors/proc-utils.ts'; + +export default function proxiedPush(...pushItems: unknown[]) { + const oMeta = this[oMetaKey]; + const target = oMeta.target; + const pushLen = pushItems.length; + const pushContent = new Array(pushLen); + const initialLength = target.length; + + for (let i = 0; i < pushLen; i++) { + pushContent[i] = getObservedOf(pushItems[i], initialLength + i, oMeta); + } + const pushResult = Reflect.apply(target.push, target, pushContent); + + const changes = []; + for (let i = initialLength, j = target.length; i < j; i++) { + changes[i - initialLength] = new Change(INSERT, [i], target[i], undefined, this); + } + callObservers(oMeta, changes); + + return pushResult; +}; \ No newline at end of file diff --git a/src/observables/methods/reverse.ts b/src/observables/methods/reverse.ts new file mode 100644 index 0000000..2feb03c --- /dev/null +++ b/src/observables/methods/reverse.ts @@ -0,0 +1,25 @@ +import { Change } from '../../model/change.ts'; +import { REVERSE, oMetaKey } from '../../constants.ts'; +import { callObservers } from '../processors/proc-utils.ts'; + +export default function proxiedReverse() { + const oMeta = this[oMetaKey]; + const target = oMeta.target; + let i, l, item; + + target.reverse(); + for (i = 0, l = target.length; i < l; i++) { + item = target[i]; + if (item && typeof item === 'object') { + const tmpObserved = item[oMetaKey]; + if (tmpObserved) { + tmpObserved.ownKey = i; + } + } + } + + const changes = [new Change(REVERSE, [], undefined, undefined, this)]; + callObservers(oMeta, changes); + + return this; +}; \ No newline at end of file diff --git a/src/observables/methods/set.ts b/src/observables/methods/set.ts new file mode 100644 index 0000000..d517b8f --- /dev/null +++ b/src/observables/methods/set.ts @@ -0,0 +1,28 @@ +import { Change } from '../../model/change.ts'; +import { INSERT, UPDATE } from '../../constants.ts'; +import { oMetaKey } from '../../constants.ts'; +import { getObservedOf } from '../processors/proc-utils.ts'; +import { callObservers } from '../processors/proc-utils.ts'; + +export function proxiedSet(target: object, key: string | symbol, value: unknown): boolean { + let oldValue = target[key]; + + if (value !== oldValue) { + const newValue = getObservedOf(value, key, this); + target[key] = newValue; + + if (oldValue && typeof oldValue === 'object') { + const tmpObserved = oldValue[oMetaKey]; + if (tmpObserved) { + oldValue = tmpObserved.detach(); + } + } + + const changes = oldValue === undefined + ? [new Change(INSERT, [key], newValue, undefined, this.proxy)] + : [new Change(UPDATE, [key], newValue, oldValue, this.proxy)]; + callObservers(this, changes); + } + + return true; +}; \ No newline at end of file diff --git a/src/observables/methods/shift.ts b/src/observables/methods/shift.ts new file mode 100644 index 0000000..35bf5cb --- /dev/null +++ b/src/observables/methods/shift.ts @@ -0,0 +1,34 @@ +import { Change } from '../../model/change.ts'; +import { DELETE, oMetaKey } from '../../constants.ts'; +import { callObservers } from '../processors/proc-utils.ts'; + +export default function proxiedShift() { + const + oMeta = this[oMetaKey], + target = oMeta.target; + let shiftResult, i, l, item, tmpObserved; + + shiftResult = target.shift(); + if (shiftResult && typeof shiftResult === 'object') { + tmpObserved = shiftResult[oMetaKey]; + if (tmpObserved) { + shiftResult = tmpObserved.detach(); + } + } + + // update indices of the remaining items + for (i = 0, l = target.length; i < l; i++) { + item = target[i]; + if (item && typeof item === 'object') { + tmpObserved = item[oMetaKey]; + if (tmpObserved) { + tmpObserved.ownKey = i; + } + } + } + + const changes = [new Change(DELETE, [0], undefined, shiftResult, this)]; + callObservers(oMeta, changes); + + return shiftResult; +}; \ No newline at end of file diff --git a/src/observables/methods/sort.ts b/src/observables/methods/sort.ts new file mode 100644 index 0000000..1c4459f --- /dev/null +++ b/src/observables/methods/sort.ts @@ -0,0 +1,25 @@ +import { Change } from '../../model/change.ts'; +import { SHUFFLE, oMetaKey } from '../../constants.ts'; +import { callObservers } from '../processors/proc-utils.ts'; + +export default function proxiedSort(comparator) { + const oMeta = this[oMetaKey]; + const target = oMeta.target; + let i, l, item; + + target.sort(comparator); + for (i = 0, l = target.length; i < l; i++) { + item = target[i]; + if (item && typeof item === 'object') { + const tmpObserved = item[oMetaKey]; + if (tmpObserved) { + tmpObserved.ownKey = i; + } + } + } + + const changes = [new Change(SHUFFLE, [], undefined, undefined, this)]; + callObservers(oMeta, changes); + + return this; +}; \ No newline at end of file diff --git a/src/observables/methods/splice.ts b/src/observables/methods/splice.ts new file mode 100644 index 0000000..c3b3208 --- /dev/null +++ b/src/observables/methods/splice.ts @@ -0,0 +1,64 @@ +import { Change } from '../../model/change.ts'; +import { INSERT, DELETE, UPDATE, oMetaKey } from '../../constants.ts'; +import { getObservedOf } from '../processors/proc-utils.ts'; +import { callObservers } from '../processors/proc-utils.ts'; + +export default function proxiedSplice(...spliceItems: unknown[]) { + const oMeta = this[oMetaKey]; + const target = oMeta.target; + const splLen = spliceItems.length; + const spliceContent = new Array(splLen); + const tarLen = target.length; + + // observify the newcomers + for (let i = 0; i < splLen; i++) { + spliceContent[i] = getObservedOf(spliceItems[i], i, oMeta); + } + + // calculate pointers + const startIndex = splLen === 0 ? 0 : (spliceContent[0] < 0 ? tarLen + spliceContent[0] : spliceContent[0]); + const removed = splLen < 2 ? tarLen - startIndex : spliceContent[1]; + const inserted = Math.max(splLen - 2, 0); + const spliceResult: unknown[] = Reflect.apply(target.splice, target, spliceContent); + const newTarLen = target.length; + + // reindex the paths + let tmpObserved; + for (let i = 0, item; i < newTarLen; i++) { + item = target[i]; + if (item && typeof item === 'object') { + tmpObserved = item[oMetaKey]; + if (tmpObserved) { + tmpObserved.ownKey = i; + } + } + } + + // detach removed objects + let i, l, item; + for (i = 0, l = spliceResult.length; i < l; i++) { + item = spliceResult[i]; + if (item && typeof item === 'object') { + tmpObserved = item[oMetaKey]; + if (tmpObserved) { + spliceResult[i] = tmpObserved.detach(); + } + } + } + + const changes = []; + let index; + for (index = 0; index < removed; index++) { + if (index < inserted) { + changes.push(new Change(UPDATE, [startIndex + index], target[startIndex + index], spliceResult[index], this)); + } else { + changes.push(new Change(DELETE, [startIndex + index], undefined, spliceResult[index], this)); + } + } + for (; index < inserted; index++) { + changes.push(new Change(INSERT, [startIndex + index], target[startIndex + index], undefined, this)); + } + callObservers(oMeta, changes); + + return spliceResult; +}; \ No newline at end of file diff --git a/src/observables/methods/typed-set.ts b/src/observables/methods/typed-set.ts new file mode 100644 index 0000000..89480cf --- /dev/null +++ b/src/observables/methods/typed-set.ts @@ -0,0 +1,19 @@ +import { Change } from '../../model/change.ts'; +import { UPDATE, oMetaKey } from '../../constants.ts'; +import { callObservers } from '../processors/proc-utils.ts'; + +export default function proxiedTypedArraySet(source, offset) { + const oMeta = this[oMetaKey]; + const target = oMeta.target; + const souLen = source.length; + const prev = target.slice(0); + offset = offset || 0; + + target.set(source, offset); + const changes = new Array(souLen); + for (let i = offset; i < (souLen + offset); i++) { + changes[i - offset] = new Change(UPDATE, [i], target[i], prev[i], this); + } + + callObservers(oMeta, changes); +}; \ No newline at end of file diff --git a/src/observables/methods/unshift.ts b/src/observables/methods/unshift.ts new file mode 100644 index 0000000..29aa8bf --- /dev/null +++ b/src/observables/methods/unshift.ts @@ -0,0 +1,36 @@ +import { Change } from '../../model/change.ts'; +import { INSERT, oMetaKey } from '../../constants.ts'; +import { getObservedOf } from '../processors/proc-utils.ts'; +import { callObservers } from '../processors/proc-utils.ts'; + +export default function proxiedUnshift(...unshiftItems: unknown[]) { + const oMeta = this[oMetaKey]; + const target = oMeta.target; + const unshiftLen = unshiftItems.length; + const unshiftContent = new Array(unshiftLen); + + for (let i = 0; i < unshiftLen; i++) { + unshiftContent[i] = getObservedOf(unshiftItems[i], i, oMeta); + } + const unshiftResult = Reflect.apply(target.unshift, target, unshiftContent); + + for (let i = 0, l = target.length, item; i < l; i++) { + item = target[i]; + if (item && typeof item === 'object') { + const tmpObserved = item[oMetaKey]; + if (tmpObserved) { + tmpObserved.ownKey = i; + } + } + } + + // publish changes + const l = unshiftContent.length; + const changes = new Array(l); + for (let i = 0; i < l; i++) { + changes[i] = new Change(INSERT, [i], target[i], undefined, this); + } + callObservers(oMeta, changes); + + return unshiftResult; +}; \ No newline at end of file diff --git a/src/observables/object.ts b/src/observables/object.ts new file mode 100644 index 0000000..8e1f0c1 --- /dev/null +++ b/src/observables/object.ts @@ -0,0 +1,15 @@ +import { ObservableBase } from './abstract-base.ts'; +import { getObservedOf } from './processors/proc-utils.ts'; +import { oMetaKey } from '../constants.ts'; + +export class ObservableObject extends ObservableBase { + + observedGraphProcessor(source: object, observableWrapper: ObservableBase, visited: Set): object { + const target = {}; + target[oMetaKey] = observableWrapper; + for (const key in source) { + target[key] = getObservedOf(source[key], key, observableWrapper, visited); + } + return target; + } +} \ No newline at end of file diff --git a/src/observables/processors/proc-utils.ts b/src/observables/processors/proc-utils.ts new file mode 100644 index 0000000..e8be93f --- /dev/null +++ b/src/observables/processors/proc-utils.ts @@ -0,0 +1,139 @@ +import { oMetaKey, REVERSE, SHUFFLE } from '../../constants.ts'; +import { Change } from '../../model/change.ts'; +import { ObservableBase } from '../abstract-base.ts'; +import { ObservableArray } from '../array.ts'; +import { ObservableObject } from '../object.ts'; +import { ObservableTypedArray } from '../typed-array.ts'; + +export function getObservedOf(item: unknown, key: string | symbol | number, parent: object, visited?: Set): unknown { + if (visited !== undefined && visited.has(item)) { + return null; + } else if (typeof item !== 'object' || item === null) { + return item; + } else if (Array.isArray(item)) { + return new ObservableArray({ target: item, ownKey: key, parent: parent, visited }).proxy; + } else if (ArrayBuffer.isView(item)) { + return new ObservableTypedArray({ target: item, ownKey: key, parent: parent }).proxy; + } else if (item instanceof Date) { + return item; + } else { + return new ObservableObject({ target: item, ownKey: key, parent: parent, visited }).proxy; + } +}; + +export function getObservableFromRoot(target: unknown, options = undefined): unknown { + if (!target || typeof target !== 'object') { + throw new Error('observable MAY ONLY be created from a non-null object'); + } else if (target[oMetaKey]) { + return target; + } else if (Array.isArray(target)) { + return new ObservableArray({ target: target, ownKey: null, parent: null, options: options }).proxy; + } else if (ArrayBuffer.isView(target)) { + return new ObservableTypedArray({ target: target, ownKey: null, parent: null, options: options }).proxy; + } else if (target instanceof Date) { + throw new Error(`${target} found to be one of a non-observable types`); + } else { + return new ObservableObject({ target: target, ownKey: null, parent: null, options: options }).proxy; + } +} + +export function callObservers(oMeta: ObservableBase, changes: Change[]) { + let currentObservable: ObservableBase = oMeta; + let isAsync, observers, target, options, relevantChanges, i; + const l = changes.length; + do { + isAsync = currentObservable.async; + observers = currentObservable.observers; + i = observers.length; + while (i--) { + [target, options] = observers[i]; + relevantChanges = filterChanges(options, changes); + + if (relevantChanges.length) { + if (isAsync) { + // this is the async dispatch handling + if (currentObservable.batches.length === 0) { + queueMicrotask(callObserversFromMT.bind(currentObservable)); + } + let rb; + for (const b of currentObservable.batches) { + if (b[0] === target) { + rb = b; + break; + } + } + if (!rb) { + rb = [target, []]; + currentObservable.batches.push(rb); + } + Array.prototype.push.apply(rb[1], relevantChanges); + } else { + // this is the naive straight forward synchronous dispatch + callObserverSafe(target, relevantChanges); + } + } + } + + // cloning all the changes and notifying in context of parent + const parent = currentObservable.parent; + if (parent) { + for (let j = 0; j < l; j++) { + const change = changes[j]; + changes[j] = new Change( + change.type, + [currentObservable.ownKey, ...change.path], + change.value, + change.oldValue, + change.object + ); + } + currentObservable = parent; + } else { + break; + } + } while (currentObservable); +}; + +function filterChanges(options, changes) { + if (options === null) { + return changes; + } + + let result = changes; + if (options.path) { + const oPath = options.path; + result = changes.filter(change => + change.path.join('.') === oPath + ); + } else if (options.pathsOf) { + const oPathsOf = options.pathsOf; + const oPathsOfStr = oPathsOf.join('.'); + result = changes.filter(change => + (change.path.length === oPathsOf.length + 1 || + (change.path.length === oPathsOf.length && (change.type === REVERSE || change.type === SHUFFLE))) && + change.path.join('.').startsWith(oPathsOfStr) + ); + } else if (options.pathsFrom) { + const oPathsFrom = options.pathsFrom; + result = changes.filter(change => + change.path.join('.').startsWith(oPathsFrom) + ); + } + return result; +} + +function callObserverSafe(listener, changes) { + try { + listener(changes); + } catch (e) { + console.error(`failed to notify listener ${listener} with ${changes}`, e); + } +} + +function callObserversFromMT() { + const batches = this.batches; + this.batches = []; + for (const [listener, changes] of batches) { + callObserverSafe(listener, changes); + } +}; \ No newline at end of file diff --git a/src/observables/typed-array.ts b/src/observables/typed-array.ts new file mode 100644 index 0000000..a559069 --- /dev/null +++ b/src/observables/typed-array.ts @@ -0,0 +1,27 @@ +import { ObservableBase } from './abstract-base.ts'; +import { oMetaKey } from '../constants.ts'; +import proxiedCopyWithin from './methods/copy-within.ts'; +import proxiedFill from './methods/fill.ts'; +import proxiedReverse from './methods/reverse.ts'; +import proxiedSort from './methods/sort.ts'; +import proxiedTypedArraySet from './methods/typed-set.ts'; + +const proxiedTypedArrayMethods = { + copyWithin: proxiedCopyWithin, + fill: proxiedFill, + reverse: proxiedReverse, + sort: proxiedSort, + set: proxiedTypedArraySet +}; + +export class ObservableTypedArray extends ObservableBase { + + get(taget: object, key: string): unknown { + return proxiedTypedArrayMethods[key] || taget[key]; + } + + observedGraphProcessor(source: Array, observableWrapper: ObservableBase): Array { + source[oMetaKey] = observableWrapper; + return source; + } +} \ No newline at end of file diff --git a/sri.json b/sri.json index cb438c4..f487df0 100644 --- a/sri.json +++ b/sri.json @@ -1,5 +1,6 @@ { - "dist/cdn/object-observer.js": "sha512-Jc51tpJGoR6MHLupRabCfXEVroGC2M5QEH19brhsgJ42vJnsIBUgDg4CeaTES8jZfQTLwzPp5mjIXg64cdEzAg==", - "dist/cdn/object-observer.min.js": "sha512-jieNuEyCm4guZuELCk+tZ8ijFhxyw6cOhx4q8cimhqdNccson04GDZpGm5kISKIkI//xERXAep1bt5HWKLDDNw==", - "dist/cdn/object-observer.min.js.map": "sha512-oCCsAVsC1+BcyrA8KzysTfw0U1qH6HN3Z/HTzyBbJUc5aPRA4+W2Hp4HSwD2Z5CDbYyKFDKyle7YHLy0V8GA2Q==" + "dist/cdn/object-observer.js": "sha512-P4pkZ11qV2l9nf3LYnwgm5oC1LY7rWaaJpOOcGSCeNDStNDH+akJEWsltvnX9tSuprxJl8XOa2GoYiRzVJaxaQ==", + "dist/cdn/object-observer.js.map": "sha512-FONqa/XOwyN6O7HyQyO8bpCfksGJ1kY62kqOOyXlFCOTmJRtnX8O/2O6kG6TgvFUycNzodnofWQ+DKoFtmipyA==", + "dist/cdn/object-observer.min.js": "sha512-kO0nlxJUjqEeMz62gkuho9Yz7xaykdOz8M5wYlejCKtV6XHNP4xTCD7k5TucIlKMIv0ogzK+RWzvPMN0gM8TYQ==", + "dist/cdn/object-observer.min.js.map": "sha512-8IuhGFJD2VjfAmL0Kdm7qQTO821JD5Ko80bKzs7haopUPBDvV1HwXYkPvsQN46z7ezUABgjPDgSKzXeILqdLCg==" } \ No newline at end of file diff --git a/tests/api-base.js b/tests/api-base.js index 1d40e26..ae924dd 100644 --- a/tests/api-base.js +++ b/tests/api-base.js @@ -1,6 +1,6 @@ import { test } from '@gullerya/just-test'; import { assert } from '@gullerya/just-test/assert'; -import { Observable } from '../src/object-observer.js'; +import { Observable } from '../src/object-observer.ts'; test('ensure Observable object has defined APIs', () => { assert.equal(typeof Observable, 'object'); diff --git a/tests/api-changes.js b/tests/api-changes.js index 5f14a3f..03876ef 100644 --- a/tests/api-changes.js +++ b/tests/api-changes.js @@ -1,13 +1,13 @@ import { test } from '@gullerya/just-test'; import { assert } from '@gullerya/just-test/assert'; -import { Observable } from '../src/object-observer.js'; +import { Observable } from '../src/object-observer.ts'; // object // test('verify object - root - insert', () => { let c; const o = Observable.from({}); - Observable.observe(o, cs => { c = cs[0]; }) + Observable.observe(o, cs => { c = cs[0]; }); o.some = 'new'; assert.deepStrictEqual(c, { type: 'insert', path: ['some'], value: 'new', oldValue: undefined, object: o }); }); @@ -15,7 +15,7 @@ test('verify object - root - insert', () => { test('verify object - deep - insert', () => { let c; const o = Observable.from({ a: {} }); - Observable.observe(o, cs => { c = cs[0]; }) + Observable.observe(o, cs => { c = cs[0]; }); o.a.some = 'new'; assert.deepStrictEqual(c, { type: 'insert', path: ['a', 'some'], value: 'new', oldValue: undefined, object: o.a }); }); @@ -23,7 +23,7 @@ test('verify object - deep - insert', () => { test('verify object - root - update', () => { let c; const o = Observable.from({ p: 'old' }); - Observable.observe(o, cs => { c = cs[0]; }) + Observable.observe(o, cs => { c = cs[0]; }); o.p = 'new'; assert.deepStrictEqual(c, { type: 'update', path: ['p'], value: 'new', oldValue: 'old', object: o }); }); @@ -31,7 +31,7 @@ test('verify object - root - update', () => { test('verify object - deep - update', () => { let c; const o = Observable.from({ a: { p: 'old' } }); - Observable.observe(o, cs => { c = cs[0]; }) + Observable.observe(o, cs => { c = cs[0]; }); o.a.p = 'new'; assert.deepStrictEqual(c, { type: 'update', path: ['a', 'p'], value: 'new', oldValue: 'old', object: o.a }); }); @@ -39,7 +39,7 @@ test('verify object - deep - update', () => { test('verify object - root - delete', () => { let c; const o = Observable.from({ p: 'old' }); - Observable.observe(o, cs => { c = cs[0]; }) + Observable.observe(o, cs => { c = cs[0]; }); delete o.p; assert.deepStrictEqual(c, { type: 'delete', path: ['p'], value: undefined, oldValue: 'old', object: o }); }); @@ -47,7 +47,7 @@ test('verify object - root - delete', () => { test('verify object - deep - delete', () => { let c; const o = Observable.from({ a: { p: 'old' } }); - Observable.observe(o, cs => { c = cs[0]; }) + Observable.observe(o, cs => { c = cs[0]; }); delete o.a.p; assert.deepStrictEqual(c, { type: 'delete', path: ['a', 'p'], value: undefined, oldValue: 'old', object: o.a }); }); @@ -57,7 +57,7 @@ test('verify object - deep - delete', () => { test('verify array - root - insert', () => { let c; const o = Observable.from([]); - Observable.observe(o, cs => { c = cs[0]; }) + Observable.observe(o, cs => { c = cs[0]; }); o.push('new'); assert.deepStrictEqual(c, { type: 'insert', path: [0], value: 'new', oldValue: undefined, object: o }); }); @@ -65,7 +65,7 @@ test('verify array - root - insert', () => { test('verify array - deep - insert', () => { let c; const o = Observable.from([[]]); - Observable.observe(o, cs => { c = cs[0]; }) + Observable.observe(o, cs => { c = cs[0]; }); o[0].push('new'); assert.deepStrictEqual(c, { type: 'insert', path: [0, 0], value: 'new', oldValue: undefined, object: o[0] }); }); @@ -73,7 +73,7 @@ test('verify array - deep - insert', () => { test('verify array - root - update', () => { let c; const o = Observable.from(['old']); - Observable.observe(o, cs => { c = cs[0]; }) + Observable.observe(o, cs => { c = cs[0]; }); o[0] = 'new'; assert.deepStrictEqual(c, { type: 'update', path: ['0'], value: 'new', oldValue: 'old', object: o }); }); @@ -81,7 +81,7 @@ test('verify array - root - update', () => { test('verify array - deep - update', () => { let c; const o = Observable.from([['old']]); - Observable.observe(o, cs => { c = cs[0]; }) + Observable.observe(o, cs => { c = cs[0]; }); o[0][0] = 'new'; assert.deepStrictEqual(c, { type: 'update', path: [0, '0'], value: 'new', oldValue: 'old', object: o[0] }); }); @@ -89,7 +89,7 @@ test('verify array - deep - update', () => { test('verify array - root - delete', () => { let c; const o = Observable.from(['old']); - Observable.observe(o, cs => { c = cs[0]; }) + Observable.observe(o, cs => { c = cs[0]; }); o.pop(); assert.deepStrictEqual(c, { type: 'delete', path: [0], value: undefined, oldValue: 'old', object: o }); }); @@ -97,7 +97,7 @@ test('verify array - root - delete', () => { test('verify array - deep - delete', () => { let c; const o = Observable.from([['old']]); - Observable.observe(o, cs => { c = cs[0]; }) + Observable.observe(o, cs => { c = cs[0]; }); o[0].pop(); assert.deepStrictEqual(c, { type: 'delete', path: [0, 0], value: undefined, oldValue: 'old', object: o[0] }); }); diff --git a/tests/browser-host-objects.js b/tests/browser-host-objects.js index 4cf3c7b..44667f7 100644 --- a/tests/browser-host-objects.js +++ b/tests/browser-host-objects.js @@ -1,6 +1,6 @@ import { test } from '@gullerya/just-test'; import { assert } from '@gullerya/just-test/assert'; -import { Observable } from '../src/object-observer.js'; +import { Observable } from '../src/object-observer.ts'; test('test DOMStringMap', () => { const diff --git a/tests/configs/tests-config-ci-chromium.js b/tests/configs/tests-config-ci-chromium.js new file mode 100644 index 0000000..2fa403a --- /dev/null +++ b/tests/configs/tests-config-ci-chromium.js @@ -0,0 +1,31 @@ +const config = { + environments: [ + { + browser: { + type: 'chromium', + executors: { + type: 'iframe' + } + }, + tests: { + ttl: 32000, + maxFail: 0, + maxSkip: 5, + include: [ + './tests/*' + ], + exclude: [ + '**/configs/**', + '**/*-performance-*.js' + ] + }, + coverage: { + include: [ + './src/**/*' + ] + } + } + ] +} + +export default config; \ No newline at end of file diff --git a/tests/configs/tests-config-ci-chromium.json b/tests/configs/tests-config-ci-chromium.json deleted file mode 100644 index e15d9fd..0000000 --- a/tests/configs/tests-config-ci-chromium.json +++ /dev/null @@ -1,28 +0,0 @@ -{ - "environments": [ - { - "browser": { - "type": "chromium", - "executors": { - "type": "iframe" - } - }, - "tests": { - "ttl": 32000, - "maxFail": 0, - "maxSkip": 5, - "include": [ - "./tests/*.js" - ], - "exclude": [ - "**/tests/*-performance-*.js" - ] - }, - "coverage": { - "include": [ - "./src/**/*.js" - ] - } - } - ] -} \ No newline at end of file diff --git a/tests/configs/tests-config-ci-firefox.js b/tests/configs/tests-config-ci-firefox.js new file mode 100644 index 0000000..d6332db --- /dev/null +++ b/tests/configs/tests-config-ci-firefox.js @@ -0,0 +1,26 @@ +const config = { + environments: [ + { + browser: { + type: 'firefox', + executors: { + type: 'iframe' + } + }, + tests: { + ttl: 32000, + maxFail: 0, + maxSkip: 5, + include: [ + './tests/*' + ], + exclude: [ + '**/configs/**', + '**/*-performance-*.js' + ] + } + } + ] +}; + +export default config; \ No newline at end of file diff --git a/tests/configs/tests-config-ci-firefox.json b/tests/configs/tests-config-ci-firefox.json deleted file mode 100644 index 2b8e0b6..0000000 --- a/tests/configs/tests-config-ci-firefox.json +++ /dev/null @@ -1,29 +0,0 @@ -{ - "environments": [ - { - "browser": { - "type": "firefox", - "executors": { - "type": "iframe" - }, - "importmap": { - "imports": { - "@gullerya/just-test": "/libs/@gullerya/just-test/bin/runner/just-test.js", - "@gullerya/just-test/assert": "/libs/@gullerya/just-test/bin/common/assert-utils.js" - } - } - }, - "tests": { - "ttl": 32000, - "maxFail": 0, - "maxSkip": 5, - "include": [ - "./tests/*.js" - ], - "exclude": [ - "**/tests/*-performance-*.js" - ] - } - } - ] -} \ No newline at end of file diff --git a/tests/configs/tests-config-ci-node.js b/tests/configs/tests-config-ci-node.js new file mode 100644 index 0000000..ec7fece --- /dev/null +++ b/tests/configs/tests-config-ci-node.js @@ -0,0 +1,27 @@ +const config = { + environments: [ + { + node: true, + tests: { + ttl: 32000, + maxFail: 0, + maxSkip: 5, + include: [ + './tests/*' + ], + exclude: [ + '**/configs/**', + '**/browser-host-objects.js', + '**/*-performance-*.js' + ] + }, + coverage: { + include: [ + './src/**/*' + ] + } + } + ] +}; + +export default config; \ No newline at end of file diff --git a/tests/configs/tests-config-ci-node.json b/tests/configs/tests-config-ci-node.json deleted file mode 100644 index d87a427..0000000 --- a/tests/configs/tests-config-ci-node.json +++ /dev/null @@ -1,24 +0,0 @@ -{ - "environments": [ - { - "node": true, - "tests": { - "ttl": 32000, - "maxFail": 0, - "maxSkip": 5, - "include": [ - "./tests/*.js" - ], - "exclude": [ - "**/tests/browser-host-objects.js", - "**/tests/*-performance-*.js" - ] - }, - "coverage": { - "include": [ - "./src/**/*.js" - ] - } - } - ] -} \ No newline at end of file diff --git a/tests/configs/tests-config-ci-webkit.js b/tests/configs/tests-config-ci-webkit.js new file mode 100644 index 0000000..262ab8f --- /dev/null +++ b/tests/configs/tests-config-ci-webkit.js @@ -0,0 +1,26 @@ +const config = { + environments: [ + { + browser: { + type: 'webkit', + executors: { + type: 'iframe' + } + }, + tests: { + ttl: 32000, + maxFail: 0, + maxSkip: 5, + include: [ + './tests/*' + ], + exclude: [ + '**/configs/**', + '**/*-performance-*.js' + ] + } + } + ] +}; + +export default config; \ No newline at end of file diff --git a/tests/configs/tests-config-ci-webkit.json b/tests/configs/tests-config-ci-webkit.json deleted file mode 100644 index 4412d41..0000000 --- a/tests/configs/tests-config-ci-webkit.json +++ /dev/null @@ -1,29 +0,0 @@ -{ - "environments": [ - { - "browser": { - "type": "webkit", - "executors": { - "type": "iframe" - }, - "importmap": { - "imports": { - "@gullerya/just-test": "/libs/@gullerya/just-test/bin/runner/just-test.js", - "@gullerya/just-test/assert": "/libs/@gullerya/just-test/bin/common/assert-utils.js" - } - } - }, - "tests": { - "ttl": 32000, - "maxFail": 0, - "maxSkip": 5, - "include": [ - "./tests/*.js" - ], - "exclude": [ - "**/tests/*-performance-*.js" - ] - } - } - ] -} \ No newline at end of file diff --git a/tests/cross-instance.js b/tests/cross-instance.js index 8ac2f2c..1cf226d 100644 --- a/tests/cross-instance.js +++ b/tests/cross-instance.js @@ -1,7 +1,7 @@ import { test } from '@gullerya/just-test'; import { assert } from '@gullerya/just-test/assert'; -import { Observable as O1, ObjectObserver as OO1 } from '../src/object-observer.js?1'; -import { Observable as O2, ObjectObserver as OO2 } from '../src/object-observer.js?2'; +import { Observable as O1, ObjectObserver as OO1 } from '../src/object-observer.ts?key=1'; +import { Observable as O2, ObjectObserver as OO2 } from '../src/object-observer.ts?key=2'; test('Observable.isObservable interoperable', () => { assert.notEqual(O1, O2); diff --git a/tests/filters.js b/tests/filters.js new file mode 100644 index 0000000..64a4333 --- /dev/null +++ b/tests/filters.js @@ -0,0 +1,54 @@ +import { test } from '@gullerya/just-test'; +import { assert } from '@gullerya/just-test/assert'; +import { Filter } from '../src/changes-processors/filters.ts'; +import { Change } from '../src/model/change.ts'; + +test('test filters - ctor direct use forbidden', () => { + assert.throws(() => new Filter('some', (change) => change.prop !== 'skip'), 'Filter class cannot be instantiated directly', 'Filter class cannot be instantiated directly'); +}); + +test('custom filter - positive cases', () => { + const filterLogic = changes => changes.filter(c => c.value !== null); + const f = Filter.custom(filterLogic); + assert.strictEqual(f.fn, filterLogic, 'Filter code is correct'); +}); + +test('custom filter - negative cases', () => { + assert.throws(() => Filter.custom(null), 'custom Filter requires a function as argument'); + assert.throws(() => Filter.custom('some'), 'custom Filter requires a function as argument'); +}); + +test('exactPaths filter - positive cases', () => { + const f = Filter.exactPaths(['a', 'b.c']); + const changes = [ + new Change('update', ['a'], 1, 0), + new Change('update', ['b', 'c'], 2, 0), + new Change('update', ['a', 'b', 'c'], 3, 0), + new Change('update', ['b', 'c', 'd'], 4, 0) + ]; + const filtered = f.fn(changes); + assert.strictEqual(filtered.length, 2); +}); + +test('exactPaths filter - negative cases', () => { + assert.throws(() => Filter.exactPaths(null), 'exactPaths Filter requires a non-empty array as argument'); + assert.throws(() => Filter.exactPaths([]), 'exactPaths Filter requires a non-empty array as argument'); +}); + +test('pathsStartWith filter - positive cases', () => { + const f = Filter.pathsStartWith('a.b'); + const changes = [ + new Change('update', ['a'], 1, 0), + new Change('update', ['a', 'b'], 2, 0), + new Change('update', ['a', 'c', 'c'], 3, 0), + new Change('update', ['a', 'b', 'c'], 4, 0) + ]; + const filtered = f.fn(changes); + assert.strictEqual(filtered.length, 2); +}); + +test('pathsStartWith filter - negative cases', () => { + assert.throws(() => Filter.pathsStartWith(null), 'pathsStartWith Filter requires a non-empty string as argument'); + assert.throws(() => Filter.pathsStartWith([]), 'pathsStartWith Filter requires a non-empty string as argument'); + assert.throws(() => Filter.pathsStartWith(''), 'pathsStartWith Filter requires a non-empty string as argument'); +}); diff --git a/tests/listeners.js b/tests/listeners.js index 44f1816..571af8a 100644 --- a/tests/listeners.js +++ b/tests/listeners.js @@ -1,6 +1,6 @@ import { test } from '@gullerya/just-test'; import { assert } from '@gullerya/just-test/assert'; -import { Observable } from '../src/object-observer.js'; +import { Observable } from '../src/object-observer.ts'; test('test listeners invocation - single listener', () => { const oo = Observable.from({}); diff --git a/tests/object-generics.js b/tests/object-generics.js index 1a16bf6..4342010 100644 --- a/tests/object-generics.js +++ b/tests/object-generics.js @@ -1,6 +1,6 @@ import { test } from '@gullerya/just-test'; import { assert } from '@gullerya/just-test/assert'; -import { Observable } from '../src/object-observer.js'; +import { Observable } from '../src/object-observer.ts'; test('Object.seal - further extensions should fail', () => { const oo = Observable.from({ propA: 'a', propB: 'b' }); diff --git a/tests/object-observer-api.js b/tests/object-observer-api.js index af054b6..b7044d2 100644 --- a/tests/object-observer-api.js +++ b/tests/object-observer-api.js @@ -1,6 +1,6 @@ import { test } from '@gullerya/just-test'; import { assert } from '@gullerya/just-test/assert'; -import { ObjectObserver, Observable } from '../src/object-observer.js'; +import { ObjectObserver, Observable } from '../src/object-observer.ts'; test('ensure ObjectObserver constructable', () => { assert.isTrue(typeof ObjectObserver === 'function'); diff --git a/tests/object-observer-arrays-copy-within.js b/tests/object-observer-arrays-copy-within.js index ac9febb..2c42755 100644 --- a/tests/object-observer-arrays-copy-within.js +++ b/tests/object-observer-arrays-copy-within.js @@ -1,6 +1,6 @@ import { test } from '@gullerya/just-test'; import { assert } from '@gullerya/just-test/assert'; -import { Observable } from '../src/object-observer.js'; +import { Observable } from '../src/object-observer.ts'; test('array copyWithin - primitives', () => { const diff --git a/tests/object-observer-arrays-typed.js b/tests/object-observer-arrays-typed.js index 45631ab..4fd0828 100644 --- a/tests/object-observer-arrays-typed.js +++ b/tests/object-observer-arrays-typed.js @@ -1,6 +1,6 @@ import { test } from '@gullerya/just-test'; import { assert } from '@gullerya/just-test/assert'; -import { Observable } from '../src/object-observer.js'; +import { Observable } from '../src/object-observer.ts'; test('typed array reverse - Int8Array', () => { const diff --git a/tests/object-observer-arrays.js b/tests/object-observer-arrays.js index 189f83f..6111c9e 100644 --- a/tests/object-observer-arrays.js +++ b/tests/object-observer-arrays.js @@ -1,6 +1,6 @@ import { test } from '@gullerya/just-test'; import { assert } from '@gullerya/just-test/assert'; -import { Observable } from '../src/object-observer.js'; +import { Observable } from '../src/object-observer.ts'; test('array push - primitives', () => { const diff --git a/tests/object-observer-native-objects-to-skip.js b/tests/object-observer-native-objects-to-skip.js index 62978fd..f428a84 100644 --- a/tests/object-observer-native-objects-to-skip.js +++ b/tests/object-observer-native-objects-to-skip.js @@ -1,6 +1,6 @@ import { test } from '@gullerya/just-test'; import { assert } from '@gullerya/just-test/assert'; -import { Observable } from '../src/object-observer.js'; +import { Observable } from '../src/object-observer.ts'; test('creating observable from non-observable should throw an error', () => { const objectsToTest = [ diff --git a/tests/object-observer-objects-async.js b/tests/object-observer-objects-async.js index 0dc43e1..2d9c8d5 100644 --- a/tests/object-observer-objects-async.js +++ b/tests/object-observer-objects-async.js @@ -1,7 +1,7 @@ import { test } from '@gullerya/just-test'; import { assert } from '@gullerya/just-test/assert'; import { waitNextTask } from '@gullerya/just-test/timing'; -import { Observable } from '../src/object-observer.js'; +import { Observable } from '../src/object-observer.ts'; test('multiple continuous mutations', async () => { const diff --git a/tests/object-observer-objects-circular.js b/tests/object-observer-objects-circular.js index 166c433..f2a5c61 100644 --- a/tests/object-observer-objects-circular.js +++ b/tests/object-observer-objects-circular.js @@ -1,6 +1,6 @@ import { test } from '@gullerya/just-test'; import { assert } from '@gullerya/just-test/assert'; -import { Observable } from '../src/object-observer.js'; +import { Observable } from '../src/object-observer.ts'; test('subgraph object pointing to the top parent', () => { const o = { prop: 'text' }; diff --git a/tests/object-observer-objects-same-refs.js b/tests/object-observer-objects-same-refs.js index d68c50a..cd4226c 100644 --- a/tests/object-observer-objects-same-refs.js +++ b/tests/object-observer-objects-same-refs.js @@ -1,6 +1,6 @@ import { test } from '@gullerya/just-test'; import { assert } from '@gullerya/just-test/assert'; -import { Observable } from '../src/object-observer.js'; +import { Observable } from '../src/object-observer.ts'; test('subgraph objects pointing to the same object few times', { skip: true }, () => { const childObj = { prop: 'A' }; diff --git a/tests/object-observer-objects.js b/tests/object-observer-objects.js index 4eb2e3b..729dfdd 100644 --- a/tests/object-observer-objects.js +++ b/tests/object-observer-objects.js @@ -1,6 +1,6 @@ import { test } from '@gullerya/just-test'; import { assert } from '@gullerya/just-test/assert'; -import { Observable } from '../src/object-observer.js'; +import { Observable } from '../src/object-observer.ts'; test('creating observable leaves original object as is', () => { const person = { @@ -42,7 +42,7 @@ test('plain object operations', () => { name: 'name', age: 7, address: null - } + }; const events = [], tmpAddress = { street: 'some' }; diff --git a/tests/object-observer-subgraphs.js b/tests/object-observer-subgraphs.js index ff23b37..ba33009 100644 --- a/tests/object-observer-subgraphs.js +++ b/tests/object-observer-subgraphs.js @@ -1,6 +1,6 @@ import { test } from '@gullerya/just-test'; import { assert } from '@gullerya/just-test/assert'; -import { Observable } from '../src/object-observer.js'; +import { Observable } from '../src/object-observer.ts'; test('inner object from observable should fire events as usual', () => { const diff --git a/tests/observable-nested.js b/tests/observable-nested.js index 5ea291a..31c12b3 100644 --- a/tests/observable-nested.js +++ b/tests/observable-nested.js @@ -1,6 +1,6 @@ import { test } from '@gullerya/just-test'; import { assert } from '@gullerya/just-test/assert'; -import { Observable } from '../src/object-observer.js'; +import { Observable } from '../src/object-observer.ts'; test('nested of observable should be observable too', () => { const oo = Observable.from({ @@ -97,7 +97,7 @@ test('nested observable should handle errors', () => { city: 'city' } } - }) + }); const oou = Observable.from(oo.user); assert.throws( () => Observable.observe(oou, 'invalid observer'), @@ -135,7 +135,7 @@ test('nested observable should provide correct path (relative to self)', () => { city: 'city' } } - }) + }); const oou = Observable.from(oo.user), ooua = Observable.from(oo.user.address), diff --git a/tests/observe-specific-paths.js b/tests/observe-specific-paths.js index 7dc81bf..35a7490 100644 --- a/tests/observe-specific-paths.js +++ b/tests/observe-specific-paths.js @@ -1,6 +1,6 @@ import { test } from '@gullerya/just-test'; import { assert } from '@gullerya/just-test/assert'; -import { Observable } from '../src/object-observer.js'; +import { Observable } from '../src/object-observer.ts'; test('baseline - negative - path not a string', () => { const oo = Observable.from({}); diff --git a/tests/reassignment-of-equals.js b/tests/reassignment-of-equals.js index 6246f5a..9f4e619 100644 --- a/tests/reassignment-of-equals.js +++ b/tests/reassignment-of-equals.js @@ -1,6 +1,6 @@ import { test } from '@gullerya/just-test'; import { assert } from '@gullerya/just-test/assert'; -import { Observable } from '../src/object-observer.js'; +import { Observable } from '../src/object-observer.ts'; test('boolean', () => { const oo = Observable.from({ p: true }); diff --git a/tests/revokation.js b/tests/revokation.js index f4cc0cf..37f79c2 100644 --- a/tests/revokation.js +++ b/tests/revokation.js @@ -1,6 +1,6 @@ import { test } from '@gullerya/just-test'; import { assert } from '@gullerya/just-test/assert'; -import { Observable } from '../src/object-observer.js'; +import { Observable } from '../src/object-observer.ts'; test('test revokation of replaced objects - simple set', () => { const og = Observable.from({ diff --git a/tests/unobserve.js b/tests/unobserve.js index d74718a..458b227 100644 --- a/tests/unobserve.js +++ b/tests/unobserve.js @@ -1,6 +1,6 @@ import { test } from '@gullerya/just-test'; import { assert } from '@gullerya/just-test/assert'; -import { Observable } from '../src/object-observer.js'; +import { Observable } from '../src/object-observer.ts'; test('test unobserve - single observer - explicit unobserve', () => { const diff --git a/tests/workers/perf-async-test-a.js b/tests/workers/perf-async-test-a.js index e0d4162..0959261 100644 --- a/tests/workers/perf-async-test-a.js +++ b/tests/workers/perf-async-test-a.js @@ -1,4 +1,4 @@ -import { Observable } from '../../src/object-observer.js'; +import { Observable } from '../../src/object-observer.ts'; export default async setup => { const { diff --git a/tests/workers/perf-async-test-b.js b/tests/workers/perf-async-test-b.js index 3ad190d..41bb05e 100644 --- a/tests/workers/perf-async-test-b.js +++ b/tests/workers/perf-async-test-b.js @@ -1,4 +1,4 @@ -import { Observable } from '../../src/object-observer.js'; +import { Observable } from '../../src/object-observer.ts'; export default async setup => { const { diff --git a/tests/workers/perf-sync-test-a.js b/tests/workers/perf-sync-test-a.js index b12421b..b1a03a3 100644 --- a/tests/workers/perf-sync-test-a.js +++ b/tests/workers/perf-sync-test-a.js @@ -1,4 +1,4 @@ -import { Observable } from '../../src/object-observer.js'; +import { Observable } from '../../src/object-observer.ts'; export default setup => { const { diff --git a/tests/workers/perf-sync-test-b.js b/tests/workers/perf-sync-test-b.js index eb41e41..fe68c6b 100644 --- a/tests/workers/perf-sync-test-b.js +++ b/tests/workers/perf-sync-test-b.js @@ -1,4 +1,4 @@ -import { Observable } from '../../src/object-observer.js'; +import { Observable } from '../../src/object-observer.ts'; export default setup => { const { diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..6d27183 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,11 @@ +{ + "compilerOptions": { + "strict": false, + "target": "es2024", + "noEmit": true, + "allowImportingTsExtensions": true, + "rewriteRelativeImportExtensions": true, + "verbatimModuleSyntax": true, + "erasableSyntaxOnly": true + } +} \ No newline at end of file