Browse Source

chore: stream large lists diff (#26)

* chore: stream large lists diff

* chore: remove useless dev dependencies + folder structure

* chore: handle edgecases streamlistsdiff

* chore: handle errors

* chore: test stream-list-diff

* chore: improve memory usage + add tests

* chore: update readme

* chore: update package.json
pull/27/head
DoneDeal0 7 months ago committed by GitHub
parent
commit
8ffb9ea547
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 526
      README.md
  2. 107
      dist/index.d.mts
  3. 107
      dist/index.d.ts
  4. 11
      dist/index.js
  5. 3
      dist/index.mjs
  6. 20
      jest.config.js
  7. 2589
      package-lock.json
  8. 33
      package.json
  9. 8
      src/index.ts
  10. 4
      src/lib/list-diff/index.ts
  11. 4
      src/lib/list-diff/list-diff.test.ts
  12. 6
      src/lib/object-diff/index.ts
  13. 4
      src/lib/object-diff/object-diff.test.ts
  14. 46
      src/lib/stream-list-diff/emitter.ts
  15. 327
      src/lib/stream-list-diff/index.ts
  16. 867
      src/lib/stream-list-diff/stream-list-diff.test.ts
  17. 2
      src/lib/utils/index.ts
  18. 2
      src/lib/utils/utils.test.ts
  19. 4
      src/models/list/index.ts
  20. 0
      src/models/object/index.ts
  21. 27
      src/models/stream/index.ts
  22. 0
      src/models/utils/index.ts
  23. 11
      tsconfig.json

526
README.md

@ -18,7 +18,9 @@ This library compares two arrays or objects and returns a full diff of their dif @@ -18,7 +18,9 @@ This library compares two arrays or objects and returns a full diff of their dif
All other existing solutions return a strange diff format that often requires additional parsing. They are also limited to object comparison.
**Superdiff** gives you a complete diff for both array <u>and</u> objects in a very readable format. Last but not least, it's battle-tested and super fast. Import. Enjoy. 👍
**Superdiff** gives you a complete diff for both array <u>and</u> objects in a very readable format. Last but not least, it's battle-tested, has zero dependencies, and is super fast.
Import. Enjoy. 👍
<hr/>
@ -38,7 +40,25 @@ I am grateful to the generous donors of **Superdiff**! @@ -38,7 +40,25 @@ I am grateful to the generous donors of **Superdiff**!
## FEATURES
**Superdiff** exports 4 functions:
**Superdiff** exports 5 functions:
```ts
// Returns a complete diff of two objects
getObjectDiff(prevObject, nextObject)
// Returns a complete diff of two arrays
getListDiff(prevList, nextList)
// Streams the diff of two object lists, ideal for large lists and maximum performance
streamListDiff(prevList, nextList, referenceProperty)
// Checks whether two values are equal
isEqual(dataA, dataB)
// Checks whether a value is an object
isObject(data)
```
<hr/>
### getObjectDiff()
@ -46,58 +66,139 @@ I am grateful to the generous donors of **Superdiff**! @@ -46,58 +66,139 @@ I am grateful to the generous donors of **Superdiff**!
import { getObjectDiff } from "@donedeal0/superdiff";
```
Compares two objects and return a diff for each value and their potential subvalues:
Compares two objects and return a diff for each value and their potential subvalues. Supports deeply nested objects with any kind of values.
#### FORMAT
**Input**
```ts
prevData: Record<string, unknown>;
nextData: Record<string, unknown>;
options?: {
ignoreArrayOrder?: boolean, // false by default,
showOnly?: {
statuses: ("added" | "deleted" | "updated" | "equal")[], // [] by default
granularity?: "basic" | "deep" // "basic" by default
}
}
```
- `prevData`: the original object.
- `nextData`: the new object.
- `options`
- `ignoreArrayOrder`: if set to `true`, `["hello", "world"]` and `["world", "hello"]` will be treated as `equal`, because the two arrays have the same value, just not in the same order.
- `showOnly`: returns only the values whose status you are interested in. It takes two parameters:
- property name
- status: `added`, `deleted`, `equal`, `updated`
- previous value, current value
- supports deeply nested objects with any kind of values
- `statuses`: status you want to see in the output (e.g. `["added", "equal"]`)
- `granularity`:
- `basic` returns only the main properties whose status matches your query.
- `deep` can return main properties if some of their subproperties' status match your request. The subproperties are filtered accordingly.
format:
**Output**
```ts
type ObjectDiff = {
type: "object";
status: "added" | "deleted" | "equal" | "updated";
diff: {
property: string;
previousValue: unknown;
currentValue: unknow;
status: "added" | "deleted" | "equal" | "updated";
// only appears if some subproperties have been added/deleted/updated
diff?: {
property: string;
previousValue: unknown;
currentValue: unknown;
status: "added" | "deleted" | "equal" | "updated";
// recursive diff in case of subproperties
diff?: SubDiff[];
}[];
}[];
diff: Diff[];
};
```
**Options**
/** recursive diff in case of subproperties */
type Diff = {
property: string;
previousValue: unknown;
currentValue: unknown;
status: "added" | "deleted" | "equal" | "updated";
diff?: Diff[];
};
```
#### USAGE
You can add a third `options` parameter to `getObjectDiff`.
**Input**
```ts
{
ignoreArrayOrder?: boolean // false by default,
showOnly?: {
statuses: ("added" | "deleted" | "updated" | "equal")[], // [] by default
granularity?: "basic" | "deep" // "basic" by default
```diff
getObjectDiff(
{
id: 54,
user: {
name: "joe",
- member: true,
- hobbies: ["golf", "football"],
age: 66,
},
},
{
id: 54,
user: {
name: "joe",
+ member: false,
+ hobbies: ["golf", "chess"],
age: 66,
},
}
}
);
```
- `ignoreArrayOrder`: if set to `true`, `["hello", "world"]` and `["world", "hello"]` will be treated as `equal`, because the two arrays have the same value, just not in the same order.
- `showOnly`: returns only the values whose status you are interested in. It takes two parameters:
**Output**
- `statuses`: status you want to see in the output (e.g. `["added", "equal"]`)
- `granularity`:
- `basic` returns only the main properties whose status matches your query.
- `deep` can return main properties if some of their subproperties' status match your request. The subproperties are filtered accordingly.
```diff
{
type: "object",
+ status: "updated",
diff: [
{
property: "id",
previousValue: 54,
currentValue: 54,
status: "equal",
},
{
property: "user",
previousValue: {
name: "joe",
member: true,
hobbies: ["golf", "football"],
age: 66,
},
currentValue: {
name: "joe",
member: false,
hobbies: ["golf", "chess"],
age: 66,
},
+ status: "updated",
diff: [
{
property: "name",
previousValue: "joe",
currentValue: "joe",
status: "equal",
},
+ {
+ property: "member",
+ previousValue: true,
+ currentValue: false,
+ status: "updated",
+ },
+ {
+ property: "hobbies",
+ previousValue: ["golf", "football"],
+ currentValue: ["golf", "chess"],
+ status: "updated",
+ },
{
property: "age",
previousValue: 66,
currentValue: 66,
status: "equal",
},
],
},
],
}
```
<hr/>
### getListDiff()
@ -105,15 +206,31 @@ You can add a third `options` parameter to `getObjectDiff`. @@ -105,15 +206,31 @@ You can add a third `options` parameter to `getObjectDiff`.
import { getListDiff } from "@donedeal0/superdiff";
```
Compares two arrays and returns a diff for each value:
Compares two arrays and returns a diff for each entry. Supports duplicate values, primitive values and objects.
- index change: `prevIndex`, `newIndex`, `indexDiff`
- status: `added`, `deleted`, `equal`, `moved`, `updated`
- value
- supports arrays of primitive values and objects
- supports arrays with duplicate values
#### FORMAT
**Input**
```ts
prevList: T[];
nextList: T[];
options?: {
showOnly?: ("added" | "deleted" | "moved" | "updated" | "equal")[], // [] by default
referenceProperty?: string, // "" by default
ignoreArrayOrder?: boolean, // false by default,
considerMoveAsUpdate?: boolean // false by default
}
```
- `prevList`: the original list.
- `nextList`: the new list.
- `options`
- `showOnly` gives you the option to return only the values whose status you are interested in (e.g. `["added", "equal"]`).
- `referenceProperty` will consider an object to be updated instead of added or deleted if one of its properties remains stable, such as its `id`. This option has no effect on other datatypes.
- `ignoreArrayOrder`: if set to `true`, `["hello", "world"]` and `["world", "hello"]` will be treated as `equal`, because the two arrays have the same value, just not in the same order.
- `considerMoveAsUpdate`: if set to `true` a `moved` value will be considered as `updated`.
format:
**Output**
```ts
type ListDiff = {
@ -128,58 +245,9 @@ type ListDiff = { @@ -128,58 +245,9 @@ type ListDiff = {
}[];
};
```
#### USAGE
**Options**
You can add a third `options` parameter to `getListDiff`.
```ts
{
showOnly?: ("added" | "deleted" | "moved" | "updated" | "equal")[], // [] by default
referenceProperty?: string; // "" by default
ignoreArrayOrder?: boolean // false by default,
}
```
- `showOnly` gives you the option to return only the values whose status you are interested in (e.g. `["added", "equal"]`).
- `referenceProperty` will consider an object to be updated instead of added or deleted if one of its properties remains stable, such as its `id`. This option has no effect on other datatypes.
- `ignoreArrayOrder`: if set to `true`, `["hello", "world"]` and `["world", "hello"]` will be treated as `equal`, because the two arrays have the same value, just not in the same order.
### isEqual()
```js
import { isEqual } from "@donedeal0/superdiff";
```
Tests whether two values are equal.
**Options**
You can add a third `options` parameter to `isEqual`.
```ts
{
ignoreArrayOrder?: boolean // false by default,
}
```
- `ignoreArrayOrder`: if set to `true`, `["hello", "world"]` and `["world", "hello"]` will be treated as `equal`, because the two arrays have the same value, just not in the same order.
### isObject()
```js
import { isObject } from "@donedeal0/superdiff";
```
Tests whether a value is an object.
<hr/>
## EXAMPLES
### getListDiff()
input
**Input**
```diff
getListDiff(
@ -188,7 +256,7 @@ getListDiff( @@ -188,7 +256,7 @@ getListDiff(
);
```
output
**Output**
```diff
{
@ -233,96 +301,168 @@ output @@ -233,96 +301,168 @@ output
],
}
```
<hr/>
### streamListDiff()
```js
import { streamListDiff } from "@donedeal0/superdiff";
```
Streams the diff of two object lists, ideal for large lists and maximum performance.
#### FORMAT
**Input**
```ts
prevList: Record<string, unknown>[],
nextList: Record<string, unknown>[],
referenceProperty: keyof Record<string, unknown>,
options: {
showOnly?: ("added" | "deleted" | "moved" | "updated" | "equal")[], // [] by default
chunksSize?: number, // // 0 by default
considerMoveAsUpdate? boolean; // false by default
}
```
- `prevList`: the original object list.
- `nextList`: the new object list.
- `referenceProperty`: a common property in all the objects of your lists (e.g. `id`).
- `options`
- `chunksSize` the number of object diffs returned by each streamed chunk. (e.g. `0` = 1 object diff by chunk, `10` = 10 object diffs by chunk).
- `showOnly` gives you the option to return only the values whose status you are interested in (e.g. `["added", "equal"]`).
- `considerMoveAsUpdate`: if set to `true` a `moved` value will be considered as `updated`.
**Output**
The objects diff are grouped in arrays - called `chunks` - and are consumed thanks to an event listener. You have access to 3 events:
- `data`: to be notified when a new chunk of object diffs is available.
- `finish`: to be notified when the stream is complete.
- `error`: to be notified of an error during the stream.
```ts
interface StreamListener<T extends Record<string, unknown>> {
on<E extends keyof EmitterEvents<T>>(
event: E,
listener: Listener<EmitterEvents<T>[E]>,
): this;
}
type EmitterEvents<T extends Record<string, unknown>> = {
data: [StreamListDiff<T>[]];
error: [Error];
finish: [];
};
### getObjectDiff()
input
type StreamListDiff<T extends Record<string, unknown>> = {
currentValue: T | null;
previousValue: T | null;
prevIndex: number | null;
newIndex: number | null;
indexDiff: number | null;
status: "added" | "deleted" | "moved" | "updated" | "equal";
};
```
#### USAGE
**Input**
```diff
getObjectDiff(
{
id: 54,
user: {
name: "joe",
- member: true,
- hobbies: ["golf", "football"],
age: 66,
},
},
{
id: 54,
user: {
name: "joe",
+ member: false,
+ hobbies: ["golf", "chess"],
age: 66,
},
}
);
const diff = streamListDiff(
[
- { id: 1, name: "Item 1" },
{ id: 2, name: "Item 2" },
{ id: 3, name: "Item 3" }
],
[
+ { id: 0, name: "Item 0" },
{ id: 2, name: "Item 2" },
+ { id: 3, name: "Item Three" },
],
"id",
{ chunksSize: 2 }
);
```
output
**Output**
```diff
{
type: "object",
+ status: "updated",
diff: [
{
property: "id",
previousValue: 54,
currentValue: 54,
status: "equal",
},
diff.on("data", (chunk) => {
// first chunk received (2 object diffs)
[
+ {
+ previousValue: null,
+ currentValue: { id: 0, name: 'Item 0' },
+ prevIndex: null,
+ newIndex: 0,
+ indexDiff: null,
+ status: 'added'
+ },
- {
- previousValue: { id: 1, name: 'Item 1' },
- currentValue: null,
- prevIndex: 0,
- newIndex: null,
- indexDiff: null,
- status: 'deleted'
- }
]
// second chunk received (2 object diffs)
[
{
property: "user",
previousValue: {
name: "joe",
member: true,
hobbies: ["golf", "football"],
age: 66,
},
currentValue: {
name: "joe",
member: false,
hobbies: ["golf", "chess"],
age: 66,
},
+ status: "updated",
diff: [
{
property: "name",
previousValue: "joe",
currentValue: "joe",
status: "equal",
},
+ {
+ property: "member",
+ previousValue: true,
+ currentValue: false,
+ status: "updated",
+ },
+ {
+ property: "hobbies",
+ previousValue: ["golf", "football"],
+ currentValue: ["golf", "chess"],
+ status: "updated",
+ },
{
property: "age",
previousValue: 66,
currentValue: 66,
status: "equal",
},
],
previousValue: { id: 2, name: 'Item 2' },
currentValue: { id: 2, name: 'Item 2' },
prevIndex: 1,
newIndex: 1,
indexDiff: 0,
status: 'equal'
},
],
}
+ {
+ previousValue: { id: 3, name: 'Item 3' },
+ currentValue: { id: 3, name: 'Item Three' },
+ prevIndex: 2,
+ newIndex: 2,
+ indexDiff: 0,
+ status: 'updated'
+ },
]
});
diff.on("finish", () => console.log("The full diff is available"))
diff.on("error", (err) => console.log(err))
```
<hr/>
### isEqual()
```js
import { isEqual } from "@donedeal0/superdiff";
```
Checks whether two values are equal.
#### FORMAT
**Input**
```ts
a: unknown,
b: unknown,
options: {
ignoreArrayOrder: boolean; // false by default
},
```
- `a`: the value to compare to the value `b`.
- `b`: the value that will be compared to the value `a`.
- `ignoreArrayOrder`: if set to `true`, `["hello", "world"]` and `["world", "hello"]` will be treated as `equal`, because the two arrays have the same value, just not in the same order.
#### USAGE
```ts
isEqual(
[
{ name: "joe", age: 99 },
@ -335,27 +475,49 @@ isEqual( @@ -335,27 +475,49 @@ isEqual(
);
```
output
**Output**
```js
```ts
false;
```
<hr/>
### isObject()
input
```js
import { isObject } from "@donedeal0/superdiff";
```
Tests whether a value is an object.
#### FORMAT
**Input**
```ts
value: unknown;
```
- `value`: the value whose type will be checked.
#### USAGE
**Input**
```ts
isObject(["hello", "world"]);
```
output
**Output**
```js
```ts
false;
```
More examples are available in the source code tests.
<hr/>
### ℹ More examples are available in the source code tests.
<hr/>
@ -365,7 +527,7 @@ DoneDeal0 @@ -365,7 +527,7 @@ DoneDeal0
## SUPPORT
If you or your company uses **Superdiff**, please show your support by becoming a sponsor! Your name and company logo will be displayed on the `README.md`. https://github.com/sponsors/DoneDeal0
If you or your company uses **Superdiff**, please show your support by becoming a sponsor! Your name and company logo will be displayed on the `README.md`. Premium support is also available. https://github.com/sponsors/DoneDeal0
<br/>
<a href="https://github.com/sponsors/DoneDeal0" target="_blank">
@ -375,4 +537,4 @@ If you or your company uses **Superdiff**, please show your support by becoming @@ -375,4 +537,4 @@ If you or your company uses **Superdiff**, please show your support by becoming
## CONTRIBUTING
Pull requests are welcome!
Issues and pull requests are welcome!

