diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml
index db9f273..2bd75b6 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
@@ -76,12 +83,6 @@ jobs:
with:
token: ${{ secrets.CODECOV_TOKEN }}
- - name: Archive logs
- uses: actions/upload-artifact@v4
- with:
- name: chromium-logs
- path: reports/logs/chromium-112.0.5615.29.log
-
quality_pipeline_firefox:
runs-on: ubuntu-24.04
timeout-minutes: 5
@@ -98,14 +99,12 @@ 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
- - name: Report coverage
- uses: codecov/codecov-action@v5
- with:
- token: ${{ secrets.CODECOV_TOKEN }}
-
quality_pipeline_webkit:
runs-on: ubuntu-24.04
timeout-minutes: 5
@@ -122,10 +121,8 @@ 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
-
- - name: Report coverage
- uses: codecov/codecov-action@v5
- with:
- token: ${{ secrets.CODECOV_TOKEN }}
diff --git a/.nvmrc b/.nvmrc
index 0a49261..5bf4400 100644
--- a/.nvmrc
+++ b/.nvmrc
@@ -1 +1 @@
-24.11.0
+24.15.0
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/docs/changelog.md b/docs/changelog.md
index ed63133..c799346 100644
--- a/docs/changelog.md
+++ b/docs/changelog.md
@@ -7,13 +7,24 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
## [7.0.0]
+### BREAKING CHANGE
+- [Issue no. 152](https://github.com/gullerya/object-observer/issues/152) - `Observable.observe` options API replaced:
+ - removed: `path`, `pathsOf`, `pathsFrom`
+ - added: `filters` — a non-empty array of `Filter` instances; multiple filters compose as logical AND
+ - `Filter` is now exported from the package entry; use its static factories (`exactPaths`, `pathsStartWith`, `directChildrenOf`, `custom`) to build filters
+ - migration: `{ path: 'a.b' }` → `{ filters: [Filter.exactPaths(['a.b'])] }`; `{ pathsOf: 'a' }` → `{ filters: [Filter.directChildrenOf('a')] }`; `{ pathsFrom: 'a' }` → `{ filters: [Filter.pathsStartWith('a')] }`
### Added
- [Issue no. 149](https://github.com/gullerya/object-observer/issues/149) - added verifiers to prevent unallowed changes
-- [Issue no. 152](https://github.com/gullerya/object-observer/issues/152) - change the way filters are configured
+- `Filter.directChildrenOf(path)` factory — covers the previous `pathsOf` semantics, with a fixed sibling-prefix bug (`directChildrenOf('inner')` no longer matches `innerX.foo`)
+### Fixed
+- `npm test` script referenced a non-existent `.json` config; now points to the correct `.js` file
### Chore
- updated dependencies
- reorganized sources
- updated build scripts
+- raised Node.js runtime to `24.15.0` (aligned with current LTS)
+- browser support matrix updated to "last 2 versions" of Chrome, Firefox, Edge, Safari
+- removed stale commented-out duplicate of the dispatch pipeline from `src/object-observer.ts`
## [6.1.4] - 2025-02-14
### Chore
diff --git a/docs/filter-paths.md b/docs/filter-paths.md
deleted file mode 100644
index c4a170d..0000000
--- a/docs/filter-paths.md
+++ /dev/null
@@ -1,73 +0,0 @@
-# Filter paths options
-
-`Observable.observe(...)` allows `options` parameter, third one, optional.
-
-Some of the options are filtering ones, allowing to specify the changes of interest from within the observable graph. Here is a detailed description of those options.
-
-## __`pathsFrom`__
-
-Value expected to be a non-empty string representing a path, any changes of which and deeper will be delivered to the observer.
-> This option MAY NOT be used together with `path` option.
-
-
-
-
-
-
-
- Given, that we have subscribed for the changes via:
-Observable.observe(o, callback, { pathsFrom: 'address' });
- Following mutations will be delivered to the callback:
-o.address.street.apt = 5;
-o.address.city = 'DreamCity';
-o.address = {};
- Following mutations will not be delivered to the callback:
-o.lastName = 'Joker';
-
-
-
-
-## __`pathsOf`__
-
-Value expected to be a string, which MAY be empty, representing a path. Changes to direct properties of which will be notified.
-
-
-
-
-
-
-
- Given, that we have subscribed for the changes via:
-Observable.observe(o, callback, { pathsOf: 'address' });
- Following mutations will be delivered to the callback:
-o.address.street = {};
-o.address.city = 'DreamCity';
- Following mutations will not be delivered to the callback:
-o.lastName = 'Joker';
-o.address = {};
-o.address.street.apt = 5;
-
-
-
-
-## __`path`__
-
-Value expected to be a non-empty string, representing a specific path to observe. Only a changes of this exact path will be notified.
-
-
-
-
-
-
-
- Given, that we have subscribed for the changes via:
-Observable.observe(o, callback, { path: 'address.street' });
- Following mutations will be delivered to the callback:
-o.address.street = {};
- Following mutations will not be delivered to the callback:
-o.lastName = 'Joker';
-o.address = {};
-o.address.street.apt = 5;
-
-
-
\ No newline at end of file
diff --git a/docs/filters.md b/docs/filters.md
new file mode 100644
index 0000000..6561f32
--- /dev/null
+++ b/docs/filters.md
@@ -0,0 +1,67 @@
+# Filters
+
+`Observable.observe(...)` accepts an optional third argument — an options object. The only currently supported option is `filters`: a non-empty array of `Filter` instances. Changes that survive **all** filters (logical AND) are delivered to the observer.
+
+```javascript
+import { Observable, Filter } from '@gullerya/object-observer';
+
+Observable.observe(obs, callback, {
+ filters: [Filter.directChildrenOf('address'), Filter.pathsStartWith('address.city')]
+});
+```
+
+`Filter` instances are created via static factory methods only; the constructor is private.
+
+## Factory methods
+
+### `Filter.exactPaths(paths: string[]): Filter`
+
+Delivers only changes whose path (as a dotted string) is an exact match for one of the provided paths. `paths` MUST be a non-empty array.
+
+```javascript
+Filter.exactPaths(['firstName', 'address.city'])
+```
+
+### `Filter.pathsStartWith(prefix: string): Filter`
+
+Delivers changes whose path string starts with `prefix`. `prefix` MUST be a non-empty string.
+
+```javascript
+Filter.pathsStartWith('address')
+// matches 'address', 'address.city', 'address.extra.data', ...
+```
+
+### `Filter.directChildrenOf(path: string): Filter`
+
+Delivers changes whose path is a **direct child** of `path` (exactly one level deeper). `path` MUST be a string (may be empty — empty string means the root). REVERSE and SHUFFLE changes at `path` itself are also delivered, because they are semantically mutations of the container's children.
+
+```javascript
+Filter.directChildrenOf('address')
+// matches 'address.city', 'address.block' — but NOT 'address' or 'address.extra.data'
+
+Filter.directChildrenOf('')
+// matches any top-level property change
+```
+
+### `Filter.custom(fn: (changes: Change[]) => Change[]): Filter`
+
+Wraps an arbitrary function. `fn` receives the (already-narrowed) array of `Change` objects and returns a filtered array. `fn` MUST be a function.
+
+```javascript
+Filter.custom(changes => changes.filter(c => c.type === 'update'))
+```
+
+## Composition
+
+When multiple filters are provided, they run in order and each narrows the result of the previous one. Equivalent to logical AND:
+
+```javascript
+// direct children of 'a' AND whose path starts with 'a.x'
+// → only changes to 'a.x'
+{ filters: [Filter.directChildrenOf('a'), Filter.pathsStartWith('a.x')] }
+```
+
+## Validation
+
+- `filters` (when provided) MUST be a non-empty array.
+- Every element MUST be a `Filter` instance — bare functions are rejected. Use `Filter.custom(fn)` to wrap a function.
diff --git a/docs/observable.md b/docs/observable.md
index 24faac8..4078c79 100644
--- a/docs/observable.md
+++ b/docs/observable.md
@@ -86,15 +86,9 @@ Observable.unobserve(observableAddress);
```
## Observation options
-If/When provided, `options` parameter MUST contain ONLY one of the properties below, no 'unknown' properties allowed.
+If/When provided, `options` MUST be an object. Unknown properties are rejected — incorrect observation options throw, to fail fast.
-In order to fail-fast and prevent unexpected mess down the hill, incorrect observation options will throw.
-
-- __`path`__ - non-empty string; specific path to observe, only a changes of this exact path will be notified; [details here](filter-paths.md)
-
-- __`pathsOf`__ - string, MAY be empty; direct properties of the specified path will be notified; [details here](filter-paths.md)
-
-- __`pathsFrom`__ - non-empty string, any changes from the specified path and deeper will be delivered to the observer; [details here](filter-paths.md)
+- __`filters`__ - non-empty array of `Filter` instances; changes that survive **all** filters (logical AND) are delivered to the observer; [details here](filters.md)
## `Change` instance properties
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..55d00e5 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",
- "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",
+ "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.js",
+ "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/readme.md b/readme.md
index 0bf08a5..1602404 100644
--- a/readme.md
+++ b/readme.md
@@ -14,26 +14,29 @@ Main aspects and features:
- observation is 'deep', yielding changes from a __sub-graphs__ too
- nested objects of the observable graph are observables too
- changes delivered in a __synchronous__ way by default, __asynchronous__ delivery is optionally available as per `Observable` configuration; [more details here](docs/sync-async.md)
-- observed path may optionally be filtered as per `observer` configuration; [more details here](docs/filter-paths.md)
+- observed changes may optionally be filtered via the `Filter` class; [more details here](docs/filters.md)
- original objects are __cloned__ while turned into `Observable`s
- circular references are nullified in the clone
+- observed mutations on plain objects:
+ - property assignment (`obj.x = v`, `obj['x'] = v`)
+ - property deletion (`delete obj.x`)
- __array__ specifics:
- - generic object-like mutations supported
- - intrinsic `Array` mutation methods supported: `pop`, `push`, `shift`, `unshift`, `reverse`, `sort`, `fill`, `splice`, `copyWithin`
- - massive mutations delivered in a single callback, usually having an array of an atomic changes
+ - indexed assignment (`arr[i] = v`), `length` assignment, and `delete arr[i]` are observed (same traps as for plain objects)
+ - intrinsic `Array` mutation methods observed: `pop`, `push`, `shift`, `unshift`, `reverse`, `sort`, `fill`, `splice`, `copyWithin`
+ - massive mutations delivered in a single callback, usually as an array of atomic changes
- __typed array__ specifics:
- - generic object-like mutations supported
- - intrinsic `TypedArray` mutation methods supported: `reverse`, `sort`, `fill`, `set`, `copyWithin`
- - massive mutations delivered in a single callback, usually having an array of an atomic changes
+ - indexed assignment (`ta[i] = v`) is observed (same trap as for plain objects); `length` is fixed
+ - intrinsic `TypedArray` mutation methods observed: `reverse`, `sort`, `fill`, `set`, `copyWithin`
+ - massive mutations delivered in a single callback, usually as an array of atomic changes
- intrinsic mutation methods of `Map`, `WeakMap`, `Set`, `WeakSet` (`set`, `delete`) etc __are not__ observed (see this [issue](https://github.com/gullerya/object-observer/issues/1) for more details)
- following host objects (and their extensions) are __skipped__ from cloning / turning into observables: `Date`
Supported:
-71+ |
-65+ |
-79+ |
-12.1 |
- 12.0.0+
+last 2 versions |
+last 2 versions |
+last 2 versions |
+last 2 versions |
+ 24.15.0+
Performance report can be found [here](docs/performance-report.md).
@@ -210,11 +213,13 @@ In cases of massive changes touching presumably the whole array I took a pessimi
##### Observation options
-`object-observer` allows to filter the events delivered to each callback/listener by an optional configuration object passed to the `observe` API.
+`object-observer` allows to filter the events delivered to each callback/listener via an optional `filters` array — each element MUST be a `Filter` instance. Multiple filters compose as logical AND (each filter narrows the result).
> In the examples below assume that `callback = changes => {...}`.
```javascript
+import { Observable, Filter } from '@gullerya/object-observer';
+
let user = {
firstName: 'Aya',
lastName: 'Guller',
@@ -229,29 +234,40 @@ let user = {
},
oUser = Observable.from(user);
-// path
+// exact paths
//
-// going to observe ONLY the changes of 'firstName'
-Observable.observe(oUser, callback, {path: 'firstName'});
+// going to observe ONLY the changes of 'firstName' or 'address.city'
+Observable.observe(oUser, callback, { filters: [Filter.exactPaths(['firstName', 'address.city'])] });
-// going to observe ONLY the changes of 'address.city'
-Observable.observe(oUser, callback, {path: 'address.city'});
+// direct children of 'address' (city, street, block, extra) — and REVERSE/SHUFFLE at 'address'
+Observable.observe(oUser, callback, { filters: [Filter.directChildrenOf('address')] });
-// pathsOf
-//
-// going to observe the changes of 'address' own properties ('city', 'block') but not else
-Observable.observe(oUser, callback, {pathsOf: 'address'});
-// here we'll be notified on changes of
-// address.city
-// address.extra
+// all changes from 'address' and deeper
+Observable.observe(oUser, callback, { filters: [Filter.pathsStartWith('address')] });
-// pathsFrom
-//
-// going to observe the changes from 'address' and deeper
-Observable.observe(oUser, callback, {pathsFrom: 'address'});
-// here we'll be notified on changes of
-// address
-// address.city
-// address.extra
-// address.extra.data
+// custom predicate
+Observable.observe(oUser, callback, { filters: [Filter.custom(cs => cs.filter(c => c.type === 'update'))] });
+```
+
+##### Validators
+
+`object-observer` allows to veto mutations before they are applied via an optional `validators` array supplied to `Observable.from`. Each element MUST be a `Validator` instance. Validators are attached at tree creation — they apply to the whole observable graph, not per observer.
+
+A validator receives the prospective `Change[]` (with rooted paths and the raw, un-observified new value) BEFORE the underlying target is mutated. Throwing from a validator aborts the mutation: the target is left unchanged and observers are not invoked. Multiple validators compose as logical AND with short-circuit — the first throw stops the chain.
+
+```javascript
+import { Observable, Validator } from '@gullerya/object-observer';
+
+const immutableMarker = Validator.custom(changes => {
+ for (const c of changes) {
+ if (c.oldValue === 'immutable') {
+ throw new Error(`change at '${c.pathAsString}' rejected: oldValue is immutable`);
+ }
+ }
+});
+
+const oo = Observable.from({ a: 'immutable' }, { validators: [immutableMarker] });
+oo.a = 'other'; // throws; oo.a still === 'immutable'; no observer fired
```
+
+> Not yet supported: validators do not currently run for `Array.prototype.splice`, `Array.prototype.fill`, and `Array.prototype.copyWithin` (and their `TypedArray` counterparts where applicable). These mutations proceed as before without validator consultation. Support is planned for a subsequent release.
diff --git a/src/changes-processors/filters.ts b/src/changes-processors/filters.ts
new file mode 100644
index 0000000..38052eb
--- /dev/null
+++ b/src/changes-processors/filters.ts
@@ -0,0 +1,77 @@
+import { Change } from '../model/change.ts';
+import { REVERSE, SHUFFLE } from '../constants.ts';
+
+export type FilterFn = (changes: Change[]) => Change[];
+
+export class Filter {
+ static #privateCtorKey = Symbol('FilterPrivateConstructorKey');
+ #fn: FilterFn;
+
+ constructor(privateCtorKey, fn: FilterFn) {
+ if (privateCtorKey !== Filter.#privateCtorKey) {
+ throw new Error('Filter class cannot be instantiated directly; use provided factory methods');
+ }
+
+ this.#fn = fn;
+ }
+
+ get fn(): FilterFn { return this.#fn; }
+
+ static custom(fn: FilterFn): 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))
+ );
+ }
+
+ // direct children of the given path; an empty string represents the root.
+ // REVERSE/SHUFFLE happening at the path itself are also included
+ // (they are semantically mutations of the container's direct children).
+ static directChildrenOf(path: string): Filter {
+ if (typeof path !== 'string') {
+ throw new Error('directChildrenOf Filter requires a string as argument (MAY be empty)');
+ }
+ const segments = path.split('.').filter(Boolean);
+ const depth = segments.length;
+ const prefix = segments.join('.');
+ // at depth N, a direct-child path is the first N segments of change.path
+ return new Filter(
+ Filter.#privateCtorKey,
+ changes => changes.filter(change => {
+ const cp = change.path;
+ const pl = cp.length;
+ if (pl === depth + 1) {
+ for (let i = 0; i < depth; i++) {
+ if (cp[i] !== segments[i]) { return false; }
+ }
+ return true;
+ }
+ if (pl === depth && (change.type === REVERSE || change.type === SHUFFLE)) {
+ return change.pathAsString === prefix;
+ }
+ return false;
+ })
+ );
+ }
+}
\ No newline at end of file
diff --git a/src/changes-processors/validators.ts b/src/changes-processors/validators.ts
new file mode 100644
index 0000000..1de0704
--- /dev/null
+++ b/src/changes-processors/validators.ts
@@ -0,0 +1,26 @@
+import { Change } from '../model/change.ts';
+
+export type ValidatorFn = (changes: Change[]) => void;
+
+export class Validator {
+ static #privateCtorKey = Symbol('ValidatorPrivateConstructorKey');
+
+ #validate: ValidatorFn;
+
+ constructor(privateCtorKey, fn: ValidatorFn) {
+ if (privateCtorKey !== Validator.#privateCtorKey) {
+ throw new Error('Validator class cannot be instantiated directly; use provided factory methods');
+ }
+
+ this.#validate = fn;
+ }
+
+ get validate(): ValidatorFn { return this.#validate; }
+
+ static custom(fn: ValidatorFn): Validator {
+ if (typeof fn !== 'function') {
+ throw new Error('custom Validator requires a function as argument');
+ }
+ return new Validator(Validator.#privateCtorKey, fn);
+ }
+}
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..7552cb5
--- /dev/null
+++ b/src/model/change.ts
@@ -0,0 +1,47 @@
+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) {
+ 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) {
+ 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..4e7d4ae
--- /dev/null
+++ b/src/object-observer.ts
@@ -0,0 +1,113 @@
+import { oMetaKey } from './constants.ts';
+import { getObservableFromRoot } from './observables/processors/proc-utils.ts';
+import { Filter, type FilterFn } from './changes-processors/filters.ts';
+import { Validator } from './changes-processors/validators.ts';
+
+export { Filter, Validator };
+
+const
+ processObserveOptions = options => {
+ if (!options || typeof options !== 'object') {
+ return null;
+ }
+
+ const result: { filters?: FilterFn[] } = {};
+ const invalidOptions = [];
+ for (const [optName, optVal] of Object.entries(options)) {
+ if (optName === 'filters') {
+ if (!Array.isArray(optVal) || optVal.length === 0) {
+ throw new Error('"filters" option, if/when provided, MUST be a non-empty array of Filter instances');
+ }
+ const fns: FilterFn[] = new Array(optVal.length);
+ for (let i = 0; i < optVal.length; i++) {
+ const f = optVal[i];
+ if (!(f instanceof Filter)) {
+ throw new Error('"filters" option, if/when provided, MUST be a non-empty array of Filter instances');
+ }
+ fns[i] = f.fn;
+ }
+ result.filters = fns;
+ } else {
+ invalidOptions.push(optName);
+ }
+ }
+ if (invalidOptions.length) {
+ throw new Error(`'${invalidOptions.join(', ')}' is/are not a valid observer option/s`);
+ }
+ return Object.keys(result).length === 0 ? null : result;
+ };
+
+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..cdd1db2
--- /dev/null
+++ b/src/observables/abstract-base.ts
@@ -0,0 +1,93 @@
+import { Change } from '../model/change.ts';
+import { Validator } from '../changes-processors/validators.ts';
+import { proxiedDeleteProperty } from './methods/delete-property.ts';
+import { proxiedSet } from './methods/set.ts';
+
+const validObservableOptionKeys = { async: 1, validators: 1 };
+
+export interface ObservableOptions {
+ async?: boolean;
+ validators?: Array;
+}
+export type ChangesProcessor = (changes: Change[]) => void;
+
+export abstract 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 = new Map();
+
+ set;
+ deleteProperty;
+
+ #validators: 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;
+ 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 validators(): Array { return this.#validators; }
+ get observers(): Array { return this.#observers; }
+
+ abstract observedGraphProcessor(source: object, observableWrapper: ObservableBase, visited: Set): object;
+
+ #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 (options.validators !== undefined) {
+ if (!Array.isArray(options.validators) || options.validators.length === 0) {
+ throw new Error('"validators" option, if/when provided, MUST be a non-empty array of Validator instances');
+ }
+ for (const v of options.validators) {
+ if (!(v instanceof Validator)) {
+ throw new Error('"validators" option, if/when provided, MUST be a non-empty array of Validator instances');
+ }
+ }
+ this.#validators.push(...options.validators);
+ }
+ }
+}
\ 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..424a36d
--- /dev/null
+++ b/src/observables/methods/copy-within.ts
@@ -0,0 +1,41 @@
+import { Change } from '../../model/change.ts';
+import { UPDATE, oMetaKey } from '../../constants.ts';
+import { callObservers, detachIfObservable, getObservedOf } 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);
+ const changes = [];
+
+ target.copyWithin(dest, start, end);
+
+ for (let i = dest; i < dest + len; i++) {
+ // update newly placed observables, if any
+ let nItem = target[i];
+ if (nItem && typeof nItem === 'object') {
+ nItem = getObservedOf(nItem, i, oMeta);
+ target[i] = nItem;
+ }
+
+ // detach overridden observables, if any
+ const oItem = detachIfObservable(prev[i]);
+
+ if (typeof nItem !== 'object' && nItem === oItem) {
+ continue;
+ }
+ changes.push(new Change(UPDATE, [i], nItem, oItem, this));
+ }
+
+ callObservers(oMeta, changes);
+ }
+
+ return this;
+};
diff --git a/src/observables/methods/delete-property.ts b/src/observables/methods/delete-property.ts
new file mode 100644
index 0000000..d88bc8f
--- /dev/null
+++ b/src/observables/methods/delete-property.ts
@@ -0,0 +1,18 @@
+import { Change } from '../../model/change.ts';
+import { DELETE } from '../../constants.ts';
+import { callObservers, detachIfObservable, runValidators } from '../processors/proc-utils.ts';
+
+export function proxiedDeleteProperty(target: object, key: string | symbol): boolean {
+ const prevValue = target[key];
+
+ const prospective = [new Change(DELETE, [key], undefined, prevValue, this.proxy)];
+ runValidators(this, prospective);
+
+ delete target[key];
+ const oldValue = detachIfObservable(prevValue);
+
+ const changes = [new Change(DELETE, [key], undefined, oldValue, this.proxy)];
+ callObservers(this, changes);
+
+ return true;
+};
diff --git a/src/observables/methods/fill.ts b/src/observables/methods/fill.ts
new file mode 100644
index 0000000..c3d3732
--- /dev/null
+++ b/src/observables/methods/fill.ts
@@ -0,0 +1,31 @@
+import { Change } from '../../model/change.ts';
+import { INSERT, UPDATE, oMetaKey } from '../../constants.ts';
+import { callObservers, detachIfObservable, getObservedOf } 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);
+
+ for (let i = start; i < end; i++) {
+ target[i] = getObservedOf(target[i], i, oMeta);
+ if (i in prev) {
+ const oldValue = detachIfObservable(prev[i]);
+ changes.push(new Change(UPDATE, [i], target[i], oldValue, this));
+ } else {
+ changes.push(new Change(INSERT, [i], target[i], undefined, this));
+ }
+ }
+
+ callObservers(oMeta, changes);
+ }
+
+ return this;
+};
diff --git a/src/observables/methods/pop.ts b/src/observables/methods/pop.ts
new file mode 100644
index 0000000..a7c9b38
--- /dev/null
+++ b/src/observables/methods/pop.ts
@@ -0,0 +1,20 @@
+import { Change } from '../../model/change.ts';
+import { DELETE, oMetaKey } from '../../constants.ts';
+import { callObservers, detachIfObservable, runValidators } from '../processors/proc-utils.ts';
+
+export default function proxiedPop() {
+ const oMeta = this[oMetaKey];
+ const target = oMeta.target;
+ const poppedIndex = target.length - 1;
+ const prevValue = target[poppedIndex];
+
+ const prospective = [new Change(DELETE, [poppedIndex], undefined, prevValue, this)];
+ runValidators(oMeta, prospective);
+
+ const popResult = detachIfObservable(target.pop());
+
+ const changes = [new Change(DELETE, [poppedIndex], undefined, popResult, this)];
+ callObservers(oMeta, changes);
+
+ return popResult;
+}
diff --git a/src/observables/methods/push.ts b/src/observables/methods/push.ts
new file mode 100644
index 0000000..58900d8
--- /dev/null
+++ b/src/observables/methods/push.ts
@@ -0,0 +1,30 @@
+import { Change } from '../../model/change.ts';
+import { INSERT, oMetaKey } from '../../constants.ts';
+import { callObservers, getObservedOf, runValidators } from '../processors/proc-utils.ts';
+
+export default function proxiedPush(...pushItems: unknown[]) {
+ const oMeta = this[oMetaKey];
+ const target = oMeta.target;
+ const pushLen = pushItems.length;
+ const initialLength = target.length;
+
+ const prospective = new Array(pushLen);
+ for (let i = 0; i < pushLen; i++) {
+ prospective[i] = new Change(INSERT, [initialLength + i], pushItems[i], undefined, this);
+ }
+ runValidators(oMeta, prospective);
+
+ const pushContent = new Array(pushLen);
+ 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 = new Array(pushLen);
+ for (let i = 0; i < pushLen; i++) {
+ changes[i] = new Change(INSERT, [initialLength + i], target[initialLength + i], undefined, this);
+ }
+ callObservers(oMeta, changes);
+
+ return pushResult;
+};
diff --git a/src/observables/methods/reverse.ts b/src/observables/methods/reverse.ts
new file mode 100644
index 0000000..6b1e580
--- /dev/null
+++ b/src/observables/methods/reverse.ts
@@ -0,0 +1,18 @@
+import { Change } from '../../model/change.ts';
+import { REVERSE, oMetaKey } from '../../constants.ts';
+import { callObservers, reindexObservableChildren, runValidators } from '../processors/proc-utils.ts';
+
+export default function proxiedReverse() {
+ const oMeta = this[oMetaKey];
+ const target = oMeta.target;
+
+ const changes = [new Change(REVERSE, [], undefined, undefined, this)];
+ runValidators(oMeta, changes);
+
+ target.reverse();
+ reindexObservableChildren(target);
+
+ callObservers(oMeta, changes);
+
+ return this;
+};
diff --git a/src/observables/methods/set.ts b/src/observables/methods/set.ts
new file mode 100644
index 0000000..9a04274
--- /dev/null
+++ b/src/observables/methods/set.ts
@@ -0,0 +1,27 @@
+import { Change } from '../../model/change.ts';
+import { INSERT, UPDATE } from '../../constants.ts';
+import { callObservers, detachIfObservable, getObservedOf, runValidators } from '../processors/proc-utils.ts';
+
+export function proxiedSet(target: object, key: string | symbol, value: unknown): boolean {
+ const prevValue = target[key];
+
+ if (value !== prevValue) {
+ // build prospective change with raw (un-observified) new value so validators see what the caller wrote
+ const prospective = prevValue === undefined
+ ? [new Change(INSERT, [key], value, undefined, this.proxy)]
+ : [new Change(UPDATE, [key], value, prevValue, this.proxy)];
+ runValidators(this, prospective);
+
+ const newValue = getObservedOf(value, key, this);
+ target[key] = newValue;
+
+ const oldValue = detachIfObservable(prevValue);
+
+ 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;
+};
diff --git a/src/observables/methods/shift.ts b/src/observables/methods/shift.ts
new file mode 100644
index 0000000..a944306
--- /dev/null
+++ b/src/observables/methods/shift.ts
@@ -0,0 +1,20 @@
+import { Change } from '../../model/change.ts';
+import { DELETE, oMetaKey } from '../../constants.ts';
+import { callObservers, detachIfObservable, reindexObservableChildren, runValidators } from '../processors/proc-utils.ts';
+
+export default function proxiedShift() {
+ const oMeta = this[oMetaKey];
+ const target = oMeta.target;
+ const prevValue = target[0];
+
+ const prospective = [new Change(DELETE, [0], undefined, prevValue, this)];
+ runValidators(oMeta, prospective);
+
+ const shiftResult = detachIfObservable(target.shift());
+ reindexObservableChildren(target);
+
+ const changes = [new Change(DELETE, [0], undefined, shiftResult, this)];
+ callObservers(oMeta, changes);
+
+ return shiftResult;
+};
diff --git a/src/observables/methods/sort.ts b/src/observables/methods/sort.ts
new file mode 100644
index 0000000..9782281
--- /dev/null
+++ b/src/observables/methods/sort.ts
@@ -0,0 +1,18 @@
+import { Change } from '../../model/change.ts';
+import { SHUFFLE, oMetaKey } from '../../constants.ts';
+import { callObservers, reindexObservableChildren, runValidators } from '../processors/proc-utils.ts';
+
+export default function proxiedSort(comparator) {
+ const oMeta = this[oMetaKey];
+ const target = oMeta.target;
+
+ const changes = [new Change(SHUFFLE, [], undefined, undefined, this)];
+ runValidators(oMeta, changes);
+
+ target.sort(comparator);
+ reindexObservableChildren(target);
+
+ callObservers(oMeta, changes);
+
+ return this;
+};
diff --git a/src/observables/methods/splice.ts b/src/observables/methods/splice.ts
new file mode 100644
index 0000000..b5b2380
--- /dev/null
+++ b/src/observables/methods/splice.ts
@@ -0,0 +1,45 @@
+import { Change } from '../../model/change.ts';
+import { INSERT, DELETE, UPDATE, oMetaKey } from '../../constants.ts';
+import { callObservers, detachIfObservable, getObservedOf, reindexObservableChildren } 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);
+
+ reindexObservableChildren(target);
+
+ // detach removed objects
+ for (let i = 0, l = spliceResult.length; i < l; i++) {
+ spliceResult[i] = detachIfObservable(spliceResult[i]);
+ }
+
+ 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;
+};
diff --git a/src/observables/methods/typed-set.ts b/src/observables/methods/typed-set.ts
new file mode 100644
index 0000000..7b9b82a
--- /dev/null
+++ b/src/observables/methods/typed-set.ts
@@ -0,0 +1,28 @@
+import { Change } from '../../model/change.ts';
+import { UPDATE, oMetaKey } from '../../constants.ts';
+import { callObservers, runValidators } from '../processors/proc-utils.ts';
+
+export default function proxiedTypedArraySet(source, offset) {
+ const oMeta = this[oMetaKey];
+ const target = oMeta.target;
+ const souLen = source.length;
+ offset = offset || 0;
+
+ if (souLen > 0) {
+ const prev = target.slice(offset, offset + souLen);
+
+ const prospective = new Array(souLen);
+ for (let i = 0; i < souLen; i++) {
+ prospective[i] = new Change(UPDATE, [offset + i], source[i], prev[i], this);
+ }
+ runValidators(oMeta, prospective);
+
+ target.set(source, offset);
+
+ const changes = new Array(souLen);
+ for (let i = 0; i < souLen; i++) {
+ changes[i] = new Change(UPDATE, [offset + i], target[offset + i], prev[i], this);
+ }
+ callObservers(oMeta, changes);
+ }
+};
diff --git a/src/observables/methods/unshift.ts b/src/observables/methods/unshift.ts
new file mode 100644
index 0000000..a10baaf
--- /dev/null
+++ b/src/observables/methods/unshift.ts
@@ -0,0 +1,31 @@
+import { Change } from '../../model/change.ts';
+import { INSERT, oMetaKey } from '../../constants.ts';
+import { callObservers, getObservedOf, reindexObservableChildren, runValidators } from '../processors/proc-utils.ts';
+
+export default function proxiedUnshift(...unshiftItems: unknown[]) {
+ const oMeta = this[oMetaKey];
+ const target = oMeta.target;
+ const unshiftLen = unshiftItems.length;
+
+ const prospective = new Array(unshiftLen);
+ for (let i = 0; i < unshiftLen; i++) {
+ prospective[i] = new Change(INSERT, [i], unshiftItems[i], undefined, this);
+ }
+ runValidators(oMeta, prospective);
+
+ 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);
+
+ reindexObservableChildren(target);
+
+ const changes = new Array(unshiftLen);
+ for (let i = 0; i < unshiftLen; i++) {
+ changes[i] = new Change(INSERT, [i], target[i], undefined, this);
+ }
+ callObservers(oMeta, changes);
+
+ return unshiftResult;
+};
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..4f0c621
--- /dev/null
+++ b/src/observables/processors/proc-utils.ts
@@ -0,0 +1,164 @@
+import { oMetaKey } 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;
+ }
+}
+
+// re-assign ownKey for every observable child of target whose position has changed
+export function reindexObservableChildren(target: ArrayLike, from = 0): void {
+ for (let i = from, l = target.length; i < l; i++) {
+ const item = target[i];
+ if (item && typeof item === 'object') {
+ const childMeta = item[oMetaKey];
+ if (childMeta) {
+ childMeta.ownKey = i;
+ }
+ }
+ }
+}
+
+// if value is an observable, detach it and return the unwrapped target; otherwise return value as-is
+export function detachIfObservable(value: unknown): unknown {
+ if (value && typeof value === 'object') {
+ const childMeta = value[oMetaKey];
+ if (childMeta) {
+ return childMeta.detach();
+ }
+ }
+ return value;
+}
+
+// invoke every Validator attached to the root of this observable tree.
+// changes' paths are rebuilt relative to the root before being passed to validators.
+// any validator throw propagates — mutation is aborted.
+export function runValidators(oMeta: ObservableBase, changes: Change[]): void {
+ // find root + accumulate path prefix from this node up
+ const prefix: Array = [];
+ let current: ObservableBase = oMeta;
+ while (current.parent) {
+ prefix.unshift(current.ownKey);
+ current = current.parent;
+ }
+ const validators = current.validators;
+ if (validators.length === 0) {
+ return;
+ }
+ const rooted = prefix.length === 0
+ ? changes
+ : changes.map(c => new Change(c.type, [...prefix, ...c.path], c.value, c.oldValue, c.object));
+ for (let i = 0, l = validators.length; i < l; i++) {
+ validators[i].validate(rooted);
+ }
+}
+
+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.size === 0) {
+ queueMicrotask(callObserversFromMT.bind(currentObservable));
+ }
+ let batch = currentObservable.batches.get(target);
+ if (!batch) {
+ batch = [];
+ currentObservable.batches.set(target, batch);
+ }
+ batch.push(...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 || !options.filters) {
+ return changes;
+ }
+ let result = changes;
+ const filters = options.filters;
+ for (let i = 0, l = filters.length; i < l && result.length; i++) {
+ result = filters[i](result);
+ }
+ 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 = new Map();
+ for (const [listener, changes] of batches) {
+ callObserverSafe(listener, changes);
+ }
+};
diff --git a/src/observables/typed-array.ts b/src/observables/typed-array.ts
new file mode 100644
index 0000000..ea2a215
--- /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(target: object, key: string): unknown {
+ return proxiedTypedArrayMethods[key] || target[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..3847fb6 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-TnjrVURwBr0zoybWnQTulkNvVJOxMzU8IiieCsvgIVSwz7wLGA1EKcFZjY2YuDEwznxbkMitJpn4dsoy9KLqDw==",
+ "dist/cdn/object-observer.js.map": "sha512-LaT8o/h0SZFtigRnjmzr0sNnr/oc9lO0TFYOD15aOWNKIHLGVkvhqtT6yHvVY1ooVlwbSzaskl+mCkxRom/0Yw==",
+ "dist/cdn/object-observer.min.js": "sha512-jCjYwEoBCiXdQiwkvz6WJWc/e8gxf7ER2wIHOB1Fqf1L369D4z/KWsDgbBJgJ7efKthGHRb3fhZjRlRVTdKvcg==",
+ "dist/cdn/object-observer.min.js.map": "sha512-2u7DLjdVHcTVSqIQMSkwy25HHW2bI1YsqMg/Btv2lQJcnc677Cs6OaDcCXcXIASqxms8JvtHPunepABFr9XKJw=="
}
\ 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..4b7d243
--- /dev/null
+++ b/tests/filters.js
@@ -0,0 +1,84 @@
+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');
+});
+
+test('directChildrenOf filter - positive cases', () => {
+ const f = Filter.directChildrenOf('a');
+ const changes = [
+ new Change('update', ['a'], 1, 0),
+ new Change('update', ['a', 'b'], 2, 0),
+ new Change('update', ['a', 'b', 'c'], 3, 0),
+ new Change('update', ['a', 'c'], 4, 0),
+ new Change('update', ['aX', 'b'], 5, 0)
+ ];
+ const filtered = f.fn(changes);
+ assert.strictEqual(filtered.length, 2);
+});
+
+test('directChildrenOf filter - empty path = root', () => {
+ const f = Filter.directChildrenOf('');
+ const changes = [
+ new Change('update', ['a'], 1, 0),
+ new Change('update', ['a', 'b'], 2, 0),
+ new Change('reverse', [], undefined, undefined),
+ new Change('shuffle', [], undefined, undefined)
+ ];
+ const filtered = f.fn(changes);
+ assert.strictEqual(filtered.length, 3);
+});
+
+test('directChildrenOf filter - negative cases', () => {
+ assert.throws(() => Filter.directChildrenOf(null), 'directChildrenOf Filter requires a string as argument (MAY be empty)');
+ assert.throws(() => Filter.directChildrenOf(123), 'directChildrenOf Filter requires a string as argument (MAY be empty)');
+});
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..f3fff4c 100644
--- a/tests/observe-specific-paths.js
+++ b/tests/observe-specific-paths.js
@@ -1,71 +1,39 @@
import { test } from '@gullerya/just-test';
import { assert } from '@gullerya/just-test/assert';
-import { Observable } from '../src/object-observer.js';
+import { Observable, Filter } from '../src/object-observer.ts';
-test('baseline - negative - path not a string', () => {
+test('baseline - negative - filters not an array', () => {
const oo = Observable.from({});
assert.throws(
- () => Observable.observe(oo, () => { }, { path: 4 }),
- '"path" option, if/when provided, MUST be a non-empty string'
+ () => Observable.observe(oo, () => { }, { filters: 'nope' }),
+ '"filters" option, if/when provided, MUST be a non-empty array of Filter instances'
);
});
-test('baseline - negative - path empty', () => {
+test('baseline - negative - filters empty array', () => {
const oo = Observable.from({});
assert.throws(
- () => Observable.observe(oo, () => { }, { path: '' }),
- '"path" option, if/when provided, MUST be a non-empty string'
+ () => Observable.observe(oo, () => { }, { filters: [] }),
+ '"filters" option, if/when provided, MUST be a non-empty array of Filter instances'
);
});
-test('baseline - negative - pathsFrom not a string', () => {
+test('baseline - negative - filters contains non-Filter element', () => {
const oo = Observable.from({});
assert.throws(
- () => Observable.observe(oo, () => { }, { pathsFrom: 4 }),
- '"pathsFrom" option, if/when provided, MUST be a non-empty string'
+ () => Observable.observe(oo, () => { }, { filters: [Filter.exactPaths(['a']), 'bad'] }),
+ '"filters" option, if/when provided, MUST be a non-empty array of Filter instances'
);
});
-test('baseline - negative - pathsFrom empty', () => {
+test('baseline - negative - foreign option', () => {
const oo = Observable.from({});
assert.throws(
- () => Observable.observe(oo, () => { }, { pathsFrom: '' }),
- '"pathsFrom" option, if/when provided, MUST be a non-empty string'
- );
-});
-
-test('baseline - negative - no pathsFrom when path present', () => {
- const oo = Observable.from({});
- assert.throws(
- () => Observable.observe(oo, () => { }, { path: 'some', pathsFrom: 'else' }),
- '"pathsFrom" option MAY NOT be specified together with'
- );
-});
-
-test('baseline - negative - no foreign options (pathFrom)', () => {
- const oo = Observable.from({});
- assert.throws(
- () => Observable.observe(oo, () => { }, { pathFrom: 'something' }),
+ () => Observable.observe(oo, () => { }, { somethingElse: 'x' }),
'is/are not a valid observer option/s'
);
});
-test('observe paths of - negative a', () => {
- const oo = Observable.from({});
- assert.throws(
- () => Observable.observe(oo, () => { }, { pathsOf: 4 }),
- '"pathsOf" option, if/when provided, MUST be a string (MAY be empty)'
- );
-});
-
-test('observe paths of - negative b', () => {
- const oo = Observable.from({});
- assert.throws(
- () => Observable.observe(oo, () => { }, { path: 'inner.prop', pathsOf: 'some.thing' }),
- '"pathsOf" option MAY NOT be specified together with "path" option'
- );
-});
-
test('baseline - no options / empty options', () => {
const
oo = Observable.from({ inner: { prop: 'more' } }),
@@ -92,7 +60,7 @@ test('baseline - empty options is valid', () => {
Observable.unobserve(oo, observer);
});
-test('observe specific path', () => {
+test('exactPaths filter - single path', () => {
const oo = Observable.from({ inner: { prop: 'more' } });
let callbackCalls = 0,
changesCounter = 0;
@@ -100,7 +68,7 @@ test('observe specific path', () => {
Observable.observe(oo, changes => {
callbackCalls++;
changesCounter += changes.length;
- }, { path: 'inner' });
+ }, { filters: [Filter.exactPaths(['inner'])] });
oo.newProp = 'non-relevant';
oo.inner.other = 'non-relevant';
@@ -110,11 +78,11 @@ test('observe specific path', () => {
assert.strictEqual(callbackCalls, 1);
});
-test('observe paths from .. and deeper', () => {
+test('pathsStartWith filter - path and deeper', () => {
const oo = Observable.from({ inner: { prop: 'more', nested: { text: 'text' } } });
let counter = 0;
- Observable.observe(oo, changes => { counter += changes.length; }, { pathsFrom: 'inner.prop' });
+ Observable.observe(oo, changes => { counter += changes.length; }, { filters: [Filter.pathsStartWith('inner.prop')] });
oo.nonRelevant = 'non-relevant';
oo.inner.also = 'non-relevant';
oo.inner.prop = 'relevant';
@@ -123,10 +91,10 @@ test('observe paths from .. and deeper', () => {
assert.strictEqual(counter, 3);
});
-test('observe paths of - inner case', () => {
+test('directChildrenOf filter - inner case', () => {
const oo = Observable.from({ inner: { prop: 'more', nested: { text: 'text' } } });
let counter = 0;
- Observable.observe(oo, changes => { counter += changes.length; }, { pathsOf: 'inner.nested' });
+ Observable.observe(oo, changes => { counter += changes.length; }, { filters: [Filter.directChildrenOf('inner.nested')] });
oo.nonRelevant = 'non-relevant';
oo.inner.also = 'non-relevant';
oo.inner.nested.text = 'relevant';
@@ -136,10 +104,10 @@ test('observe paths of - inner case', () => {
assert.strictEqual(counter, 2);
});
-test('observe paths of - array - property of same depth updated', () => {
+test('directChildrenOf filter - array - property of same depth updated', () => {
const oo = Observable.from({ array: [1, 2, 3], prop: { inner: 'value' } });
let counter = 0;
- Observable.observe(oo, changes => { counter += changes.length; }, { pathsOf: 'array' });
+ Observable.observe(oo, changes => { counter += changes.length; }, { filters: [Filter.directChildrenOf('array')] });
oo.nonRelevant = 'non-relevant';
oo.prop.inner = 'non-relevant';
oo.prop = { newObj: { test: 'non-relevant' } };
@@ -149,10 +117,10 @@ test('observe paths of - array - property of same depth updated', () => {
assert.equal(counter, 2);
});
-test('observe paths of - root case', () => {
+test('directChildrenOf filter - root case', () => {
const oo = Observable.from({ inner: { prop: 'more', nested: { text: 'text' } } });
let counter = 0;
- Observable.observe(oo, changes => { counter += changes.length; }, { pathsOf: '' });
+ Observable.observe(oo, changes => { counter += changes.length; }, { filters: [Filter.directChildrenOf('')] });
oo.relevant = 'relevant';
oo.inner.also = 'non-relevant';
oo.inner = { newObj: { test: 'relevant' } };
@@ -160,20 +128,58 @@ test('observe paths of - root case', () => {
assert.equal(counter, 2);
});
-test('observe paths of - root case - array sort', () => {
+test('directChildrenOf filter - root case - array sort', () => {
const oo = Observable.from([1, 3, 2, 4, 9]);
let counter = 0;
- Observable.observe(oo, changes => { counter += changes.length; }, { pathsOf: '' });
+ Observable.observe(oo, changes => { counter += changes.length; }, { filters: [Filter.directChildrenOf('')] });
oo.sort();
assert.isTrue(oo[0] === 1 && oo[1] === 2 && oo[2] === 3 && oo[3] === 4 && oo[4] === 9);
assert.equal(counter, 1);
});
-test('observe paths of - root case - array reverse', () => {
+test('directChildrenOf filter - root case - array reverse', () => {
const oo = Observable.from([1, 2, 3, 4, 9]);
let counter = 0;
- Observable.observe(oo, changes => { counter += changes.length; }, { pathsOf: '' });
+ Observable.observe(oo, changes => { counter += changes.length; }, { filters: [Filter.directChildrenOf('')] });
oo.reverse();
assert.isTrue(oo[0] === 9 && oo[1] === 4 && oo[2] === 3 && oo[3] === 2 && oo[4] === 1);
assert.equal(counter, 1);
});
+
+test('multiple filters compose as AND', () => {
+ const oo = Observable.from({ a: { x: 1, y: 2 }, b: { x: 3 } });
+ let counter = 0;
+ // direct children of 'a' AND under 'a.x' → only 'a.x' changes
+ Observable.observe(
+ oo,
+ changes => { counter += changes.length; },
+ { filters: [Filter.directChildrenOf('a'), Filter.pathsStartWith('a.x')] }
+ );
+ oo.a.x = 10; // kept
+ oo.a.y = 20; // dropped (not under a.x)
+ oo.b.x = 30; // dropped (not direct child of a)
+ assert.strictEqual(counter, 1);
+});
+
+test('directChildrenOf filter - sibling-prefix is not a child', () => {
+ // regression guard: path 'innerX.foo' should NOT be a direct child of 'inner'
+ const oo = Observable.from({ inner: { a: 1 }, innerX: { a: 1 } });
+ let counter = 0;
+ Observable.observe(oo, changes => { counter += changes.length; }, { filters: [Filter.directChildrenOf('inner')] });
+ oo.innerX.a = 2; // must NOT match
+ oo.inner.a = 2; // must match
+ assert.strictEqual(counter, 1);
+});
+
+test('custom filter via Filter.custom', () => {
+ const oo = Observable.from({ a: 1, b: 2 });
+ let counter = 0;
+ Observable.observe(
+ oo,
+ changes => { counter += changes.length; },
+ { filters: [Filter.custom(cs => cs.filter(c => c.value > 10))] }
+ );
+ oo.a = 5; // dropped
+ oo.b = 20; // kept
+ assert.strictEqual(counter, 1);
+});
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/validators.js b/tests/validators.js
new file mode 100644
index 0000000..3a251bd
--- /dev/null
+++ b/tests/validators.js
@@ -0,0 +1,277 @@
+import { test } from '@gullerya/just-test';
+import { assert } from '@gullerya/just-test/assert';
+import { Observable, Validator } from '../src/object-observer.ts';
+import { Change } from '../src/model/change.ts';
+import { oMetaKey } from '../src/constants.ts';
+
+test('test validators - ctor direct use forbidden', () => {
+ assert.throws(() => new Validator('some', () => { }), 'Validator class cannot be instantiated directly');
+});
+
+test('custom validator - positive cases', () => {
+ const validatorLogic = () => { };
+ const v = Validator.custom(validatorLogic);
+ assert.strictEqual(v.validate, validatorLogic, 'Validator code is correct');
+});
+
+test('custom validator - negative cases', () => {
+ assert.throws(() => Validator.custom(null), 'custom Validator requires a function as argument');
+ assert.throws(() => Validator.custom('some'), 'custom Validator requires a function as argument');
+});
+
+test('custom validator - validate invokes passed fn - success path (no throw)', () => {
+ let called = 0;
+ const v = Validator.custom(changes => { called = changes.length; });
+ const changes = [new Change('update', ['a'], 1, 0), new Change('update', ['b'], 2, 0)];
+ v.validate(changes);
+ assert.strictEqual(called, 2);
+});
+
+test('custom validator - validate propagates throw', () => {
+ const v = Validator.custom(() => { throw new Error('rejected'); });
+ assert.throws(() => v.validate([new Change('update', ['a'], 1, 0)]), 'rejected');
+});
+
+test('custom validator - validate is read-only', () => {
+ const v = Validator.custom(() => { });
+ assert.throws(() => { v.validate = () => { }; });
+});
+
+test('Observable.from - validators option - negative - not an array', () => {
+ assert.throws(
+ () => Observable.from({}, { validators: 'nope' }),
+ '"validators" option, if/when provided, MUST be a non-empty array of Validator instances'
+ );
+});
+
+test('Observable.from - validators option - negative - number', () => {
+ assert.throws(
+ () => Observable.from({}, { validators: 42 }),
+ '"validators" option, if/when provided, MUST be a non-empty array of Validator instances'
+ );
+});
+
+test('Observable.from - validators option - negative - empty array', () => {
+ assert.throws(
+ () => Observable.from({}, { validators: [] }),
+ '"validators" option, if/when provided, MUST be a non-empty array of Validator instances'
+ );
+});
+
+test('Observable.from - validators option - negative - non-Validator element', () => {
+ assert.throws(
+ () => Observable.from({}, { validators: [Validator.custom(() => { }), 'bad'] }),
+ '"validators" option, if/when provided, MUST be a non-empty array of Validator instances'
+ );
+});
+
+test('Observable.from - validators option - negative - bare function rejected', () => {
+ assert.throws(
+ () => Observable.from({}, { validators: [() => { }] }),
+ '"validators" option, if/when provided, MUST be a non-empty array of Validator instances'
+ );
+});
+
+test('Observable.from - validators option - positive - stored on meta', () => {
+ const v1 = Validator.custom(() => { });
+ const v2 = Validator.custom(() => { });
+ const oo = Observable.from({}, { validators: [v1, v2] });
+ const stored = oo[oMetaKey].validators;
+ assert.strictEqual(stored.length, 2);
+ assert.strictEqual(stored[0], v1);
+ assert.strictEqual(stored[1], v2);
+});
+
+// e2e: a single "immutable-marker" validator rejecting any change whose oldValue === 'immutable'
+const immutableMarker = Validator.custom(changes => {
+ for (const c of changes) {
+ if (c.oldValue === 'immutable') {
+ throw new Error(`change at '${c.pathAsString}' rejected: oldValue is immutable`);
+ }
+ }
+});
+
+test('e2e - set insert - no oldValue - allowed', () => {
+ const oo = Observable.from({}, { validators: [immutableMarker] });
+ let observed = 0;
+ Observable.observe(oo, changes => { observed += changes.length; });
+ oo.a = 'new';
+ assert.strictEqual(oo.a, 'new');
+ assert.strictEqual(observed, 1);
+});
+
+test('e2e - set update - oldValue not immutable - allowed', () => {
+ const oo = Observable.from({ a: 'mutable' }, { validators: [immutableMarker] });
+ let observed = 0;
+ Observable.observe(oo, changes => { observed += changes.length; });
+ oo.a = 'other';
+ assert.strictEqual(oo.a, 'other');
+ assert.strictEqual(observed, 1);
+});
+
+test('e2e - set update - oldValue is immutable - throws, target unchanged, no observer fired', () => {
+ const oo = Observable.from({ a: 'immutable' }, { validators: [immutableMarker] });
+ let observed = 0;
+ Observable.observe(oo, changes => { observed += changes.length; });
+ assert.throws(() => { oo.a = 'other'; }, `change at 'a' rejected`);
+ assert.strictEqual(oo.a, 'immutable');
+ assert.strictEqual(observed, 0);
+});
+
+test('e2e - delete - oldValue not immutable - allowed', () => {
+ const oo = Observable.from({ a: 'mutable' }, { validators: [immutableMarker] });
+ let observed = 0;
+ Observable.observe(oo, changes => { observed += changes.length; });
+ delete oo.a;
+ assert.isTrue(!('a' in oo));
+ assert.strictEqual(observed, 1);
+});
+
+test('e2e - delete - oldValue is immutable - throws, target unchanged, no observer fired', () => {
+ const oo = Observable.from({ a: 'immutable' }, { validators: [immutableMarker] });
+ let observed = 0;
+ Observable.observe(oo, changes => { observed += changes.length; });
+ assert.throws(() => { delete oo.a; }, `change at 'a' rejected`);
+ assert.strictEqual(oo.a, 'immutable');
+ assert.strictEqual(observed, 0);
+});
+
+test('e2e - change shape at validator - set insert', () => {
+ let seen = null;
+ const oo = Observable.from({}, {
+ validators: [Validator.custom(changes => { seen = changes; })]
+ });
+ oo.a = 42;
+ assert.strictEqual(seen.length, 1);
+ const c = seen[0];
+ assert.strictEqual(c.type, 'insert');
+ assert.deepStrictEqual(c.path, ['a']);
+ assert.strictEqual(c.value, 42);
+ assert.strictEqual(c.oldValue, undefined);
+ assert.strictEqual(c.object, oo);
+});
+
+test('e2e - change shape at validator - set update', () => {
+ let seen = null;
+ const oo = Observable.from({ a: 1 }, {
+ validators: [Validator.custom(changes => { seen = changes; })]
+ });
+ oo.a = 2;
+ assert.strictEqual(seen.length, 1);
+ const c = seen[0];
+ assert.strictEqual(c.type, 'update');
+ assert.deepStrictEqual(c.path, ['a']);
+ assert.strictEqual(c.value, 2);
+ assert.strictEqual(c.oldValue, 1);
+ assert.strictEqual(c.object, oo);
+});
+
+test('e2e - change shape at validator - delete', () => {
+ let seen = null;
+ const oo = Observable.from({ a: 1 }, {
+ validators: [Validator.custom(changes => { seen = changes; })]
+ });
+ delete oo.a;
+ assert.strictEqual(seen.length, 1);
+ const c = seen[0];
+ assert.strictEqual(c.type, 'delete');
+ assert.deepStrictEqual(c.path, ['a']);
+ assert.strictEqual(c.value, undefined);
+ assert.strictEqual(c.oldValue, 1);
+ assert.strictEqual(c.object, oo);
+});
+
+test('e2e - rooted path - mutation on nested observable - validator sees full path', () => {
+ let seen = null;
+ const oo = Observable.from({ outer: { inner: { x: 1 } } }, {
+ validators: [Validator.custom(changes => { seen = changes; })]
+ });
+ oo.outer.inner.x = 2;
+ assert.strictEqual(seen.length, 1);
+ assert.deepStrictEqual(seen[0].path, ['outer', 'inner', 'x']);
+});
+
+test('e2e - raw (un-wrapped) new value at validator', () => {
+ const raw = { k: 'v' };
+ let seen = null;
+ const oo = Observable.from({}, {
+ validators: [Validator.custom(changes => { seen = changes; })]
+ });
+ oo.a = raw;
+ assert.strictEqual(seen[0].value, raw); // exact same object identity, no proxy wrapping yet
+});
+
+test('e2e - multiple validators - first throws, second not called', () => {
+ let v2Calls = 0;
+ const v1 = Validator.custom(() => { throw new Error('v1 reject'); });
+ const v2 = Validator.custom(() => { v2Calls++; });
+ const oo = Observable.from({}, { validators: [v1, v2] });
+ assert.throws(() => { oo.a = 1; }, 'v1 reject');
+ assert.strictEqual(v2Calls, 0);
+});
+
+// vetoing validator used across the array-method e2e tests below
+const vetoAll = Validator.custom(() => { throw new Error('rejected'); });
+
+test('e2e - array pop - vetoed - target unchanged, no observer fired', () => {
+ const oo = Observable.from([1, 2, 3], { validators: [vetoAll] });
+ let observed = 0;
+ Observable.observe(oo, changes => { observed += changes.length; });
+ assert.throws(() => oo.pop(), 'rejected');
+ assert.deepStrictEqual(Array.from(oo), [1, 2, 3]);
+ assert.strictEqual(observed, 0);
+});
+
+test('e2e - array push - vetoed - target unchanged, no observer fired', () => {
+ const oo = Observable.from([1, 2, 3], { validators: [vetoAll] });
+ let observed = 0;
+ Observable.observe(oo, changes => { observed += changes.length; });
+ assert.throws(() => oo.push(4, 5), 'rejected');
+ assert.deepStrictEqual(Array.from(oo), [1, 2, 3]);
+ assert.strictEqual(observed, 0);
+});
+
+test('e2e - array shift - vetoed - target unchanged, no observer fired', () => {
+ const oo = Observable.from([1, 2, 3], { validators: [vetoAll] });
+ let observed = 0;
+ Observable.observe(oo, changes => { observed += changes.length; });
+ assert.throws(() => oo.shift(), 'rejected');
+ assert.deepStrictEqual(Array.from(oo), [1, 2, 3]);
+ assert.strictEqual(observed, 0);
+});
+
+test('e2e - array unshift - vetoed - target unchanged, no observer fired', () => {
+ const oo = Observable.from([1, 2, 3], { validators: [vetoAll] });
+ let observed = 0;
+ Observable.observe(oo, changes => { observed += changes.length; });
+ assert.throws(() => oo.unshift(0), 'rejected');
+ assert.deepStrictEqual(Array.from(oo), [1, 2, 3]);
+ assert.strictEqual(observed, 0);
+});
+
+test('e2e - array reverse - vetoed - target unchanged, no observer fired', () => {
+ const oo = Observable.from([1, 2, 3], { validators: [vetoAll] });
+ let observed = 0;
+ Observable.observe(oo, changes => { observed += changes.length; });
+ assert.throws(() => oo.reverse(), 'rejected');
+ assert.deepStrictEqual(Array.from(oo), [1, 2, 3]);
+ assert.strictEqual(observed, 0);
+});
+
+test('e2e - array sort - vetoed - target unchanged, no observer fired', () => {
+ const oo = Observable.from([3, 1, 2], { validators: [vetoAll] });
+ let observed = 0;
+ Observable.observe(oo, changes => { observed += changes.length; });
+ assert.throws(() => oo.sort(), 'rejected');
+ assert.deepStrictEqual(Array.from(oo), [3, 1, 2]);
+ assert.strictEqual(observed, 0);
+});
+
+test('e2e - typed-array set - vetoed - target unchanged, no observer fired', () => {
+ const oo = Observable.from(new Int32Array([1, 2, 3, 4]), { validators: [vetoAll] });
+ let observed = 0;
+ Observable.observe(oo, changes => { observed += changes.length; });
+ assert.throws(() => oo.set([10, 20], 1), 'rejected');
+ assert.deepStrictEqual(oo, new Int32Array([1, 2, 3, 4]));
+ assert.strictEqual(observed, 0);
+});
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