@wszerad/items 0.1.1 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,20 +1,18 @@
1
- # items
1
+ # @wszerad/items
2
2
 
3
- Lightweight, immutable collection manager inspired by NgRx Entity Adapter.
3
+ An immutable collection library for managing entities with built-in selection, filtering, and diffing capabilities.
4
+
5
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
6
+ [![npm version](https://badge.fury.io/js/@wszerad%2Fitems.svg)](https://badge.fury.io/js/@wszerad%2Fitems)
4
7
 
5
8
  ## Features
6
9
 
7
- - Immutable operations (insert/insertMany, upsert/upsertMany, set/setMany, update, remove/removeMany, filter, etc.)
8
- - Single entity and batch operations
9
- - Custom ID selection (`selectId`)
10
- - Optional sorting (`sortComparer`)
11
- - TypeScript-first with full type safety
12
- - Zero dependencies
13
- - ✅ Tree-shakeable ESM build
14
- - ✅ Flexible selectors (ID, array of IDs, or predicate function)
15
- - ✅ Built-in pagination support
16
- - ✅ Diff detection between collections
17
- - ✅ `every()` and `some()` collection validators
10
+ - 🔒 **Immutable**: All operations return new instances without mutating the original
11
+ - 🎯 **Type-safe**: Full TypeScript support with generic types
12
+ - 🔍 **Powerful Selection**: Query and filter items with a fluent API
13
+ - 📊 **Diff Tracking**: Compare collections and track changes
14
+ - **Performance**: Efficient internal storage using Maps
15
+ - 🎨 **Flexible**: Custom ID selectors and sorting comparers
18
16
 
19
17
  ## Installation
20
18
 
@@ -25,7 +23,7 @@ npm install @wszerad/items
25
23
  ## Quick Start
26
24
 
27
25
  ```typescript
28
- import { Items } from 'items'
26
+ import { Items } from '@wszerad/items'
29
27
 
30
28
  interface User {
31
29
  id: number
@@ -33,734 +31,566 @@ interface User {
33
31
  age: number
34
32
  }
35
33
 
36
- // Create collection
37
- const items = new Items<number, User>()
38
-
39
- // Add single entity
40
- const withUser = items.insert({ id: 1, name: 'Alice', age: 25 })
34
+ // Create a collection
35
+ const users = new Items<User>([
36
+ { id: 1, name: 'Alice', age: 30 },
37
+ { id: 2, name: 'Bob', age: 25 },
38
+ { id: 3, name: 'Charlie', age: 35 }
39
+ ])
41
40
 
42
- // Add multiple entities (batch operation)
43
- const withUsers = items.insertMany([
44
- { id: 1, name: 'Alice', age: 25 },
45
- { id: 2, name: 'Bob', age: 30 }
41
+ // Add items
42
+ const updated = users.add([
43
+ { id: 4, name: 'David', age: 40 }
46
44
  ])
47
45
 
48
- // Query
49
- console.log(withUsers.getIds()) // [1, 2]
50
- console.log(withUsers.select(1)) // { id: 1, name: 'Alice', age: 25 }
51
- console.log(withUsers.length) // 2
46
+ // Update items
47
+ const older = users.update([1, 2], (user) => ({
48
+ ...user!,
49
+ age: user!.age + 1
50
+ }))
51
+
52
+ // Select and filter - returns array
53
+ const adults = users.select((s) =>
54
+ s.filter((user) => user.age >= 30)
55
+ .sort((a, b) => a.age - b.age)
56
+ )
57
+ console.log(adults) // [{ id: 1, name: 'Alice', age: 30 }, { id: 3, name: 'Charlie', age: 35 }]
52
58
 
53
- // Update
54
- const updated = withUsers.update(1, { age: 26 })
59
+ // Select single item using at() - returns single item or undefined
60
+ const oldestUser = users.select((s) =>
61
+ s.sort((a, b) => b.age - a.age).at(0)
62
+ )
63
+ console.log(oldestUser) // { id: 3, name: 'Charlie', age: 35 }
55
64
 
56
- // Remove
57
- const removed = updated.remove(1)
65
+ // Get IDs instead of items
66
+ const adultIds = users.selectId((s) =>
67
+ s.filter((user) => user.age >= 30)
68
+ )
69
+ console.log(adultIds) // [1, 3]
58
70
  ```
59
71
 
60
72
  ## API Reference
61
73
 
62
- ### Constructor
63
-
64
- #### `new Items<I, E>(items?, options?)`
74
+ ### Items Class
65
75
 
66
- Creates an `Items` instance with optional initial items and options.
76
+ #### Constructor
67
77
 
68
78
  ```typescript
69
- // Empty collection
70
- const items = new Items<number, User>()
79
+ new Items<E>(items?: Iterable<E>, options?: ItemsOptions<E>)
80
+ ```
71
81
 
72
- // With initial items
73
- const items = new Items<number, User>([
74
- { id: 1, name: 'Alice' },
75
- { id: 2, name: 'Bob' }
76
- ])
82
+ **Options:**
83
+ - `selectId?: (entity: E) => ItemId` - Custom ID selector (defaults to `entity.id`)
84
+ - `sortComparer?: false | ((a: E, b: E) => number)` - Sorting comparator for maintaining order
77
85
 
78
- // With options
79
- const items = new Items<number, User>([], {
80
- selectId: (user) => user.id, // default: entity.id
81
- sortComparer: (a, b) => a.name.localeCompare(b.name) // default: false
86
+ **Example:**
87
+ ```typescript
88
+ const items = new Items(users, {
89
+ selectId: (user) => user.userId,
90
+ sortComparer: (a, b) => a.name.localeCompare(b.name)
82
91
  })
83
92
  ```
84
93
 
85
- ### Properties
94
+ #### Methods
95
+
96
+ ##### `add(items: Iterable<E>): Items<E>`
86
97
 
87
- - **`length`** Number of entities in the collection
98
+ Adds new items to the collection. Items with existing IDs are ignored.
88
99
 
89
100
  ```typescript
90
- items.length // 2
101
+ const updated = users.add([
102
+ { id: 4, name: 'David', age: 40 }
103
+ ])
91
104
  ```
92
105
 
93
- ### Methods
106
+ ##### `update(selector: Selector<E>, updater: Updater<E>): Items<E>`
94
107
 
95
- #### Insert Operations
96
-
97
- - **`insert(entity)`** – Adds a single entity (skips if already exists)
98
- - **`insertMany(entities)`** – Adds multiple entities (skips duplicates). Accepts an `Iterable<E>` (array, Set, etc.)
108
+ Updates selected items with partial data or a function. Can create new items if using a function updater.
99
109
 
100
110
  ```typescript
101
- // Insert single entity
102
- items.insert({ id: 1, name: 'Alice' })
111
+ // Update with partial data
112
+ const updated = users.update(1, { age: 31 })
113
+
114
+ // Update with function
115
+ const updated = users.update([1, 2], (user) => ({
116
+ ...user!,
117
+ age: user!.age + 1
118
+ }))
119
+
120
+ // Update with selector function
121
+ const updated = users.update(
122
+ (s) => s.filter((u) => u.age > 25),
123
+ { active: true }
124
+ )
125
+ ```
103
126
 
104
- // Insert multiple entities
105
- items.insertMany([
106
- { id: 1, name: 'Alice' },
107
- { id: 2, name: 'Bob' }
108
- ])
127
+ ##### `merge(items: Iterable<E>): Items<E>`
109
128
 
110
- // Insert from Set
111
- const usersSet = new Set([
112
- { id: 1, name: 'Alice' },
113
- { id: 2, name: 'Bob' }
114
- ])
115
- items.insertMany(usersSet)
129
+ Merges items into the collection. Adds new items and overwrites existing ones.
116
130
 
117
- // Skips duplicates - won't replace existing entity with id: 1
118
- items.insert({ id: 1, name: 'Alice Updated' }) // Original stays
131
+ ```typescript
132
+ const merged = users.merge([
133
+ { id: 1, name: 'Alice', age: 31 }, // Updates existing
134
+ { id: 5, name: 'Eve', age: 28 } // Adds new
135
+ ])
119
136
  ```
120
137
 
121
- #### Upsert Operations
122
-
123
- - **`upsert(entity)`** – Adds or **merges** a single entity (extends existing properties)
124
- - **`upsertMany(entities)`** – Adds or **merges** multiple entities. Accepts an `Iterable<E>` (array, Set, etc.)
138
+ ##### `remove(selector: Selector<E>): Items<E>`
125
139
 
126
- **Note:** `upsert` merges/extends properties with existing entities, similar to `Object.assign()` or spread operator behavior.
140
+ Removes selected items from the collection.
127
141
 
128
142
  ```typescript
129
- // Upsert single entity
130
- items.upsert({ id: 1, name: 'Alice' })
143
+ // Remove by ID
144
+ const removed = users.remove(1)
131
145
 
132
- // Upsert existing entity - MERGES properties
133
- const items = new Items([{ id: 1, name: 'Alice', age: 25 }])
134
- const updated = items.upsert({ id: 1, name: 'Alice Updated' })
135
- // Result: { id: 1, name: 'Alice Updated', age: 25 }
136
- // Note: age is preserved!
146
+ // Remove by IDs
147
+ const removed = users.remove([1, 2])
137
148
 
138
- // Upsert multiple entities
139
- items.upsertMany([
140
- { id: 1, name: 'Alice' },
141
- { id: 2, name: 'Bob' }
142
- ])
143
-
144
- // Adding new properties
145
- const items = new Items([{ id: 1, name: 'Alice' }])
146
- const updated = items.upsert({ id: 1, age: 25 })
147
- // Result: { id: 1, name: 'Alice', age: 25 }
148
- // Note: name is preserved, age is added
149
+ // Remove by function
150
+ const removed = users.remove((s) => s.filter((u) => u.age < 30))
149
151
  ```
150
152
 
151
- #### Set Operations
152
-
153
- - **`set(entity)`** – Adds or **completely replaces** a single entity
154
- - **`setMany(entities)`** – Adds or **completely replaces** multiple entities. Accepts an `Iterable<E>` (array, Set, etc.)
153
+ ##### `pick(selector: Selector<E>): Items<E>`
155
154
 
156
- **Note:** `set` completely replaces existing entities, removing any properties not in the new entity.
155
+ Returns a new Items instance containing only the selected items.
157
156
 
158
157
  ```typescript
159
- // Set single entity
160
- items.set({ id: 1, name: 'Alice' })
161
-
162
- // Set existing entity - REPLACES completely
163
- const items = new Items([{ id: 1, name: 'Alice', age: 25 }])
164
- const updated = items.set({ id: 1, name: 'Alice Updated' })
165
- // Result: { id: 1, name: 'Alice Updated' }
166
- // Note: age is removed!
167
-
168
- // Set multiple entities
169
- items.setMany([
170
- { id: 1, name: 'Alice' },
171
- { id: 2, name: 'Bob' }
172
- ])
158
+ const picked = users.pick((s) => s.filter((u) => u.age >= 30))
173
159
  ```
174
160
 
175
- #### Update Operations
176
-
177
- - **`update(id, updater)`** – Updates a single entity by ID
178
- - **`updateMany(selector, updater)`** – Updates multiple entities matching selector
161
+ ##### `select(selector: Selector<E>): E[] | E | undefined`
179
162
 
180
- The updater can be a partial object or a function that returns the updated entity.
163
+ Selects items and returns them. Returns an array for multiple selections, or a single item (or undefined) when using SingleSelect methods like `at()` or `on()`.
181
164
 
182
165
  ```typescript
183
- // Update single entity by ID with partial
184
- items.update(1, { age: 26 })
166
+ // Select by ID - returns single item or undefined
167
+ const user = items.select(1)
185
168
 
186
- // Update single entity by ID with function
187
- items.update(1, user => ({ ...user, age: user.age + 1 }))
169
+ // Select by IDs - returns array
170
+ const users = items.select([1, 2])
188
171
 
189
- // Update multiple entities by IDs
190
- items.updateMany([1, 2], { age: 26 })
172
+ // Select with function returning Select - returns array
173
+ const adults = items.select((s) => s.filter((u) => u.age >= 30))
191
174
 
192
- // Update multiple entities by predicate
193
- items.updateMany(
194
- user => user.age < 30,
195
- { age: 26 }
196
- )
175
+ // Select with function returning SingleSelect - returns single item or undefined
176
+ const firstUser = items.select((s) => s.at(0))
177
+ const oldest = items.select((s) => s.sort((a, b) => b.age - a.age).at(0))
197
178
  ```
198
179
 
199
- #### Remove Operations
180
+ ##### `selectId(selector: Selector<E>): ItemId[] | ItemId | undefined`
200
181
 
201
- - **`remove(id)`** Removes a single entity by ID
202
- - **`removeMany(selector)`** – Removes multiple entities matching selector
182
+ Selects item IDs instead of items. Returns an array of IDs for multiple selections, or a single ID (or undefined) when using SingleSelect methods.
203
183
 
204
184
  ```typescript
205
- // Remove single entity by ID
206
- items.remove(1)
185
+ // Get IDs for filtered items - returns array
186
+ const adultIds = items.selectId((s) => s.filter((u) => u.age >= 30))
207
187
 
208
- // Remove multiple entities by IDs
209
- items.removeMany([1, 2])
188
+ // Get ID of first item - returns ItemId or undefined
189
+ const firstId = items.selectId((s) => s.at(0))
210
190
 
211
- // Remove multiple entities by predicate
212
- items.removeMany(user => user.age < 30)
191
+ // Get ID of oldest user - returns ItemId or undefined
192
+ const oldestId = items.selectId((s) => s.sort((a, b) => b.age - a.age).at(0))
213
193
  ```
214
194
 
215
- - **`clear()`** – Removes all entities
195
+ ##### `extractId(entity: E): ItemId`
196
+
197
+ Extracts the ID from an entity using the configured ID selector.
216
198
 
217
199
  ```typescript
218
- items.clear()
200
+ const user = { id: 5, name: 'Eve', age: 28 }
201
+ const id = items.extractId(user) // Returns: 5
202
+
203
+ // With custom selector
204
+ const products = new Items(items, {
205
+ selectId: (p) => p.sku
206
+ })
207
+ const product = { sku: 'ABC123', name: 'Widget' }
208
+ const sku = products.extractId(product) // Returns: 'ABC123'
219
209
  ```
220
210
 
221
- #### Filter Operations
211
+ ##### `clear(): Items<E>`
222
212
 
223
- - **`filter(selector)`** Returns new collection with only matching entities
213
+ Returns an empty Items instance with the same options.
224
214
 
225
215
  ```typescript
226
- // Filter by ID
227
- items.filter(1)
228
-
229
- // Filter by multiple IDs
230
- items.filter([1, 2])
231
-
232
- // Filter by predicate
233
- items.filter(user => user.age >= 30)
216
+ const empty = users.clear()
234
217
  ```
235
218
 
236
- #### Selectors
219
+ ##### `every(check: (entity: E) => boolean): boolean`
237
220
 
238
- - **`getIds()`** Returns array of IDs
239
- - **`getEntities()`** – Returns Map of entities
240
- - **`select(id)`** – Returns entity by ID or `undefined`
221
+ Tests whether all items pass the provided function.
241
222
 
242
223
  ```typescript
243
- items.getIds() // [1, 2]
244
- items.getEntities() // Map { 1 => {...}, 2 => {...} }
245
- items.select(1) // { id: 1, name: 'Alice' }
224
+ const allAdults = users.every((u) => u.age >= 18)
246
225
  ```
247
226
 
248
- #### Has/Check Operations
227
+ ##### `some(check: (entity: E) => boolean): boolean`
249
228
 
250
- - **`has(id)`** Checks if a single entity exists by ID
251
- - **`hasMany(selector)`** – Checks if entities exist matching selector
229
+ Tests whether at least one item passes the provided function.
252
230
 
253
231
  ```typescript
254
- // Check single entity by ID
255
- items.has(1) // true
256
- items.has(99) // false
232
+ const hasSenior = users.some((u) => u.age >= 65)
233
+ ```
257
234
 
258
- // Check multiple IDs (returns true only if ALL exist)
259
- items.hasMany([1, 2]) // true
260
- items.hasMany([1, 99]) // false
235
+ ##### `has(id: ItemId): boolean`
261
236
 
262
- // Check by predicate (returns true if ANY matches)
263
- items.hasMany(user => user.age >= 30) // true
264
- items.hasMany(user => user.age >= 100) // false
237
+ Checks if an item with the given ID exists.
265
238
 
266
- // Check single ID using array
267
- items.hasMany([1]) // true
239
+ ```typescript
240
+ const exists = users.has(1)
268
241
  ```
269
242
 
270
- - **`every(predicate)`** Returns `true` if ALL entities match the predicate
243
+ ##### `get(id: ItemId): E | undefined`
244
+
245
+ Gets an item by ID.
271
246
 
272
247
  ```typescript
273
- const items = new Items<number, User>([
274
- { id: 1, name: 'Alice', age: 25 },
275
- { id: 2, name: 'Bob', age: 30 },
276
- { id: 3, name: 'Charlie', age: 35 }
277
- ])
248
+ const user = users.get(1)
249
+ ```
278
250
 
279
- // Check if all users are adults
280
- items.every(user => user.age >= 18) // true
251
+ ##### `getIds(): ItemId[]`
281
252
 
282
- // Check if all users are seniors
283
- items.every(user => user.age >= 65) // false
253
+ Returns an array of all item IDs.
284
254
 
285
- // Returns true for empty collection
286
- new Items<number, User>().every(user => false) // true
255
+ ```typescript
256
+ const ids = users.getIds() // [1, 2, 3]
287
257
  ```
288
258
 
289
- - **`some(predicate)`** – Returns `true` if AT LEAST ONE entity matches the predicate
259
+ ##### `getEntities(): E[]`
260
+
261
+ Returns an array of all items.
290
262
 
291
263
  ```typescript
292
- const items = new Items<number, User>([
293
- { id: 1, name: 'Alice', age: 25 },
294
- { id: 2, name: 'Bob', age: 30 },
295
- { id: 3, name: 'Charlie', age: 35 }
296
- ])
264
+ const allUsers = users.getEntities()
265
+ ```
297
266
 
298
- // Check if any user is under 30
299
- items.some(user => user.age < 30) // true
267
+ ##### `length: number`
300
268
 
301
- // Check if any user is named 'Dave'
302
- items.some(user => user.name === 'Dave') // false
269
+ Gets the number of items in the collection.
303
270
 
304
- // Returns false for empty collection
305
- new Items<number, User>().some(user => true) // false
271
+ ```typescript
272
+ console.log(users.length) // 3
306
273
  ```
307
274
 
308
- #### Pagination
309
275
 
310
- - **`page(pageNumber, pageSize)`** – Returns paginated results
276
+ #### Static Methods
277
+
278
+ ##### `Items.compare<E>(base: Items<E>, to: Items<E>): ItemsDiff`
279
+
280
+ Compares two Items instances and returns the differences.
311
281
 
312
282
  ```typescript
313
- const result = items.page(0, 10)
283
+ const before = new Items(users)
284
+ const after = before.update(1, { age: 31 })
285
+
286
+ const diff = Items.compare(before, after)
287
+ console.log(diff)
314
288
  // {
315
- // items: [...],
316
- // page: 0,
317
- // pageSize: 10,
318
- // hasNext: true,
319
- // hasPrevious: false,
320
- // total: 25,
321
- // totalPages: 3
289
+ // added: [],
290
+ // removed: [],
291
+ // updated: [{ id: 1, changes: [...] }]
322
292
  // }
323
293
  ```
324
294
 
325
- #### Diff Detection
295
+ ### Select Class
326
296
 
327
- - **`diff(base)`** Compares with another collection and returns detailed changes
297
+ The Select class provides a fluent API for querying and transforming item collections.
328
298
 
329
- The diff method returns an object with three arrays:
330
- - `added`: IDs of entities that exist in the new collection but not in the base
331
- - `removed`: IDs of entities that exist in the base but not in the new collection
332
- - `updated`: Array of `ItemDiff` objects containing the ID and detailed property changes
299
+ #### Methods
333
300
 
334
- Each change object contains:
335
- - `key`: The property name
336
- - `type`: `'added'`, `'removed'`, or `'changed'`
337
- - `oldValue`: The old value (wrapped in a `DiffHashedObject` with a `value` property)
338
- - `newValue`: The new value (wrapped in a `DiffHashedObject` with a `value` property)
301
+ ##### `take(len: number): Select<E>`
302
+
303
+ Takes the first `n` items.
339
304
 
340
305
  ```typescript
341
- // Detect added entities
342
- const base = new Items([{ id: 1, name: 'Alice' }])
343
- const updated = base.insert([{ id: 2, name: 'Bob' }])
344
- const diff = updated.diff(base)
345
- // {
346
- // added: [2],
347
- // removed: [],
348
- // updated: []
349
- // }
306
+ users.select((s) => s.take(2))
307
+ ```
350
308
 
351
- // Detect removed entities
352
- const base = new Items([
353
- { id: 1, name: 'Alice' },
354
- { id: 2, name: 'Bob' }
355
- ])
356
- const updated = base.remove(2)
357
- const diff = updated.diff(base)
358
- // {
359
- // added: [],
360
- // removed: [2],
361
- // updated: []
362
- // }
309
+ ##### `skip(len: number): Select<E>`
363
310
 
364
- // Detect property changes
365
- const base = new Items([{ id: 1, name: 'Alice', age: 25 }])
366
- const updated = base.update(1, { age: 26 })
367
- const diff = updated.diff(base)
368
- // {
369
- // added: [],
370
- // removed: [],
371
- // updated: [
372
- // {
373
- // id: 1,
374
- // changes: [
375
- // {
376
- // key: 'age',
377
- // type: 'changed',
378
- // oldValue: { value: 25, ... },
379
- // newValue: { value: 26, ... }
380
- // }
381
- // ]
382
- // }
383
- // ]
384
- // }
311
+ Skips the first `n` items.
385
312
 
386
- // Detect property addition
387
- const base = new Items([{ id: 1, name: 'Alice' }])
388
- const updated = base.update(1, { age: 25 })
389
- const diff = updated.diff(base)
390
- // {
391
- // added: [],
392
- // removed: [],
393
- // updated: [
394
- // {
395
- // id: 1,
396
- // changes: [
397
- // { key: 'age', type: 'added', newValue: { value: 25, ... } }
398
- // ]
399
- // }
400
- // ]
401
- // }
313
+ ```typescript
314
+ users.select((s) => s.skip(1))
315
+ ```
402
316
 
403
- // Detect property removal (using set to replace completely)
404
- const base = new Items([{ id: 1, name: 'Alice', age: 25 }])
405
- const updated = base.set([{ id: 1, name: 'Alice' }])
406
- const diff = updated.diff(base)
407
- // {
408
- // added: [],
409
- // removed: [],
410
- // updated: [
411
- // {
412
- // id: 1,
413
- // changes: [
414
- // { key: 'age', type: 'removed', oldValue: { value: 25, ... } }
415
- // ]
416
- // }
417
- // ]
418
- // }
317
+ ##### `filter(testFn: (entry: E, id: ItemId, index: number) => boolean): Select<E>`
419
318
 
420
- // Detect multiple changes at once
421
- const base = new Items([
422
- { id: 1, name: 'Alice', age: 25 },
423
- { id: 2, name: 'Bob', age: 30 },
424
- { id: 3, name: 'Charlie', age: 35 }
425
- ])
426
- const updated = base
427
- .remove(3) // Remove Charlie
428
- .update(1, { age: 26 }) // Update Alice's age
429
- .insert([{ id: 4, name: 'Dave', age: 40 }]) // Add Dave
430
-
431
- const diff = updated.diff(base)
432
- // {
433
- // added: [4],
434
- // removed: [3],
435
- // updated: [
436
- // {
437
- // id: 1,
438
- // changes: [
439
- // {
440
- // key: 'age',
441
- // type: 'changed',
442
- // oldValue: { value: 25, ... },
443
- // newValue: { value: 26, ... }
444
- // }
445
- // ]
446
- // }
447
- // ]
448
- // }
319
+ Filters items based on a predicate function.
449
320
 
450
- // Working with diff results
451
- const diff = updated.diff(base)
452
-
453
- // Access changed values
454
- diff.updated.forEach(item => {
455
- console.log(`Entity ${item.id} was updated`)
456
- item.changes.forEach(change => {
457
- if (change.type === 'changed') {
458
- console.log(` ${change.key}: ${change.oldValue?.value} -> ${change.newValue?.value}`)
459
- } else if (change.type === 'added') {
460
- console.log(` ${change.key}: added with value ${change.newValue?.value}`)
461
- } else if (change.type === 'removed') {
462
- console.log(` ${change.key}: removed (was ${change.oldValue?.value})`)
463
- }
464
- })
465
- })
321
+ ```typescript
322
+ users.select((s) => s.filter((user, id, index) => user.age > 25))
323
+ ```
466
324
 
467
- // Nested objects are also tracked
468
- interface UserWithAddress {
469
- id: number
470
- name: string
471
- address: { city: string; country: string }
472
- }
325
+ ##### `revert(): Select<E>`
473
326
 
474
- const base = new Items<number, UserWithAddress>([
475
- { id: 1, name: 'Alice', address: { city: 'NYC', country: 'USA' } }
476
- ])
477
- const updated = base.update(1, {
478
- address: { city: 'LA', country: 'USA' }
479
- })
480
- const diff = updated.diff(base)
481
- // Detects changes in nested object properties
327
+ Reverses the order of items.
328
+
329
+ ```typescript
330
+ users.select((s) => s.revert())
482
331
  ```
483
332
 
484
- #### Iteration
333
+ ##### `sort(sortFn: (a: E, b: E) => number): Select<E>`
485
334
 
486
- Items implements the iterable protocol:
335
+ Sorts items using a comparator function.
487
336
 
488
337
  ```typescript
489
- for (const user of items) {
490
- console.log(user.name)
491
- }
338
+ users.select((s) => s.sort((a, b) => a.age - b.age))
339
+ ```
340
+
341
+ ##### `at(index: number): SingleSelect<E>`
342
+
343
+ Returns a SingleSelect for the item at the given index.
492
344
 
493
- // Or use spread
494
- const array = [...items]
345
+ ```typescript
346
+ const select = new Select(users.getIds(), users)
347
+ const single = select.at(0)
495
348
  ```
496
349
 
497
- ## Options
350
+ ##### `from(entities: Iterable<E>): Select<E>`
351
+
352
+ Creates a new Select from the given entities.
353
+
354
+ ```typescript
355
+ const select = new Select(users.getIds(), users)
356
+ const newSelect = select.from([
357
+ { id: 2, name: 'Bob', age: 25 }
358
+ ])
359
+ ```
498
360
 
499
- ### `selectId`
361
+ ##### `on(entry: E): SingleSelect<E>`
500
362
 
501
- Custom ID selector function. Defaults to `entity.id`.
363
+ Creates a SingleSelect for the given entity.
502
364
 
503
365
  ```typescript
504
- interface Book {
505
- isbn: string
506
- title: string
507
- }
366
+ const select = new Select(users.getIds(), users)
367
+ const single = select.on({ id: 1, name: 'Alice', age: 30 })
368
+ ```
508
369
 
509
- const items = new Items<string, Book>([], {
510
- selectId: (book) => book.isbn
511
- })
370
+ ### Chaining Selectors
371
+
372
+ Selectors can be chained for powerful queries:
373
+
374
+ ```typescript
375
+ const result = users.select((s) =>
376
+ s.filter((u) => u.age >= 25)
377
+ .sort((a, b) => a.age - b.age)
378
+ .skip(1)
379
+ .take(2)
380
+ )
512
381
  ```
513
382
 
514
- ### `sortComparer`
383
+ ## Diff Tracking
515
384
 
516
- Optional sort function. Set to `false` to disable sorting (default).
385
+ Track changes between two Items instances:
517
386
 
518
387
  ```typescript
519
- // Sort by name ascending
520
- const items = new Items<number, User>([], {
521
- sortComparer: (a, b) => a.name.localeCompare(b.name)
522
- })
388
+ import { Items, itemsDiff } from '@wszerad/items'
523
389
 
524
- // Sort by age descending
525
- const items = new Items<number, User>([], {
526
- sortComparer: (a, b) => b.age - a.age
527
- })
390
+ const before = new Items([
391
+ { id: 1, name: 'Alice', age: 30 },
392
+ { id: 2, name: 'Bob', age: 25 }
393
+ ])
394
+
395
+ const after = before
396
+ .update(1, { age: 31 })
397
+ .add([{ id: 3, name: 'Charlie', age: 35 }])
398
+ .remove(2)
399
+
400
+ const diff = itemsDiff(before, after)
401
+ console.log(diff)
402
+ // {
403
+ // added: [3],
404
+ // removed: [2],
405
+ // updated: [{ id: 1, changes: [...] }]
406
+ // }
528
407
  ```
529
408
 
530
- ### Using every() and some()
409
+ ## Advanced Usage
410
+
411
+ ### Custom ID Selector
412
+
413
+ Use a custom field as the ID:
531
414
 
532
415
  ```typescript
533
416
  interface Product {
534
- id: number
417
+ sku: string
535
418
  name: string
536
419
  price: number
537
- inStock: boolean
538
420
  }
539
421
 
540
- const products = new Items<number, Product>([
541
- { id: 1, name: 'Laptop', price: 999, inStock: true },
542
- { id: 2, name: 'Mouse', price: 29, inStock: true },
543
- { id: 3, name: 'Keyboard', price: 79, inStock: false }
544
- ])
545
-
546
- // Check if all products are in stock
547
- const allInStock = products.every(p => p.inStock) // false
422
+ const products = new Items<Product>(
423
+ [
424
+ { sku: 'ABC123', name: 'Widget', price: 9.99 },
425
+ { sku: 'XYZ789', name: 'Gadget', price: 19.99 }
426
+ ],
427
+ { selectId: (product) => product.sku }
428
+ )
548
429
 
549
- // Check if any product is expensive (over $500)
550
- const hasExpensive = products.some(p => p.price > 500) // true
430
+ const widget = products.get('ABC123')
431
+ ```
551
432
 
552
- // Check if all products have names
553
- const allNamed = products.every(p => p.name.length > 0) // true
433
+ ### Automatic Sorting
554
434
 
555
- // Check if any product is cheap (under $30)
556
- const hasCheap = products.some(p => p.price < 30) // true
435
+ Keep items sorted automatically:
557
436
 
558
- // Validation example
559
- const validateProducts = (items: Items<number, Product>) => {
560
- const errors: string[] = []
561
-
562
- if (!items.every(p => p.price > 0)) {
563
- errors.push('All products must have positive prices')
564
- }
565
-
566
- if (!items.every(p => p.name.trim().length > 0)) {
567
- errors.push('All products must have names')
568
- }
569
-
570
- if (items.some(p => p.price > 10000)) {
571
- errors.push('Warning: Some products are very expensive')
437
+ ```typescript
438
+ const users = new Items(
439
+ [
440
+ { id: 3, name: 'Charlie', age: 35 },
441
+ { id: 1, name: 'Alice', age: 30 },
442
+ { id: 2, name: 'Bob', age: 25 }
443
+ ],
444
+ {
445
+ sortComparer: (a, b) => a.age - b.age
572
446
  }
573
-
574
- return errors
575
- }
576
- ```
577
-
578
- ## Selectors
447
+ )
579
448
 
580
- Methods come in two flavors:
449
+ console.log(users.getIds()) // [2, 1, 3] - sorted by age
450
+ ```
581
451
 
582
- ### Single Entity Methods
583
- Accept a single ID directly:
584
- - `insert(entity)`, `upsert(entity)`, `set(entity)`
585
- - `update(id, updater)` – `items.update(1, { age: 26 })`
586
- - `remove(id)` – `items.remove(1)`
587
- - `has(id)` – `items.has(1)`
452
+ ### Complex Updates
588
453
 
589
- ### Batch Methods
590
- Accept a `Selector` parameter that can be:
591
- 1. **Array of IDs** – `items.removeMany([1, 2, 3])`
592
- 2. **Predicate function** – `items.filter(user => user.age >= 30)`
454
+ Perform complex transformations:
593
455
 
594
- Methods with `*Many` suffix always operate on multiple entities:
595
- - `insertMany(entities)`, `upsertMany(entities)`, `setMany(entities)`
596
- - `updateMany(selector, updater)`
597
- - `removeMany(selector)`
598
- - `filter(selector)`, `hasMany(selector)`
456
+ ```typescript
457
+ // Increment age for all users over 25
458
+ const updated = users.update(
459
+ (s) => s.filter((u) => u.age > 25),
460
+ (user) => ({
461
+ ...user!,
462
+ age: user!.age + 1,
463
+ senior: user!.age >= 65
464
+ })
465
+ )
466
+ ```
599
467
 
600
- ## Immutability
468
+ ### Immutability
601
469
 
602
- All operations return a **new** `Items` instance. Original instance is never modified.
470
+ All operations are immutable:
603
471
 
604
472
  ```typescript
605
- const items1 = new Items<number, User>()
606
- const items2 = items1.insert({ id: 1, name: 'Alice' })
473
+ const original = new Items([
474
+ { id: 1, name: 'Alice', age: 30 }
475
+ ])
476
+
477
+ const updated = original.update(1, { age: 31 })
607
478
 
608
- console.log(items1.length) // 0
609
- console.log(items2.length) // 1
479
+ console.log(original.get(1)?.age) // 30 - unchanged
480
+ console.log(updated.get(1)?.age) // 31 - new instance
610
481
  ```
611
482
 
612
- ## TypeScript
483
+ ### Iteration
613
484
 
614
- Full type safety with generics:
485
+ Items are iterable:
615
486
 
616
487
  ```typescript
617
- interface User {
618
- id: number
619
- name: string
620
- age: number
488
+ for (const user of users) {
489
+ console.log(user.name)
621
490
  }
622
491
 
623
- const items = new Items<number, User>()
624
-
625
- // ✅ Type-safe
626
- items.insert({ id: 1, name: 'Alice', age: 25 })
627
-
628
- // ❌ Type error
629
- items.insert({ id: 1, name: 'Alice' }) // Missing 'age'
492
+ const array = Array.from(users)
493
+ const names = [...users].map(u => u.name)
630
494
  ```
631
495
 
632
- ## Examples
496
+ ### Single Item Selection
633
497
 
634
- ### Basic CRUD
498
+ The `select()` method automatically returns a single item (or undefined) when using `at()` or `on()` methods:
635
499
 
636
500
  ```typescript
637
- import { Items } from 'items'
638
-
639
- interface Todo {
640
- id: number
641
- text: string
642
- completed: boolean
643
- }
501
+ const users = new Items<User>([
502
+ { id: 1, name: 'Alice', age: 30 },
503
+ { id: 2, name: 'Bob', age: 25 },
504
+ { id: 3, name: 'Charlie', age: 35 }
505
+ ])
644
506
 
645
- let todos = new Items<number, Todo>()
507
+ // Select by index - returns single User or undefined
508
+ const firstUser = users.select((s) => s.at(0))
509
+ console.log(firstUser?.name) // 'Alice'
646
510
 
647
- // Add single todo
648
- todos = todos.insert({ id: 1, text: 'Learn Items', completed: false })
511
+ const secondUser = users.select((s) => s.at(1))
512
+ console.log(secondUser?.name) // 'Bob'
649
513
 
650
- // Add multiple todos
651
- todos = todos.insertMany([
652
- { id: 2, text: 'Build app', completed: false },
653
- { id: 3, text: 'Deploy', completed: false }
654
- ])
514
+ // Select by ID - returns single User or undefined
515
+ const user = users.select(2)
516
+ console.log(user?.name) // 'Bob'
655
517
 
656
- // Update (merges with existing)
657
- todos = todos.update(1, { completed: true })
518
+ // Out of bounds returns undefined
519
+ const notFound = users.select((s) => s.at(10))
520
+ console.log(notFound) // undefined
658
521
 
659
- // Filter
660
- const completed = todos.filter(todo => todo.completed)
522
+ // Chain operations before selecting single item
523
+ const oldestUser = users.select((s) =>
524
+ s.sort((a, b) => b.age - a.age).at(0)
525
+ )
526
+ console.log(oldestUser?.name) // 'Charlie' (age 35)
661
527
 
662
- // Remove
663
- todos = todos.remove(1)
528
+ // Get just the ID instead of the full item
529
+ const oldestId = users.selectId((s) =>
530
+ s.sort((a, b) => b.age - a.age).at(0)
531
+ )
532
+ console.log(oldestId) // 3
664
533
  ```
665
534
 
666
- ### Understanding insert, upsert, set, and update
535
+ ## TypeScript Support
667
536
 
668
- ```typescript
669
- import { Items } from 'items'
537
+ Full TypeScript support with generics:
670
538
 
539
+ ```typescript
671
540
  interface User {
672
541
  id: number
673
542
  name: string
674
- email?: string
675
- age?: number
543
+ age: number
676
544
  }
677
545
 
678
- // Start with a user
679
- let users = new Items<number, User>([
680
- { id: 1, name: 'Alice', email: 'alice@example.com', age: 25 }
681
- ])
546
+ const users = new Items<User>() // Fully typed
682
547
 
683
- // INSERT - only adds if doesn't exist, skips if exists
684
- users = users.insert({ id: 1, name: 'Alice Updated', age: 30 })
685
- // Result: { id: 1, name: 'Alice', email: 'alice@example.com', age: 25 }
686
- // Note: Original entity unchanged because id: 1 already exists
687
-
688
- users = users.insert({ id: 2, name: 'Bob' })
689
- // Result: Adds Bob with id: 2 since it doesn't exist
690
-
691
- // UPSERT - merges properties (adds new, extends existing)
692
- users = users.upsert({ id: 1, name: 'Alicia', age: 26 })
693
- // Result: { id: 1, name: 'Alicia', email: 'alice@example.com', age: 26 }
694
- // Note: name and age updated, email preserved!
695
-
696
- // SET - completely replaces entity
697
- users = users.set({ id: 1, name: 'Alice' })
698
- // Result: { id: 1, name: 'Alice' }
699
- // Note: email and age are removed!
700
-
701
- // UPDATE - merges partial update with existing entity
702
- users = users.update(1, { age: 27 })
703
- // Result: { id: 1, name: 'Alice', age: 27 }
704
- // Note: age added, name preserved
705
-
706
- // Batch operations with *Many methods
707
- users = users.insertMany([
708
- { id: 3, name: 'Charlie' },
709
- { id: 4, name: 'Dave' }
710
- ])
711
-
712
- users = users.upsertMany([
713
- { id: 1, age: 28 },
714
- { id: 3, email: 'charlie@example.com' }
715
- ])
716
-
717
- users = users.setMany([
718
- { id: 2, name: 'Robert', age: 35 }
719
- ])
548
+ // Type inference works automatically
549
+ const names: string[] = users
550
+ .select((s) => s.filter((u) => u.age >= 30))
551
+ .map(u => u.name)
720
552
  ```
721
553
 
722
- ### Checking Entity Existence
554
+ ## Types
723
555
 
724
556
  ```typescript
725
- interface Product {
726
- sku: string
727
- name: string
728
- price: number
729
- }
557
+ type ItemId = string | number
730
558
 
731
- const products = new Items<string, Product>([], {
732
- selectId: (product) => product.sku
733
- })
559
+ type Selector<E> =
560
+ | ((selector: BaseSelect<E>) => BaseSelect<E>)
561
+ | ItemId
562
+ | Iterable<ItemId>
734
563
 
735
- const updated = products.insert([
736
- { sku: 'ABC-123', name: 'Widget', price: 19.99 }
737
- ])
564
+ type Updater<E> =
565
+ | ((entity: E | undefined) => E)
566
+ | Partial<E>
738
567
 
739
- console.log(updated.select('ABC-123'))
568
+ type ItemsOptions<E> = {
569
+ selectId?: (entity: E) => ItemId
570
+ sortComparer?: false | ((a: E, b: E) => number)
571
+ }
572
+
573
+ interface ItemsDiff {
574
+ added: ItemId[]
575
+ removed: ItemId[]
576
+ updated: ItemDiff[]
577
+ }
578
+
579
+ interface ItemDiff {
580
+ id: ItemId
581
+ changes: any[]
582
+ }
740
583
  ```
741
584
 
742
- ### With Sorting
585
+ ## License
743
586
 
744
- ```typescript
745
- const items = new Items<number, User>(
746
- [
747
- { id: 3, name: 'Charlie', age: 35 },
748
- { id: 1, name: 'Alice', age: 25 },
749
- { id: 2, name: 'Bob', age: 30 }
750
- ],
751
- { sortComparer: (a, b) => a.name.localeCompare(b.name) }
752
- )
587
+ MIT © Wszerad Martynowski
753
588
 
754
- console.log(items.getIds()) // [1, 2, 3] - sorted by name
755
- ```
589
+ ## Contributing
756
590
 
757
- ---
591
+ Contributions are welcome! Please feel free to submit a Pull Request.
758
592
 
759
- ## Development
593
+ ## Repository
760
594
 
761
- ### Scripts
595
+ https://github.com/wszerad/items
762
596
 
763
- - `npm test` – runs tests (Vitest)
764
- - `npm run test:watch` – watch mode
765
- - `npm run build` – typecheck + bundling (tsc + tsdown)
766
- - `npm run typecheck` – TypeScript type checking (