107
dist/index.d.mts vendored

@ -1,107 +0,0 @@ @@ -1,107 +0,0 @@
declare const STATUS: Record<string, ObjectDiffStatus>;
declare const LIST_STATUS: Record<string, ListDiffStatus>;
declare const GRANULARITY: Record<string, "basic" | "deep">;
type ListDiffStatus = "added" | "equal" | "moved" | "deleted" | "updated";
type ObjectDiffStatus = "added" | "equal" | "deleted" | "updated";
type ObjectData = Record<string, any> | undefined | null;
type ListData = any;
type ObjectStatusTuple = readonly [
"added",
"equal",
"deleted",
"updated"
];
type ListStatusTuple = readonly [
"added",
"equal",
"deleted",
"moved",
"updated"
];
type isEqualOptions = {
ignoreArrayOrder?: boolean;
};
type ObjectOptions = {
ignoreArrayOrder?: boolean;
showOnly?: {
statuses: Array<ObjectStatusTuple[number]>;
granularity?: (typeof GRANULARITY)[keyof typeof GRANULARITY];
};
};
type ListOptions = {
showOnly?: Array<ListStatusTuple[number]>;
referenceProperty?: string;
considerMoveAsUpdate?: boolean;
ignoreArrayOrder?: boolean;
};
type ListDiff = {
type: "list";
status: ListDiffStatus;
diff: {
value: ListData;
prevIndex: number | null;
newIndex: number | null;
indexDiff: number | null;
status: ListDiffStatus;
}[];
};
type SubProperties = {
property: string;
previousValue: any;
currentValue: any;
status: ObjectDiffStatus;
subPropertiesDiff?: SubProperties[];
};
type ObjectDiff = {
type: "object";
status: ObjectDiffStatus;
diff: {
property: string;
previousValue: any;
currentValue: any;
status: ObjectDiffStatus;
subPropertiesDiff?: SubProperties[];
}[];
};
type DataDiff = ListDiff | ObjectDiff;
/**
* Returns the diff between two objects
* @param {Record<string, any>} prevData - The original object.
* @param {Record<string, any>} nextData - The new object.
* * @param {ListOptions} options - Options to refine your output.
- `showOnly`: returns only the values whose status you are interested in. It takes two parameters: `statuses` and `granularity`
`statuses` are the status you want to see in the output (e.g. `["added", "equal"]`)
`granularity` can be either `basic` (to return only the main properties whose status matches your query) or `deep` (to return the main properties if some of their subproperties' status match your request. The subproperties are filtered accordingly).
- `ignoreArrayOrder` if set to `true`, `["hello", "world"]` and `["world", "hello"]` will be treated as `equal`, because the two arrays have the same value, just not in the same order.
* @returns ObjectDiff
*/
declare function getObjectDiff(prevData: ObjectData, nextData: ObjectData, options?: ObjectOptions): ObjectDiff;
/**
* Returns the diff between two arrays
* @param {Array<T>} prevList - The original array.
* @param {Array<T>} nextList - The new array.
* @param {ListOptions} options - Options to refine your output.
- `showOnly` gives you the option to return only the values whose status you are interested in (e.g. `["added", "equal"]`).
- `referenceProperty` will consider an object to be updated instead of added or deleted if one of its properties remains stable, such as its `id`. This option has no effect on other datatypes.
* @returns ListDiff
*/
declare const getListDiff: <T>(prevList: T[] | undefined | null, nextList: T[] | undefined | null, options?: ListOptions) => ListDiff;
/**
* Returns true if two data are equal
* @param {any} a - The original data.
* @param {any} b - The data to compare.
* @param {isEqualOptions} options - The options to compare the data.
* @returns boolean
*/
declare function isEqual(a: any, b: any, options?: isEqualOptions): boolean;
/**
* Returns true if the provided value is an object
* @param {any} value - The data to check.
* @returns value is Record<string, any>
*/
declare function isObject(value: any): value is Record<string, any>;
export { type DataDiff, GRANULARITY, LIST_STATUS, type ListData, type ListDiff, type ListDiffStatus, type ListOptions, type ListStatusTuple, type ObjectData, type ObjectDiff, type ObjectDiffStatus, type ObjectOptions, type ObjectStatusTuple, STATUS, type SubProperties, getListDiff, getObjectDiff, isEqual, type isEqualOptions, isObject };

107
dist/index.d.ts vendored

@ -1,107 +0,0 @@ @@ -1,107 +0,0 @@
declare const STATUS: Record<string, ObjectDiffStatus>;
declare const LIST_STATUS: Record<string, ListDiffStatus>;
declare const GRANULARITY: Record<string, "basic" | "deep">;
type ListDiffStatus = "added" | "equal" | "moved" | "deleted" | "updated";
type ObjectDiffStatus = "added" | "equal" | "deleted" | "updated";
type ObjectData = Record<string, any> | undefined | null;
type ListData = any;
type ObjectStatusTuple = readonly [
"added",
"equal",
"deleted",
"updated"
];
type ListStatusTuple = readonly [
"added",
"equal",
"deleted",
"moved",
"updated"
];
type isEqualOptions = {
ignoreArrayOrder?: boolean;
};
type ObjectOptions = {
ignoreArrayOrder?: boolean;
showOnly?: {
statuses: Array<ObjectStatusTuple[number]>;
granularity?: (typeof GRANULARITY)[keyof typeof GRANULARITY];
};
};
type ListOptions = {
showOnly?: Array<ListStatusTuple[number]>;
referenceProperty?: string;
considerMoveAsUpdate?: boolean;
ignoreArrayOrder?: boolean;
};
type ListDiff = {
type: "list";
status: ListDiffStatus;
diff: {
value: ListData;
prevIndex: number | null;
newIndex: number | null;
indexDiff: number | null;
status: ListDiffStatus;
}[];
};
type SubProperties = {
property: string;
previousValue: any;
currentValue: any;
status: ObjectDiffStatus;
subPropertiesDiff?: SubProperties[];
};
type ObjectDiff = {
type: "object";
status: ObjectDiffStatus;
diff: {
property: string;
previousValue: any;
currentValue: any;
status: ObjectDiffStatus;
subPropertiesDiff?: SubProperties[];
}[];
};
type DataDiff = ListDiff | ObjectDiff;
/**
* Returns the diff between two objects
* @param {Record<string, any>} prevData - The original object.
* @param {Record<string, any>} nextData - The new object.
* * @param {ListOptions} options - Options to refine your output.
- `showOnly`: returns only the values whose status you are interested in. It takes two parameters: `statuses` and `granularity`
`statuses` are the status you want to see in the output (e.g. `["added", "equal"]`)
`granularity` can be either `basic` (to return only the main properties whose status matches your query) or `deep` (to return the main properties if some of their subproperties' status match your request. The subproperties are filtered accordingly).
- `ignoreArrayOrder` if set to `true`, `["hello", "world"]` and `["world", "hello"]` will be treated as `equal`, because the two arrays have the same value, just not in the same order.
* @returns ObjectDiff
*/
declare function getObjectDiff(prevData: ObjectData, nextData: ObjectData, options?: ObjectOptions): ObjectDiff;
/**
* Returns the diff between two arrays
* @param {Array<T>} prevList - The original array.
* @param {Array<T>} nextList - The new array.
* @param {ListOptions} options - Options to refine your output.
- `showOnly` gives you the option to return only the values whose status you are interested in (e.g. `["added", "equal"]`).
- `referenceProperty` will consider an object to be updated instead of added or deleted if one of its properties remains stable, such as its `id`. This option has no effect on other datatypes.
* @returns ListDiff
*/
declare const getListDiff: <T>(prevList: T[] | undefined | null, nextList: T[] | undefined | null, options?: ListOptions) => ListDiff;
/**
* Returns true if two data are equal
* @param {any} a - The original data.
* @param {any} b - The data to compare.
* @param {isEqualOptions} options - The options to compare the data.
* @returns boolean
*/
declare function isEqual(a: any, b: any, options?: isEqualOptions): boolean;
/**
* Returns true if the provided value is an object
* @param {any} value - The data to check.
* @returns value is Record<string, any>
*/
declare function isObject(value: any): value is Record<string, any>;
export { type DataDiff, GRANULARITY, LIST_STATUS, type ListData, type ListDiff, type ListDiffStatus, type ListOptions, type ListStatusTuple, type ObjectData, type ObjectDiff, type ObjectDiffStatus, type ObjectOptions, type ObjectStatusTuple, STATUS, type SubProperties, getListDiff, getObjectDiff, isEqual, type isEqualOptions, isObject };

11
dist/index.js vendored

@ -1,11 +0,0 @@ @@ -1,11 +0,0 @@
'use strict';
var u={ADDED:"added",EQUAL:"equal",DELETED:"deleted",UPDATED:"updated"},c={...u,MOVED:"moved"},y={BASIC:"basic",DEEP:"deep"};function D(e,t,i={ignoreArrayOrder:!1}){return typeof e!=typeof t?!1:Array.isArray(e)&&Array.isArray(t)?e.length!==t.length?!1:i.ignoreArrayOrder?e.every(f=>t.some(n=>JSON.stringify(n)===JSON.stringify(f))):e.every((f,n)=>JSON.stringify(f)===JSON.stringify(t[n])):typeof e=="object"?JSON.stringify(e)===JSON.stringify(t):e===t}function p(e){return !!e&&typeof e=="object"&&!Array.isArray(e)}function O(e,t={statuses:[],granularity:y.BASIC}){let{statuses:i,granularity:f}=t;return e.reduce((n,r)=>{if(f===y.DEEP&&r.subPropertiesDiff){let s=O(r.subPropertiesDiff,t);if(s.length>0)return [...n,{...r,subPropertiesDiff:s}]}if(f===y.DEEP&&r.subDiff){let s=O(r.subDiff,t);if(s.length>0)return [...n,{...r,subDiff:s}]}return i.includes(r.status)?[...n,r]:n},[])}function E(e){return e.some(t=>t.status!==u.EQUAL)?u.UPDATED:u.EQUAL}function A(e,t,i={ignoreArrayOrder:!1,showOnly:{statuses:[],granularity:y.BASIC}}){if(!e)return {type:"object",status:u.EQUAL,diff:[]};let f=[];return Object.entries(e).forEach(([n,r])=>{if(p(r)){let s=[];return Object.entries(r).forEach(([o,d])=>{s.push({property:o,previousValue:t===u.ADDED?void 0:d,currentValue:t===u.ADDED?d:void 0,status:t});}),f.push({property:n,previousValue:t===u.ADDED?void 0:e[n],currentValue:t===u.ADDED?r:void 0,status:t,subPropertiesDiff:s})}return f.push({property:n,previousValue:t===u.ADDED?void 0:e[n],currentValue:t===u.ADDED?r:void 0,status:t})}),i.showOnly&&i.showOnly.statuses.length>0?{type:"object",status:t,diff:O(f,i.showOnly)}:{type:"object",status:t,diff:f}}function P(e,t,i){if(!e)return;let f=Object.entries(e).find(([n])=>D(n,t,i));return f?f[1]:void 0}function j(e,t,i){return D(e,t,i)?u.EQUAL:u.UPDATED}function U(e){return e.some(t=>t.status!==u.EQUAL)?u.UPDATED:u.EQUAL}function g(e,t){if(!e)return;let i=Object.keys(e),f=Object.keys(t),n=i.filter(r=>!f.includes(r));if(n.length>0)return n.map(r=>({property:r,value:e[r]}))}function S(e,t,i){let f=[],n,r=g(e,t);return r&&r.forEach(s=>{f.push({property:s.property,previousValue:s.value,currentValue:void 0,status:u.DELETED});}),Object.entries(t).forEach(([s,o])=>{let d=P(e,s,i);if(!d)return f.push({property:s,previousValue:d,currentValue:o,status:!e||!(s in e)?u.ADDED:d===o?u.EQUAL:u.UPDATED});if(p(o)){let a=S(d,o,i);a&&a.length>0&&(n=a);}d&&f.push({property:s,previousValue:d,currentValue:o,status:j(d,o,i),...!!n&&{subDiff:n}});}),f}function m(e,t,i={ignoreArrayOrder:!1,showOnly:{statuses:[],granularity:y.BASIC}}){if(!e&&!t)return {type:"object",status:u.EQUAL,diff:[]};if(!e)return A(t,u.ADDED,i);if(!t)return A(e,u.DELETED,i);let f=[];Object.entries(t).forEach(([r,s])=>{let o=e[r];if(!o)return f.push({property:r,previousValue:o,currentValue:s,status:r in e?o===s?u.EQUAL:u.UPDATED:u.ADDED});if(p(s)){let d=S(o,s,i),a=U(d);return f.push({property:r,previousValue:o,currentValue:s,status:a,...a!==u.EQUAL&&{subPropertiesDiff:d}})}return f.push({property:r,previousValue:o,currentValue:s,status:j(o,s,i)})});let n=g(e,t);return n&&n.forEach(r=>{f.push({property:r.property,previousValue:r.value,currentValue:void 0,status:u.DELETED});}),i.showOnly&&i.showOnly.statuses.length>0?{type:"object",status:E(f),diff:O(f,i.showOnly)}:{type:"object",status:E(f),diff:f}}function w(e,t=[]){return e.filter(i=>t?.includes(i.status))}function h(e,t,i={showOnly:[]}){let f=e.map((n,r)=>({value:n,prevIndex:t===c.ADDED?null:r,newIndex:t===c.ADDED?r:null,indexDiff:null,status:t}));return i.showOnly&&i.showOnly.length>0?{type:"list",status:t,diff:f.filter(n=>i.showOnly?.includes(n.status))}:{type:"list",status:t,diff:f}}function L(e){return e.some(t=>t.status!==c.EQUAL)?c.UPDATED:c.EQUAL}function T(e,t){return p(e)&&t?Object.hasOwn(e,t):!1}var I=(e,t,i={showOnly:[],referenceProperty:void 0,considerMoveAsUpdate:!1,ignoreArrayOrder:!1})=>{if(!e&&!t)return {type:"list",status:c.EQUAL,diff:[]};if(!e)return h(t,c.ADDED,i);if(!t)return h(e,c.DELETED,i);let f=[],n=[];return t.forEach((r,s)=>{let o=e.findIndex((a,b)=>T(a,i.referenceProperty)?p(r)?D(a[i.referenceProperty],r[i.referenceProperty])&&!n.includes(b):!1:D(a,r)&&!n.includes(b));o>-1&&n.push(o);let d=o===-1?null:s-o;if(d===0||i.ignoreArrayOrder){let a=c.EQUAL;return T(r,i.referenceProperty)&&(D(e[o],r)||(a=c.UPDATED)),f.push({value:r,prevIndex:o,newIndex:s,indexDiff:d,status:a})}return o===-1?f.push({value:r,prevIndex:null,newIndex:s,indexDiff:d,status:c.ADDED}):f.push({value:r,prevIndex:o,newIndex:s,indexDiff:d,status:i.considerMoveAsUpdate?c.UPDATED:c.MOVED})}),e.forEach((r,s)=>{if(!n.includes(s))return f.splice(s,0,{value:r,prevIndex:s,newIndex:null,indexDiff:null,status:c.DELETED})}),i.showOnly&&i?.showOnly?.length>0?{type:"list",status:L(f),diff:w(f,i.showOnly)}:{type:"list",status:L(f),diff:f}};
exports.GRANULARITY = y;
exports.LIST_STATUS = c;
exports.STATUS = u;
exports.getListDiff = I;
exports.getObjectDiff = m;
exports.isEqual = D;
exports.isObject = p;

3
dist/index.mjs vendored

@ -1,3 +0,0 @@ @@ -1,3 +0,0 @@
var u={ADDED:"added",EQUAL:"equal",DELETED:"deleted",UPDATED:"updated"},c={...u,MOVED:"moved"},y={BASIC:"basic",DEEP:"deep"};function D(e,t,i={ignoreArrayOrder:!1}){return typeof e!=typeof t?!1:Array.isArray(e)&&Array.isArray(t)?e.length!==t.length?!1:i.ignoreArrayOrder?e.every(f=>t.some(n=>JSON.stringify(n)===JSON.stringify(f))):e.every((f,n)=>JSON.stringify(f)===JSON.stringify(t[n])):typeof e=="object"?JSON.stringify(e)===JSON.stringify(t):e===t}function p(e){return !!e&&typeof e=="object"&&!Array.isArray(e)}function b(e,t={statuses:[],granularity:y.BASIC}){let{statuses:i,granularity:f}=t;return e.reduce((n,r)=>{if(f===y.DEEP&&r.subPropertiesDiff){let s=b(r.subPropertiesDiff,t);if(s.length>0)return [...n,{...r,subPropertiesDiff:s}]}if(f===y.DEEP&&r.subDiff){let s=b(r.subDiff,t);if(s.length>0)return [...n,{...r,subDiff:s}]}return i.includes(r.status)?[...n,r]:n},[])}function A(e){return e.some(t=>t.status!==u.EQUAL)?u.UPDATED:u.EQUAL}function j(e,t,i={ignoreArrayOrder:!1,showOnly:{statuses:[],granularity:y.BASIC}}){if(!e)return {type:"object",status:u.EQUAL,diff:[]};let f=[];return Object.entries(e).forEach(([n,r])=>{if(p(r)){let s=[];return Object.entries(r).forEach(([o,d])=>{s.push({property:o,previousValue:t===u.ADDED?void 0:d,currentValue:t===u.ADDED?d:void 0,status:t});}),f.push({property:n,previousValue:t===u.ADDED?void 0:e[n],currentValue:t===u.ADDED?r:void 0,status:t,subPropertiesDiff:s})}return f.push({property:n,previousValue:t===u.ADDED?void 0:e[n],currentValue:t===u.ADDED?r:void 0,status:t})}),i.showOnly&&i.showOnly.statuses.length>0?{type:"object",status:t,diff:b(f,i.showOnly)}:{type:"object",status:t,diff:f}}function U(e,t,i){if(!e)return;let f=Object.entries(e).find(([n])=>D(n,t,i));return f?f[1]:void 0}function g(e,t,i){return D(e,t,i)?u.EQUAL:u.UPDATED}function m(e){return e.some(t=>t.status!==u.EQUAL)?u.UPDATED:u.EQUAL}function S(e,t){if(!e)return;let i=Object.keys(e),f=Object.keys(t),n=i.filter(r=>!f.includes(r));if(n.length>0)return n.map(r=>({property:r,value:e[r]}))}function h(e,t,i){let f=[],n,r=S(e,t);return r&&r.forEach(s=>{f.push({property:s.property,previousValue:s.value,currentValue:void 0,status:u.DELETED});}),Object.entries(t).forEach(([s,o])=>{let d=U(e,s,i);if(!d)return f.push({property:s,previousValue:d,currentValue:o,status:!e||!(s in e)?u.ADDED:d===o?u.EQUAL:u.UPDATED});if(p(o)){let a=h(d,o,i);a&&a.length>0&&(n=a);}d&&f.push({property:s,previousValue:d,currentValue:o,status:g(d,o,i),...!!n&&{subDiff:n}});}),f}function w(e,t,i={ignoreArrayOrder:!1,showOnly:{statuses:[],granularity:y.BASIC}}){if(!e&&!t)return {type:"object",status:u.EQUAL,diff:[]};if(!e)return j(t,u.ADDED,i);if(!t)return j(e,u.DELETED,i);let f=[];Object.entries(t).forEach(([r,s])=>{let o=e[r];if(!o)return f.push({property:r,previousValue:o,currentValue:s,status:r in e?o===s?u.EQUAL:u.UPDATED:u.ADDED});if(p(s)){let d=h(o,s,i),a=m(d);return f.push({property:r,previousValue:o,currentValue:s,status:a,...a!==u.EQUAL&&{subPropertiesDiff:d}})}return f.push({property:r,previousValue:o,currentValue:s,status:g(o,s,i)})});let n=S(e,t);return n&&n.forEach(r=>{f.push({property:r.property,previousValue:r.value,currentValue:void 0,status:u.DELETED});}),i.showOnly&&i.showOnly.statuses.length>0?{type:"object",status:A(f),diff:b(f,i.showOnly)}:{type:"object",status:A(f),diff:f}}function I(e,t=[]){return e.filter(i=>t?.includes(i.status))}function L(e,t,i={showOnly:[]}){let f=e.map((n,r)=>({value:n,prevIndex:t===c.ADDED?null:r,newIndex:t===c.ADDED?r:null,indexDiff:null,status:t}));return i.showOnly&&i.showOnly.length>0?{type:"list",status:t,diff:f.filter(n=>i.showOnly?.includes(n.status))}:{type:"list",status:t,diff:f}}function T(e){return e.some(t=>t.status!==c.EQUAL)?c.UPDATED:c.EQUAL}function P(e,t){return p(e)&&t?Object.hasOwn(e,t):!1}var R=(e,t,i={showOnly:[],referenceProperty:void 0,considerMoveAsUpdate:!1,ignoreArrayOrder:!1})=>{if(!e&&!t)return {type:"list",status:c.EQUAL,diff:[]};if(!e)return L(t,c.ADDED,i);if(!t)return L(e,c.DELETED,i);let f=[],n=[];return t.forEach((r,s)=>{let o=e.findIndex((a,E)=>P(a,i.referenceProperty)?p(r)?D(a[i.referenceProperty],r[i.referenceProperty])&&!n.includes(E):!1:D(a,r)&&!n.includes(E));o>-1&&n.push(o);let d=o===-1?null:s-o;if(d===0||i.ignoreArrayOrder){let a=c.EQUAL;return P(r,i.referenceProperty)&&(D(e[o],r)||(a=c.UPDATED)),f.push({value:r,prevIndex:o,newIndex:s,indexDiff:d,status:a})}return o===-1?f.push({value:r,prevIndex:null,newIndex:s,indexDiff:d,status:c.ADDED}):f.push({value:r,prevIndex:o,newIndex:s,indexDiff:d,status:i.considerMoveAsUpdate?c.UPDATED:c.MOVED})}),e.forEach((r,s)=>{if(!n.includes(s))return f.splice(s,0,{value:r,prevIndex:s,newIndex:null,indexDiff:null,status:c.DELETED})}),i.showOnly&&i?.showOnly?.length>0?{type:"list",status:T(f),diff:I(f,i.showOnly)}:{type:"list",status:T(f),diff:f}};
export { y as GRANULARITY, c as LIST_STATUS, u as STATUS, R as getListDiff, w as getObjectDiff, D as isEqual, p as isObject };

20
jest.config.js

@ -1,5 +1,23 @@ @@ -1,5 +1,23 @@
module.exports = {
transform: {
"^.+\\.(ts|js)$": "ts-jest",
"^.+\\.(ts|js)$": [
"@swc/jest",
{
jsc: {
baseUrl: ".",
parser: {
syntax: "typescript",
tsx: true,
dynamicImport: true,
},
paths: {
"@models/*": ["./src/models/*"],
"@lib/*": ["./src/lib/*"],
},
target: "esnext",
},
},
],
},
};

2589
package-lock.json generated

File diff suppressed because it is too large Load Diff

33
package.json

@ -1,6 +1,6 @@ @@ -1,6 +1,6 @@
{
"name": "@donedeal0/superdiff",
"version": "2.0.0",
"version": "2.1.0",
"description": "SuperDiff checks the changes between two objects or arrays. It returns a complete diff with relevant information for each property or piece of data",
"main": "dist/index.js",
"module": "dist/index.mjs",
@ -18,6 +18,10 @@ @@ -18,6 +18,10 @@
"bugs": {
"url": "https://github.com/DoneDeal0/superdiff/issues"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/DoneDeal0"
},
"readme": "./README.md",
"release": {
"branches": [
@ -57,32 +61,35 @@ @@ -57,32 +61,35 @@
"diff",
"deep-diff",
"comparison",
"compare"
"compare",
"stream",
"streaming"
],
"scripts": {
"build": "tsup",
"test": "jest",
"lint": "eslint --cache --max-warnings=0 --fix",
"tsc": "tsc --noEmit --incremental",
"format": "npx prettier . --write",
"prepare": "husky"
"lint:dead-code": "npx -p typescript@latest -p knip knip",
"lint": "eslint --cache --max-warnings=0 --fix",
"prepare": "husky",
"test": "jest",
"tsc": "tsc --noEmit --incremental"
},
"devDependencies": {
"@eslint/js": "^9.11.1",
"eslint": "^9.11.1",
"prettier": "^3.3.3",
"@semantic-release/exec": "^6.0.3",
"@semantic-release/git": "^10.0.1",
"@semantic-release/github": "^11.0.0",
"@semantic-release/npm": "^12.0.1",
"@actions/core": "^1.10.1",
"@babel/preset-env": "^7.25.4",
"@swc/core": "^1.7.26",
"@swc/jest": "^0.2.36",
"@types/jest": "^29.5.13",
"eslint": "^9.11.1",
"husky": "^9.1.6",
"jest": "^29.7.0",
"ts-jest": "^29.2.5",
"prettier": "^3.3.3",
"swc-loader": "^0.2.6",
"tsup": "^8.3.0",
"typescript-eslint": "^8.7.0",
"typescript": "^5.6.2"
"typescript": "^5.6.2",
"typescript-eslint": "^8.7.0"
}
}

8
src/index.ts

@ -1,5 +1,7 @@ @@ -1,5 +1,7 @@
export { getObjectDiff } from "./object-diff";
export { getListDiff } from "./list-diff";
export { isEqual, isObject } from "./utils";
export { getObjectDiff } from "./lib/object-diff";
export { getListDiff } from "./lib/list-diff";
export { isEqual, isObject } from "./lib/utils";
export { streamListDiff } from "./lib/stream-list-diff";
export * from "./models/list";
export * from "./models/object";
export * from "./models/stream";

4
src/list-diff.ts → src/lib/list-diff/index.ts

@ -3,8 +3,8 @@ import { @@ -3,8 +3,8 @@ import {
LIST_STATUS,
ListDiff,
ListDiffOptions,
} from "./models/list";
import { isEqual, isObject } from "./utils";
} from "@models/list";
import { isEqual, isObject } from "@lib/utils";
function getLeanDiff(
diff: ListDiff["diff"],

4
test/list-diff.test.ts → src/lib/list-diff/list-diff.test.ts

@ -1,5 +1,5 @@ @@ -1,5 +1,5 @@
import { getListDiff } from "../src/list-diff";
import { LIST_STATUS } from "../src/models/list";
import { getListDiff } from ".";
import { LIST_STATUS } from "@models/list";
describe("getListDiff", () => {
it("returns an empty diff if no lists are provided", () => {

6
src/object-diff.ts → src/lib/object-diff/index.ts

@ -6,8 +6,8 @@ import { @@ -6,8 +6,8 @@ import {
ObjectDiffOptions,
Diff,
DEFAULT_OBJECT_DIFF_OPTIONS,
} from "./models/object";
import { isEqual, isObject } from "./utils";
} from "@models/object";
import { isEqual, isObject } from "@lib/utils";
function getLeanDiff(
diff: ObjectDiff["diff"],
@ -209,7 +209,7 @@ function getSubPropertiesDiff( @@ -209,7 +209,7 @@ function getSubPropertiesDiff(
* Returns the diff between two objects
* @param {ObjectData} prevData - The original object.
* @param {ObjectData} nextData - The new object.
* * @param {ObjectOptions} options - Options to refine your output.
* @param {ObjectOptions} options - Options to refine your output.
- `showOnly`: returns only the values whose status you are interested in. It takes two parameters: `statuses` and `granularity`
`statuses` are the status you want to see in the output (e.g. `["added", "equal"]`)
`granularity` can be either `basic` (to return only the main properties whose status matches your query) or `deep` (to return the main properties if some of their subproperties' status match your request. The subproperties are filtered accordingly).

4
test/object-diff.test.ts → src/lib/object-diff/object-diff.test.ts

@ -1,5 +1,5 @@ @@ -1,5 +1,5 @@
import { GRANULARITY, OBJECT_STATUS } from "../src/models/object";
import { getObjectDiff } from "../src/object-diff";
import { GRANULARITY, OBJECT_STATUS } from "@models/object";
import { getObjectDiff } from ".";
describe("getObjectDiff", () => {
it("returns an empty diff if no objects are provided", () => {

46
src/lib/stream-list-diff/emitter.ts

@ -0,0 +1,46 @@ @@ -0,0 +1,46 @@
import { StreamListDiff } from "@models/stream";
type Listener<T extends unknown[]> = (...args: T) => void;
export enum StreamEvent {
Data = "data",
Finish = "finish",
Error = "error",
}
export type Emitter<T extends Record<string, unknown>> = EventEmitter<{
data: [StreamListDiff<T>[]];
error: [Error];
finish: [];
}>;
export class EventEmitter<Events extends Record<string, unknown[]>> {
private events: Record<string, Listener<unknown[]>[]> = {};
on<E extends keyof Events>(event: E, listener: Listener<Events[E]>): this {
if (!this.events[event as string]) {
this.events[event as string] = [];
}
this.events[event as string].push(listener as Listener<unknown[]>);
return this;
}
emit<E extends keyof Events>(event: E, ...args: Events[E]): void {
if (this.events[event as string]) {
this.events[event as string].forEach((listener) => listener(...args));
}
}
}
export type EmitterEvents<T extends Record<string, unknown>> = {
data: [StreamListDiff<T>[]];
error: [Error];
finish: [];
};
export interface StreamListener<T extends Record<string, unknown>> {
on<E extends keyof EmitterEvents<T>>(
event: E,
listener: Listener<EmitterEvents<T>[E]>,
): this;
}

327
src/lib/stream-list-diff/index.ts

@ -0,0 +1,327 @@ @@ -0,0 +1,327 @@
import {
DEFAULT_LIST_STREAM_OPTIONS,
ListStreamOptions,
ReferenceProperty,
StreamListDiff,
StreamReferences,
} from "@models/stream";
import { LIST_STATUS } from "@models/list";
import { isObject } from "@lib/utils";
import {
Emitter,
EmitterEvents,
EventEmitter,
StreamListener,
StreamEvent,
} from "./emitter";
function outputDiffChunk<T extends Record<string, unknown>>(
emitter: Emitter<T>,
) {
let chunks: StreamListDiff<T>[] = [];
return function handleDiffChunk(
chunk: StreamListDiff<T>,
isLastChunk: boolean,
options: ListStreamOptions,
): void {
const showChunk = options?.showOnly
? options?.showOnly.includes(chunk.status)
: true;
if (!showChunk) {
return;
}
if ((options.chunksSize as number) > 0) {
chunks.push(chunk);
if (chunks.length >= (options.chunksSize as number) || isLastChunk) {
const output = chunks;
chunks = [];
return emitter.emit(StreamEvent.Data, output);
} else {
return;
}
}
return emitter.emit(StreamEvent.Data, [chunk]);
};
}
function formatSingleListStreamDiff<T extends Record<string, unknown>>(
list: T[],
isPrevious: boolean,
status: LIST_STATUS,
options: ListStreamOptions,
): StreamListDiff<T>[] | null {
let isValid = true;
const diff: StreamListDiff<T>[] = [];
for (let i = 0; i < list.length; i++) {
const data = list[i];
if (!isObject(data)) {
isValid = false;
break;
}
diff.push({
previousValue: isPrevious ? data : null,
currentValue: isPrevious ? null : data,
prevIndex: status === LIST_STATUS.ADDED ? null : i,
newIndex: status === LIST_STATUS.ADDED ? i : null,
indexDiff: null,
status,
});
}
if (!isValid) {
return null;
}
if (options.showOnly && options.showOnly.length > 0) {
return diff.filter((value) => options.showOnly?.includes(value.status));
}
return diff;
}
function isValidChunkSize(
chunksSize: ListStreamOptions["chunksSize"],
): boolean {
if (!chunksSize) return true;
const sign = String(Math.sign(chunksSize));
return sign !== "-1" && sign !== "NaN";
}
function isDataValid<T extends Record<string, unknown>>(
data: T,
referenceProperty: ReferenceProperty<T>,
listType: "prevList" | "nextList",
): { isValid: boolean; message?: string } {
if (!isObject(data)) {
return {
isValid: false,
message: `Your ${listType} must only contain valid objects. Found '${data}'`,
};
}
if (!Object.hasOwn(data, referenceProperty)) {
return {
isValid: false,
message: `The reference property '${String(referenceProperty)}' is not available in all the objects of your ${listType}.`,
};
}
return {
isValid: true,
message: "",
};
}
function getDiffChunks<T extends Record<string, unknown>>(
prevList: T[] = [],
nextList: T[] = [],
referenceProperty: ReferenceProperty<T>,
emitter: Emitter<T>,
options: ListStreamOptions = DEFAULT_LIST_STREAM_OPTIONS,
): void {
if (!isValidChunkSize(options?.chunksSize)) {
return emitter.emit(
StreamEvent.Error,
new Error(
`The chunk size can't be negative. You entered the value '${options.chunksSize}'`,
),
);
}
if (prevList.length === 0 && nextList.length === 0) {
return emitter.emit(StreamEvent.Finish);
}
const handleDiffChunk = outputDiffChunk<T>(emitter);
if (prevList.length === 0) {
const nextDiff = formatSingleListStreamDiff(
nextList as T[],
false,
LIST_STATUS.ADDED,
options,
);
if (!nextDiff) {
emitter.emit(
StreamEvent.Error,
new Error("Your nextList must only contain valid objects."),
);
return emitter.emit(StreamEvent.Finish);
}
nextDiff?.forEach((data, i) =>
handleDiffChunk(data, i === nextDiff.length - 1, options),
);
return emitter.emit(StreamEvent.Finish);
}
if (nextList.length === 0) {
const prevDiff = formatSingleListStreamDiff(
prevList as T[],
true,
LIST_STATUS.DELETED,
options,
);
if (!prevDiff) {
emitter.emit(
StreamEvent.Error,
new Error("Your prevList must only contain valid objects."),
);
return emitter.emit(StreamEvent.Finish);
}
prevDiff?.forEach((data, i) =>
handleDiffChunk(data, i === prevDiff.length - 1, options),
);
return emitter.emit(StreamEvent.Finish);
}
const listsReferences: StreamReferences<T> = new Map();
for (let i = 0; i < prevList.length; i++) {
const data = prevList[i];
if (data) {
const { isValid, message } = isDataValid(
data,
referenceProperty,
"prevList",
);
if (!isValid) {
emitter.emit(StreamEvent.Error, new Error(message));
emitter.emit(StreamEvent.Finish);
break;
}
listsReferences.set(String(data[referenceProperty]), {
prevIndex: i,
nextIndex: undefined,
});
}
}
const totalChunks = listsReferences.size;
for (let i = 0; i < nextList.length; i++) {
const data = nextList[i];
if (data) {
const { isValid, message } = isDataValid(
data,
referenceProperty,
"nextList",
);
if (!isValid) {
emitter.emit(StreamEvent.Error, new Error(message));
emitter.emit(StreamEvent.Finish);
break;
}
const listReference = listsReferences.get(
String(data[referenceProperty]),
);
if (listReference) {
listReference.nextIndex = i;
} else {
handleDiffChunk(
{
previousValue: null,
currentValue: data,
prevIndex: null,
newIndex: i,
indexDiff: null,
status: LIST_STATUS.ADDED,
},
totalChunks > 0 ? false : i === nextList.length - 1,
options,
);
}
}
}
let streamedChunks = 0;
for (const [key, data] of listsReferences.entries()) {
streamedChunks++;
const isLastChunk = totalChunks === streamedChunks;
if (typeof data.nextIndex === "undefined") {
handleDiffChunk(
{
previousValue: prevList[data.prevIndex],
currentValue: null,
prevIndex: data.prevIndex,
newIndex: null,
indexDiff: null,
status: LIST_STATUS.DELETED,
},
isLastChunk,
options,
);
} else {
const prevData = prevList[data.prevIndex];
const nextData = nextList[data.nextIndex];
const isDataEqual = JSON.stringify(prevData) === JSON.stringify(nextData);
const indexDiff = data.nextIndex - data.prevIndex;
if (isDataEqual) {
if (indexDiff === 0) {
handleDiffChunk(
{
previousValue: prevList[data.prevIndex],
currentValue: nextList[data.nextIndex],
prevIndex: data.prevIndex,
newIndex: data.nextIndex,
indexDiff: 0,
status: LIST_STATUS.EQUAL,
},
isLastChunk,
options,
);
} else {
handleDiffChunk(
{
previousValue: prevList[data.prevIndex],
currentValue: nextList[data.nextIndex],
prevIndex: data.prevIndex,
newIndex: data.nextIndex,
indexDiff,
status: options.considerMoveAsUpdate
? LIST_STATUS.UPDATED
: LIST_STATUS.MOVED,
},
isLastChunk,
options,
);
}
} else {
handleDiffChunk(
{
previousValue: prevList[data.prevIndex],
currentValue: nextList[data.nextIndex],
prevIndex: data.prevIndex,
newIndex: data.nextIndex,
indexDiff,
status: LIST_STATUS.UPDATED,
},
isLastChunk,
options,
);
}
}
listsReferences.delete(key);
}
return emitter.emit(StreamEvent.Finish);
}
/**
* Streams the diff of two object lists
* @param {Record<string, unknown>[]} prevList - The original object list.
* @param {Record<string, unknown>[]} nextList - The new object list.
* @param {ReferenceProperty<T>} referenceProperty - A common property in all the objects of your lists (e.g. `id`)
* @param {ListStreamOptions} options - Options to refine your output.
- `chunksSize`: the number of object diffs returned by each streamed chunk. (e.g. `0` = 1 object diff by chunk, `10` = 10 object diffs by chunk).
- `showOnly`: returns only the values whose status you are interested in. (e.g. `["added", "equal"]`)
- `considerMoveAsUpdate`: if set to `true` a `moved` object will be considered as `updated`
* @returns EventEmitter
*/
export function streamListDiff<T extends Record<string, unknown>>(
prevList: T[],
nextList: T[],
referenceProperty: ReferenceProperty<T>,
options: ListStreamOptions = DEFAULT_LIST_STREAM_OPTIONS,
): StreamListener<T> {
const emitter = new EventEmitter<EmitterEvents<T>>();
setTimeout(() => {
try {
getDiffChunks(prevList, nextList, referenceProperty, emitter, options);
} catch (err) {
return emitter.emit(StreamEvent.Error, err as Error);
}
}, 0);
return emitter as StreamListener<T>;
}

867
src/lib/stream-list-diff/stream-list-diff.test.ts

@ -0,0 +1,867 @@ @@ -0,0 +1,867 @@
import { LIST_STATUS } from "@models/list";
import { streamListDiff } from ".";
import { StreamListDiff } from "@models/stream";
describe("streamListDiff data", () => {
it("emits 'data' event and consider the all the nextList added if no prevList is provided", (done) => {
const nextList = [
{ id: 1, name: "Item 1" },
{ id: 2, name: "Item 2" },
];
const diff = streamListDiff([], nextList, "id", { chunksSize: 2 });
const expectedChunks = [
{
previousValue: null,
currentValue: { id: 1, name: "Item 1" },
prevIndex: null,
newIndex: 0,
indexDiff: null,
status: LIST_STATUS.ADDED,
},
{
previousValue: null,
currentValue: { id: 2, name: "Item 2" },
prevIndex: null,
newIndex: 1,
indexDiff: null,
status: LIST_STATUS.ADDED,
},
];
let chunkCount = 0;
diff.on("data", (chunk) => {
expect(chunk).toStrictEqual(expectedChunks);
chunkCount++;
});
diff.on("finish", () => {
expect(chunkCount).toBe(1);
done();
});
});
it("emits 'data' event and consider the all the prevList deleted if no nextList is provided", (done) => {
const prevList = [
{ id: 1, name: "Item 1" },
{ id: 2, name: "Item 2" },
];
const diff = streamListDiff(prevList, [], "id", { chunksSize: 2 });
const expectedChunks = [
{
previousValue: { id: 1, name: "Item 1" },
currentValue: null,
prevIndex: 0,
newIndex: null,
indexDiff: null,
status: LIST_STATUS.DELETED,
},
{
previousValue: { id: 2, name: "Item 2" },
currentValue: null,
prevIndex: 1,
newIndex: null,
indexDiff: null,
status: LIST_STATUS.DELETED,
},
];
let chunkCount = 0;
diff.on("data", (chunk) => {
expect(chunk).toStrictEqual(expectedChunks);
chunkCount++;
});
diff.on("finish", () => {
expect(chunkCount).toBe(1);
done();
});
});
it("emits 'data' event with one object diff by chunk if chunkSize is 0 or undefined", (done) => {
const prevList = [
{ id: 1, name: "Item 1" },
{ id: 2, name: "Item 2" },
];
const nextList = [
{ id: 2, name: "Item 2" },
{ id: 3, name: "Item 3" },
];
const diff = streamListDiff(prevList, nextList, "id");
const expectedChunks = [
[
{
previousValue: null,
currentValue: { id: 3, name: "Item 3" },
prevIndex: null,
newIndex: 1,
indexDiff: null,
status: LIST_STATUS.ADDED,
},
],
[
{
previousValue: { id: 1, name: "Item 1" },
currentValue: null,
prevIndex: 0,
newIndex: null,
indexDiff: null,
status: LIST_STATUS.DELETED,
},
],
[
{
previousValue: { id: 2, name: "Item 2" },
currentValue: { id: 2, name: "Item 2" },
prevIndex: 1,
newIndex: 0,
indexDiff: -1,
status: LIST_STATUS.MOVED,
},
],
];
let chunkCount = 0;
diff.on("data", (chunk) => {
expect(chunk).toStrictEqual(expectedChunks[chunkCount]);
chunkCount++;
});
diff.on("finish", () => {
expect(chunkCount).toBe(3);
done();
});
});
it("emits 'data' event with 5 object diff by chunk and return the last object diff in a one entry chunk at the end", (done) => {
const prevList = [
{ id: 1, name: "Item 1" },
{ id: 2, name: "Item 2" },
{ id: 3, name: "Item 3" },
{ id: 4, name: "Item 4" },
{ id: 5, name: "Item 5" },
{ id: 6, name: "Item 6" },
{ id: 7, name: "Item 7" },
{ id: 8, name: "Item 8" },
{ id: 9, name: "Item 9" },
{ id: 10, name: "Item 10" },
];
const nextList = [
{ id: 1, name: "Item 1" },
{ id: 2, name: "Item Two" },
{ id: 3, name: "Item 3" },
{ id: 5, name: "Item 5" },
{ id: 6, name: "Item Six" },
{ id: 7, name: "Item 7" },
{ id: 10, name: "Item 10" },
{ id: 11, name: "Item 11" },
{ id: 9, name: "Item 9" },
{ id: 8, name: "Item 8" },
];
const diff = streamListDiff(prevList, nextList, "id", { chunksSize: 5 });
const expectedChunks = [
[
{
previousValue: null,
currentValue: { id: 11, name: "Item 11" },
prevIndex: null,
newIndex: 7,
indexDiff: null,
status: LIST_STATUS.ADDED,
},
{
previousValue: { id: 1, name: "Item 1" },
currentValue: { id: 1, name: "Item 1" },
prevIndex: 0,
newIndex: 0,
indexDiff: 0,
status: LIST_STATUS.EQUAL,
},
{
previousValue: { id: 2, name: "Item 2" },
currentValue: { id: 2, name: "Item Two" },
prevIndex: 1,
newIndex: 1,
indexDiff: 0,
status: LIST_STATUS.UPDATED,
},
{
previousValue: { id: 3, name: "Item 3" },
currentValue: { id: 3, name: "Item 3" },
prevIndex: 2,
newIndex: 2,
indexDiff: 0,
status: LIST_STATUS.EQUAL,
},
{
previousValue: { id: 4, name: "Item 4" },
currentValue: null,
prevIndex: 3,
newIndex: null,
indexDiff: null,
status: LIST_STATUS.DELETED,
},
],
[
{
previousValue: { id: 5, name: "Item 5" },
currentValue: { id: 5, name: "Item 5" },
prevIndex: 4,
newIndex: 3,
indexDiff: -1,
status: LIST_STATUS.MOVED,
},
{
previousValue: { id: 6, name: "Item 6" },
currentValue: { id: 6, name: "Item Six" },
prevIndex: 5,
newIndex: 4,
indexDiff: -1,
status: LIST_STATUS.UPDATED,
},
{
previousValue: { id: 7, name: "Item 7" },
currentValue: { id: 7, name: "Item 7" },
prevIndex: 6,
newIndex: 5,
indexDiff: -1,
status: LIST_STATUS.MOVED,
},
{
previousValue: { id: 8, name: "Item 8" },
currentValue: { id: 8, name: "Item 8" },
prevIndex: 7,
newIndex: 9,
indexDiff: 2,
status: LIST_STATUS.MOVED,
},
{
previousValue: { id: 9, name: "Item 9" },
currentValue: { id: 9, name: "Item 9" },
prevIndex: 8,
newIndex: 8,
indexDiff: 0,
status: LIST_STATUS.EQUAL,
},
],
[
{
previousValue: { id: 10, name: "Item 10" },
currentValue: { id: 10, name: "Item 10" },
prevIndex: 9,
newIndex: 6,
indexDiff: -3,
status: LIST_STATUS.MOVED,
},
],
];
let chunkCount = 0;
diff.on("data", (chunk) => {
expect(chunk).toStrictEqual(expectedChunks[chunkCount]);
chunkCount++;
});
diff.on("finish", () => {
expect(chunkCount).toBe(3);
done();
});
});
it("emits 'data' event with all the objects diff in a single chunk if the chunkSize is bigger than the provided lists ", (done) => {
const prevList = [
{ id: 1, name: "Item 1" },
{ id: 2, name: "Item 2" },
{ id: 3, name: "Item 3" },
{ id: 4, name: "Item 4" },
];
const nextList = [
{ id: 1, name: "Item 1" },
{ id: 2, name: "Item Two" },
{ id: 3, name: "Item 3" },
{ id: 5, name: "Item 5" },
];
const diff = streamListDiff(prevList, nextList, "id", { chunksSize: 150 });
const expectedChunks = [
{
previousValue: null,
currentValue: { id: 5, name: "Item 5" },
prevIndex: null,
newIndex: 3,
indexDiff: null,
status: LIST_STATUS.ADDED,
},
{
previousValue: { id: 1, name: "Item 1" },
currentValue: { id: 1, name: "Item 1" },
prevIndex: 0,
newIndex: 0,
indexDiff: 0,
status: LIST_STATUS.EQUAL,
},
{
previousValue: { id: 2, name: "Item 2" },
currentValue: { id: 2, name: "Item Two" },
prevIndex: 1,
newIndex: 1,
indexDiff: 0,
status: LIST_STATUS.UPDATED,
},
{
previousValue: { id: 3, name: "Item 3" },
currentValue: { id: 3, name: "Item 3" },
prevIndex: 2,
newIndex: 2,
indexDiff: 0,
status: LIST_STATUS.EQUAL,
},
{
previousValue: { id: 4, name: "Item 4" },
currentValue: null,
prevIndex: 3,
newIndex: null,
indexDiff: null,
status: LIST_STATUS.DELETED,
},
];
let chunkCount = 0;
diff.on("data", (chunk) => {
expect(chunk).toStrictEqual(expectedChunks);
chunkCount++;
});
diff.on("finish", () => {
expect(chunkCount).toBe(1);
done();
});
});
it("emits 'data' event with moved objects considered as updated if considerMoveAsUpdate is true", (done) => {
const prevList = [
{ id: 1, name: "Item 1" },
{ id: 2, name: "Item 2" },
{ id: 3, name: "Item 3" },
{ id: 4, name: "Item 4" },
];
const nextList = [
{ id: 2, name: "Item Two" },
{ id: 1, name: "Item 1" },
{ id: 3, name: "Item 3" },
{ id: 5, name: "Item 5" },
];
const diff = streamListDiff(prevList, nextList, "id", {
chunksSize: 5,
considerMoveAsUpdate: true,
});
const expectedChunks = [
{
previousValue: null,
currentValue: { id: 5, name: "Item 5" },
prevIndex: null,
newIndex: 3,
indexDiff: null,
status: LIST_STATUS.ADDED,
},
{
previousValue: { id: 1, name: "Item 1" },
currentValue: { id: 1, name: "Item 1" },
prevIndex: 0,
newIndex: 1,
indexDiff: 1,
status: LIST_STATUS.UPDATED,
},
{
previousValue: { id: 2, name: "Item 2" },
currentValue: { id: 2, name: "Item Two" },
prevIndex: 1,
newIndex: 0,
indexDiff: -1,
status: LIST_STATUS.UPDATED,
},
{
previousValue: { id: 3, name: "Item 3" },
currentValue: { id: 3, name: "Item 3" },
prevIndex: 2,
newIndex: 2,
indexDiff: 0,
status: LIST_STATUS.EQUAL,
},
{
previousValue: { id: 4, name: "Item 4" },
currentValue: null,
prevIndex: 3,
newIndex: null,
indexDiff: null,
status: LIST_STATUS.DELETED,
},
];
let chunkCount = 0;
diff.on("data", (chunk) => {
expect(chunk).toStrictEqual(expectedChunks);
chunkCount++;
});
diff.on("finish", () => {
expect(chunkCount).toBe(1);
done();
});
});
it("emits 'data' event only with objects diff whose status match with showOnly's", (done) => {
const prevList = [
{ id: 1, name: "Item 1" },
{ id: 2, name: "Item 2" },
{ id: 3, name: "Item 3" },
{ id: 4, name: "Item 4" },
];
const nextList = [
{ id: 2, name: "Item Two" },
{ id: 1, name: "Item 1" },
{ id: 3, name: "Item 3" },
{ id: 5, name: "Item 5" },
];
const diff = streamListDiff(prevList, nextList, "id", {
chunksSize: 5,
showOnly: ["added", "deleted"],
});
const expectedChunks = [
{
previousValue: null,
currentValue: { id: 5, name: "Item 5" },
prevIndex: null,
newIndex: 3,
indexDiff: null,
status: LIST_STATUS.ADDED,
},
{
previousValue: { id: 4, name: "Item 4" },
currentValue: null,
prevIndex: 3,
newIndex: null,
indexDiff: null,
status: LIST_STATUS.DELETED,
},
];
let chunkCount = 0;
diff.on("data", (chunk) => {
expect(chunk).toStrictEqual(expectedChunks);
chunkCount++;
});
diff.on("finish", () => {
expect(chunkCount).toBe(1);
done();
});
});
it("emits 'data' event with deep nested objects diff", (done) => {
const prevList = [
{
id: 1,
name: "Item 1",
user: { role: "admin", hobbies: ["golf", "football"] },
},
{ id: 2, name: "Item 2" },
{ id: 3, name: "Item 3", user: { role: "admin", hobbies: ["rugby"] } },
{
id: 4,
name: "Item 4",
user: { role: "reader", hobbies: ["video games", "fishing"] },
},
{ id: 5, name: "Item 5" },
{ id: 6, name: "Item 6", user: { role: "root", hobbies: ["coding"] } },
{ id: 7, name: "Item 7" },
{ id: 8, name: "Item 8" },
{ id: 9, name: "Item 9" },
{
id: 10,
name: "Item 10",
user: {
role: "root",
hobbies: ["coding"],
skills: { driving: true, diving: false },
},
},
];
const nextList = [
{
id: 1,
name: "Item 1",
user: { role: "admin", hobbies: ["golf", "football"] },
},
{ id: 2, name: "Item Two" },
{ id: 3, name: "Item 3", user: { role: "admin", hobbies: ["rugby"] } },
{ id: 5, name: "Item 5" },
{ id: 6, name: "Item 6", user: { role: "root", hobbies: ["farming"] } },
{ id: 7, name: "Item 7" },
{
id: 10,
name: "Item 10",
user: {
role: "root",
hobbies: ["coding"],
skills: { driving: true, diving: false },
},
},
{ id: 11, name: "Item 11" },
{ id: 9, name: "Item 9" },
{ id: 8, name: "Item 8" },
];
const diff = streamListDiff(prevList, nextList, "id", { chunksSize: 5 });
const expectedChunks = [
[
{
previousValue: null,
currentValue: { id: 11, name: "Item 11" },
prevIndex: null,
newIndex: 7,
indexDiff: null,
status: LIST_STATUS.ADDED,
},
{
previousValue: {
id: 1,
name: "Item 1",
user: { role: "admin", hobbies: ["golf", "football"] },
},
currentValue: {
id: 1,
name: "Item 1",
user: { role: "admin", hobbies: ["golf", "football"] },
},
prevIndex: 0,
newIndex: 0,
indexDiff: 0,
status: LIST_STATUS.EQUAL,
},
{
previousValue: { id: 2, name: "Item 2" },
currentValue: { id: 2, name: "Item Two" },
prevIndex: 1,
newIndex: 1,
indexDiff: 0,
status: LIST_STATUS.UPDATED,
},
{
previousValue: {
id: 3,
name: "Item 3",
user: { role: "admin", hobbies: ["rugby"] },
},
currentValue: {
id: 3,
name: "Item 3",
user: { role: "admin", hobbies: ["rugby"] },
},
prevIndex: 2,
newIndex: 2,
indexDiff: 0,
status: LIST_STATUS.EQUAL,
},
{
previousValue: {
id: 4,
name: "Item 4",
user: { role: "reader", hobbies: ["video games", "fishing"] },
},
currentValue: null,
prevIndex: 3,
newIndex: null,
indexDiff: null,
status: LIST_STATUS.DELETED,
},
],
[
{
previousValue: { id: 5, name: "Item 5" },
currentValue: { id: 5, name: "Item 5" },
prevIndex: 4,
newIndex: 3,
indexDiff: -1,
status: LIST_STATUS.MOVED,
},
{
previousValue: {
id: 6,
name: "Item 6",
user: { role: "root", hobbies: ["coding"] },
},
currentValue: {
id: 6,
name: "Item 6",
user: { role: "root", hobbies: ["farming"] },
},
prevIndex: 5,
newIndex: 4,
indexDiff: -1,
status: LIST_STATUS.UPDATED,
},
{
previousValue: { id: 7, name: "Item 7" },
currentValue: { id: 7, name: "Item 7" },
prevIndex: 6,
newIndex: 5,
indexDiff: -1,
status: LIST_STATUS.MOVED,
},
{
previousValue: { id: 8, name: "Item 8" },
currentValue: { id: 8, name: "Item 8" },
prevIndex: 7,
newIndex: 9,
indexDiff: 2,
status: LIST_STATUS.MOVED,
},
{
previousValue: { id: 9, name: "Item 9" },
currentValue: { id: 9, name: "Item 9" },
prevIndex: 8,
newIndex: 8,
indexDiff: 0,
status: LIST_STATUS.EQUAL,
},
],
[
{
previousValue: {
id: 10,
name: "Item 10",
user: {
role: "root",
hobbies: ["coding"],
skills: { driving: true, diving: false },
},
},
currentValue: {
id: 10,
name: "Item 10",
user: {
role: "root",
hobbies: ["coding"],
skills: { driving: true, diving: false },
},
},
prevIndex: 9,
newIndex: 6,
indexDiff: -3,
status: LIST_STATUS.MOVED,
},
],
];
let chunkCount = 0;
diff.on("data", (chunk) => {
expect(chunk).toStrictEqual(expectedChunks[chunkCount]);
chunkCount++;
});
diff.on("finish", () => {
expect(chunkCount).toBe(3);
done();
});
});
});
describe("streamListDiff finish", () => {
it("emits 'finish' event if no prevList nor nextList is provided", (done) => {
const diff = streamListDiff([], [], "id");
diff.on("finish", () => done());
});
it("emits 'finish' event when all the chunks have been processed", (done) => {
const prevList = [
{ id: 1, name: "Item 1" },
{ id: 2, name: "Item 2" },
];
const nextList = [
{ id: 2, name: "Item 2" },
{ id: 3, name: "Item 3" },
];
const diff = streamListDiff(prevList, nextList, "id");
diff.on("finish", () => done());
});
});
describe("streamListDiff error", () => {
test("emits 'error' event when prevList has invalid data", (done) => {
const prevList = [
{ id: 1, name: "Item 1" },
"hello",
{ id: 2, name: "Item 2" },
];
const nextList = [
{ id: 1, name: "Item 1" },
{ id: 2, name: "Item 2" },
];
// @ts-expect-error prevList is invalid by design for the test
const diff = streamListDiff(prevList, nextList, "id");
diff.on("error", (err) => {
expect(err["message"]).toEqual(
`Your prevList must only contain valid objects. Found 'hello'`,
);
done();
});
});
test("emits 'error' event when nextList has invalid data", (done) => {
const prevList = [
{ id: 1, name: "Item 1" },
{ id: 2, name: "Item 2" },
];
const nextList = [
{ id: 1, name: "Item 1" },
"hello",
{ id: 2, name: "Item 2" },
];
// @ts-expect-error nextList is invalid by design for the test
const diff = streamListDiff(prevList, nextList, "id");
diff.on("error", (err) => {
expect(err["message"]).toEqual(
`Your nextList must only contain valid objects. Found 'hello'`,
);
done();
});
});
test("emits 'error' event when all prevList ojects don't have the requested reference property", (done) => {
const prevList = [{ id: 1, name: "Item 1" }, { name: "Item 2" }];
const nextList = [
{ id: 1, name: "Item 1" },
{ id: 2, name: "Item 2" },
];
const diff = streamListDiff(prevList, nextList, "id");
diff.on("error", (err) => {
expect(err["message"]).toEqual(
`The reference property 'id' is not available in all the objects of your prevList.`,
);
done();
});
});
test("emits 'error' event when all nextList ojects don't have the requested reference property", (done) => {
const prevList = [
{ id: 1, name: "Item 1" },
{ id: 2, name: "Item 2" },
];
const nextList = [{ id: 1, name: "Item 1" }, { name: "Item 2" }];
const diff = streamListDiff(prevList, nextList, "id");
diff.on("error", (err) => {
expect(err["message"]).toEqual(
`The reference property 'id' is not available in all the objects of your nextList.`,
);
done();
});
});
test("emits 'error' event when the chunkSize option is negative", (done) => {
const prevList = [
{ id: 1, name: "Item 1" },
{ id: 2, name: "Item 2" },
];
const nextList = [{ id: 1, name: "Item 1" }, { name: "Item 2" }];
const diff = streamListDiff(prevList, nextList, "id", { chunksSize: -3 });
diff.on("error", (err) => {
expect(err["message"]).toEqual(
"The chunk size can't be negative. You entered the value '-3'",
);
done();
});
});
});
describe("Performance", () => {
it("should correctly stream diff for 10.000 entries", (done) => {
const generateLargeList = (size: number, idPrefix: string) => {
return Array.from({ length: size }, (_, i) => ({
id: `${idPrefix}-${i}`,
value: i,
}));
};
const prevList = generateLargeList(10_000, "prev");
const nextList = [
...generateLargeList(5000, "prev"),
...generateLargeList(5000, "next"),
];
const receivedChunks: StreamListDiff<{ id: string; value: number }>[] = [];
let chunkCount = 0;
const diffStream = streamListDiff(prevList, nextList, "id", {
chunksSize: 1000,
});
diffStream.on("data", (chunk) => {
receivedChunks.push(...chunk);
chunkCount++;
});
diffStream.on("finish", () => {
const deletions = receivedChunks.filter(
(diff) => diff.status === LIST_STATUS.DELETED,
);
const additions = receivedChunks.filter(
(diff) => diff.status === LIST_STATUS.ADDED,
);
const updates = receivedChunks.filter(
(diff) => diff.status === LIST_STATUS.EQUAL,
);
expect(receivedChunks.length).toBe(15_000); // 5000 deletions + 5000 equal + 5000 additions
expect(chunkCount).toBe(15);
expect(deletions.length).toBe(5000);
expect(additions.length).toBe(5000);
expect(updates.length).toBe(5000);
done();
});
});
it("should correctly stream diff for 100.000 entries", (done) => {
const generateLargeList = (size: number, idPrefix: string) => {
return Array.from({ length: size }, (_, i) => ({
id: `${idPrefix}-${i}`,
value: i,
}));
};
const prevList = generateLargeList(100_000, "prev");
const nextList = [
...generateLargeList(50000, "prev"),
...generateLargeList(50000, "next"),
];
const receivedChunks: StreamListDiff<{ id: string; value: number }>[] = [];
let chunkCount = 0;
const diffStream = streamListDiff(prevList, nextList, "id", {
chunksSize: 10_000,
});
diffStream.on("data", (chunk) => {
receivedChunks.push(...chunk);
chunkCount++;
});
diffStream.on("finish", () => {
const deletions = receivedChunks.filter(
(diff) => diff.status === LIST_STATUS.DELETED,
);
const additions = receivedChunks.filter(
(diff) => diff.status === LIST_STATUS.ADDED,
);
const updates = receivedChunks.filter(
(diff) => diff.status === LIST_STATUS.EQUAL,
);
expect(receivedChunks.length).toBe(150_000); // 50.000 deletions + 50.000 equal + 50.000 additions
expect(chunkCount).toBe(15);
expect(deletions.length).toBe(50000);
expect(additions.length).toBe(50000);
expect(updates.length).toBe(50000);
done();
});
});
});

2
src/utils.ts → src/lib/utils/index.ts

@ -1,4 +1,4 @@ @@ -1,4 +1,4 @@
import { isEqualOptions } from "./models/utils";
import { isEqualOptions } from "@models/utils";
/**
* Returns true if two data are equal

2
test/utils.test.ts → src/lib/utils/utils.test.ts

@ -1,4 +1,4 @@ @@ -1,4 +1,4 @@
import { isEqual, isObject } from "../src/utils";
import { isEqual, isObject } from ".";
describe("isEqual", () => {
it("return true if data are the same", () => {

4
src/models/list.ts → src/models/list/index.ts

@ -6,8 +6,6 @@ export enum LIST_STATUS { @@ -6,8 +6,6 @@ export enum LIST_STATUS {
MOVED = "moved",
}
export type ListData = unknown;
export type ListDiffOptions = {
showOnly?: `${LIST_STATUS}`[];
referenceProperty?: string;
@ -26,7 +24,7 @@ export type ListDiff = { @@ -26,7 +24,7 @@ export type ListDiff = {
type: "list";
status: LIST_STATUS;
diff: {
value: ListData;
value: unknown;
prevIndex: number | null;
newIndex: number | null;
indexDiff: number | null;

0
src/models/object.ts → src/models/object/index.ts

27
src/models/stream/index.ts

@ -0,0 +1,27 @@ @@ -0,0 +1,27 @@
import { LIST_STATUS } from "@models/list";
export type StreamListDiff<T extends Record<string, unknown>> = {
currentValue: T | null;
previousValue: T | null;
prevIndex: number | null;
newIndex: number | null;
indexDiff: number | null;
status: LIST_STATUS;
};
export type ReferenceProperty<T extends Record<string, unknown>> = keyof T;
export type StreamReferences<T extends Record<string, unknown>> = Map<
ReferenceProperty<T>,
{ prevIndex: number; nextIndex?: number }
>;
export type ListStreamOptions = {
chunksSize?: number; // 0 by default.
showOnly?: `${LIST_STATUS}`[];
considerMoveAsUpdate?: boolean;
};
export const DEFAULT_LIST_STREAM_OPTIONS: ListStreamOptions = {
chunksSize: 0,
};

0
src/models/utils.ts → src/models/utils/index.ts

11
tsconfig.json

@ -1,6 +1,6 @@ @@ -1,6 +1,6 @@
{
"exclude": ["node_modules", "dist"],
"include": ["src/*.ts"],
"include": ["src"],
"compilerOptions": {
"declaration": true,
"declarationDir": "./dist",
@ -15,6 +15,13 @@ @@ -15,6 +15,13 @@
"strict": true,
"resolveJsonModule": true,
"allowSyntheticDefaultImports": true,
"skipLibCheck": true
"skipLibCheck": true ,
"baseUrl": ".",
"paths": {
"@models/*": ["./src/models/*"],
"@lib/*": ["./src/lib/*"],
}
},
}

Loading…
Cancel
Save