mvc-kit 2.12.0 → 2.12.1
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/agent-config/bin/postinstall.mjs +5 -3
- package/agent-config/bin/setup.mjs +3 -4
- package/agent-config/claude-code/agents/mvc-kit-architect.md +14 -0
- package/agent-config/claude-code/skills/guide/api-reference.md +24 -2
- package/agent-config/lib/install-claude.mjs +10 -33
- package/dist/Model.cjs +9 -1
- package/dist/Model.cjs.map +1 -1
- package/dist/Model.d.ts +1 -1
- package/dist/Model.d.ts.map +1 -1
- package/dist/Model.js +9 -1
- package/dist/Model.js.map +1 -1
- package/dist/ViewModel.cjs +9 -1
- package/dist/ViewModel.cjs.map +1 -1
- package/dist/ViewModel.d.ts +1 -1
- package/dist/ViewModel.d.ts.map +1 -1
- package/dist/ViewModel.js +9 -1
- package/dist/ViewModel.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/mvc-kit.cjs +3 -0
- package/dist/mvc-kit.cjs.map +1 -1
- package/dist/mvc-kit.js +3 -0
- package/dist/mvc-kit.js.map +1 -1
- package/dist/produceDraft.cjs +105 -0
- package/dist/produceDraft.cjs.map +1 -0
- package/dist/produceDraft.d.ts +19 -0
- package/dist/produceDraft.d.ts.map +1 -0
- package/dist/produceDraft.js +105 -0
- package/dist/produceDraft.js.map +1 -0
- package/package.json +4 -2
- package/src/Channel.md +408 -0
- package/src/Channel.test.ts +957 -0
- package/src/Channel.ts +429 -0
- package/src/Collection.md +533 -0
- package/src/Collection.test.ts +1559 -0
- package/src/Collection.ts +653 -0
- package/src/Controller.md +306 -0
- package/src/Controller.test.ts +380 -0
- package/src/Controller.ts +90 -0
- package/src/EventBus.md +308 -0
- package/src/EventBus.test.ts +295 -0
- package/src/EventBus.ts +110 -0
- package/src/Feed.md +218 -0
- package/src/Feed.test.ts +442 -0
- package/src/Feed.ts +101 -0
- package/src/Model.md +524 -0
- package/src/Model.test.ts +642 -0
- package/src/Model.ts +260 -0
- package/src/Pagination.md +168 -0
- package/src/Pagination.test.ts +244 -0
- package/src/Pagination.ts +92 -0
- package/src/Pending.md +380 -0
- package/src/Pending.test.ts +1719 -0
- package/src/Pending.ts +390 -0
- package/src/PersistentCollection.md +183 -0
- package/src/PersistentCollection.test.ts +649 -0
- package/src/PersistentCollection.ts +375 -0
- package/src/Resource.ViewModel.test.ts +503 -0
- package/src/Resource.md +239 -0
- package/src/Resource.test.ts +786 -0
- package/src/Resource.ts +231 -0
- package/src/Selection.md +155 -0
- package/src/Selection.test.ts +326 -0
- package/src/Selection.ts +117 -0
- package/src/Service.md +440 -0
- package/src/Service.test.ts +241 -0
- package/src/Service.ts +72 -0
- package/src/Sorting.md +170 -0
- package/src/Sorting.test.ts +334 -0
- package/src/Sorting.ts +135 -0
- package/src/Trackable.md +166 -0
- package/src/Trackable.test.ts +236 -0
- package/src/Trackable.ts +129 -0
- package/src/ViewModel.async.test.ts +813 -0
- package/src/ViewModel.derived.test.ts +1583 -0
- package/src/ViewModel.md +1111 -0
- package/src/ViewModel.test.ts +1236 -0
- package/src/ViewModel.ts +800 -0
- package/src/bindPublicMethods.test.ts +126 -0
- package/src/bindPublicMethods.ts +48 -0
- package/src/env.d.ts +5 -0
- package/src/errors.test.ts +155 -0
- package/src/errors.ts +133 -0
- package/src/index.ts +49 -0
- package/src/produceDraft.md +90 -0
- package/src/produceDraft.test.ts +394 -0
- package/src/produceDraft.ts +168 -0
- package/src/react/components/CardList.md +97 -0
- package/src/react/components/CardList.test.tsx +142 -0
- package/src/react/components/CardList.tsx +68 -0
- package/src/react/components/DataTable.md +179 -0
- package/src/react/components/DataTable.test.tsx +599 -0
- package/src/react/components/DataTable.tsx +267 -0
- package/src/react/components/InfiniteScroll.md +116 -0
- package/src/react/components/InfiniteScroll.test.tsx +218 -0
- package/src/react/components/InfiniteScroll.tsx +70 -0
- package/src/react/components/types.ts +90 -0
- package/src/react/derived.test.tsx +261 -0
- package/src/react/guards.ts +24 -0
- package/src/react/index.ts +40 -0
- package/src/react/provider.test.tsx +143 -0
- package/src/react/provider.tsx +55 -0
- package/src/react/strict-mode.test.tsx +266 -0
- package/src/react/types.ts +25 -0
- package/src/react/use-event-bus.md +214 -0
- package/src/react/use-event-bus.test.tsx +168 -0
- package/src/react/use-event-bus.ts +40 -0
- package/src/react/use-instance.md +204 -0
- package/src/react/use-instance.test.tsx +350 -0
- package/src/react/use-instance.ts +60 -0
- package/src/react/use-local.md +457 -0
- package/src/react/use-local.rapid-remount.test.tsx +503 -0
- package/src/react/use-local.test.tsx +692 -0
- package/src/react/use-local.ts +165 -0
- package/src/react/use-model.md +364 -0
- package/src/react/use-model.test.tsx +394 -0
- package/src/react/use-model.ts +161 -0
- package/src/react/use-singleton.md +415 -0
- package/src/react/use-singleton.test.tsx +296 -0
- package/src/react/use-singleton.ts +69 -0
- package/src/react/use-subscribe-only.ts +39 -0
- package/src/react/use-teardown.md +169 -0
- package/src/react/use-teardown.test.tsx +86 -0
- package/src/react/use-teardown.ts +27 -0
- package/src/react-native/NativeCollection.test.ts +250 -0
- package/src/react-native/NativeCollection.ts +138 -0
- package/src/react-native/index.ts +1 -0
- package/src/singleton.md +310 -0
- package/src/singleton.test.ts +204 -0
- package/src/singleton.ts +70 -0
- package/src/types.ts +70 -0
- package/src/walkPrototypeChain.ts +22 -0
- package/src/web/IndexedDBCollection.test.ts +235 -0
- package/src/web/IndexedDBCollection.ts +66 -0
- package/src/web/WebStorageCollection.test.ts +214 -0
- package/src/web/WebStorageCollection.ts +116 -0
- package/src/web/idb.ts +184 -0
- package/src/web/index.ts +2 -0
- package/src/wrapAsyncMethods.ts +249 -0
|
@@ -0,0 +1,533 @@
|
|
|
1
|
+
# Collection
|
|
2
|
+
|
|
3
|
+
A reactive typed array that serves as the shared in-memory data cache. Collections hold entity data, notify subscribers on mutation, and provide query methods for read access.
|
|
4
|
+
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
## Role
|
|
8
|
+
|
|
9
|
+
Collections are the single source of truth for entity data. Multiple ViewModels can subscribe to the same Collection and stay in sync without coordinating with each other. Services fetch data, ViewModels write it into Collections, and ViewModel getters derive views from it.
|
|
10
|
+
|
|
11
|
+
```
|
|
12
|
+
Service → ViewModel → Collection → (subscribers notified) → other ViewModels react
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
Collections are never accessed directly by components.
|
|
16
|
+
|
|
17
|
+
---
|
|
18
|
+
|
|
19
|
+
## Type Constraint
|
|
20
|
+
|
|
21
|
+
Every item must have an `id` field of type `string | number`. This is enforced by the generic constraint:
|
|
22
|
+
|
|
23
|
+
```typescript
|
|
24
|
+
class Collection<T extends { id: string | number }>
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
The `id` is used for indexing (`get`, `has`) and targeted mutations (`update`, `remove`).
|
|
28
|
+
|
|
29
|
+
---
|
|
30
|
+
|
|
31
|
+
## Creating a Collection
|
|
32
|
+
|
|
33
|
+
### Empty
|
|
34
|
+
|
|
35
|
+
```typescript
|
|
36
|
+
const collection = new Collection<Todo>();
|
|
37
|
+
// collection.items → []
|
|
38
|
+
// collection.length → 0
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
### With Initial Items
|
|
42
|
+
|
|
43
|
+
```typescript
|
|
44
|
+
const collection = new Collection<Todo>([
|
|
45
|
+
{ id: '1', text: 'Buy milk', done: false },
|
|
46
|
+
{ id: '2', text: 'Walk dog', done: true },
|
|
47
|
+
]);
|
|
48
|
+
// collection.length → 2
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
### As a Singleton (typical usage)
|
|
52
|
+
|
|
53
|
+
Define a thin subclass for singleton identity:
|
|
54
|
+
|
|
55
|
+
```typescript
|
|
56
|
+
export class TodosCollection extends Collection<Todo> {}
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
Resolve inside ViewModels:
|
|
60
|
+
|
|
61
|
+
```typescript
|
|
62
|
+
private collection = singleton(TodosCollection);
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
The subclass gives the singleton registry a unique class key. Don't add methods — query logic belongs in ViewModel getters.
|
|
66
|
+
|
|
67
|
+
---
|
|
68
|
+
|
|
69
|
+
## Properties
|
|
70
|
+
|
|
71
|
+
| Property | Type | Description |
|
|
72
|
+
|---|---|---|
|
|
73
|
+
| `items` | `T[]` | The current frozen array of items |
|
|
74
|
+
| `state` | `T[]` | Alias for `items` (Subscribable compatibility) |
|
|
75
|
+
| `length` | `number` | Number of items |
|
|
76
|
+
| `disposed` | `boolean` | Whether `dispose()` has been called |
|
|
77
|
+
| `disposeSignal` | `AbortSignal` | Lazily created; aborted on `dispose()` |
|
|
78
|
+
|
|
79
|
+
`items` is always a frozen array (`Object.freeze`). You cannot mutate it in place — use the CRUD methods below.
|
|
80
|
+
|
|
81
|
+
---
|
|
82
|
+
|
|
83
|
+
## CRUD Methods
|
|
84
|
+
|
|
85
|
+
All CRUD methods notify subscribers when they produce an actual change. No-op mutations (removing an ID that doesn't exist, updating with identical values, clearing an already-empty collection) are silent.
|
|
86
|
+
|
|
87
|
+
### add(...items)
|
|
88
|
+
|
|
89
|
+
Appends one or more items. Items with IDs that already exist in the collection are silently skipped. Duplicate IDs within a single batch are also deduplicated (first occurrence wins). Notifies once.
|
|
90
|
+
|
|
91
|
+
```typescript
|
|
92
|
+
collection.add({ id: '1', text: 'First', done: false });
|
|
93
|
+
collection.add(
|
|
94
|
+
{ id: '2', text: 'Second', done: false },
|
|
95
|
+
{ id: '3', text: 'Third', done: true },
|
|
96
|
+
);
|
|
97
|
+
|
|
98
|
+
// Duplicates are skipped — no error, no notification:
|
|
99
|
+
collection.add({ id: '1', text: 'Duplicate', done: true }); // no-op
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
No-op when called with zero arguments or when all items already exist.
|
|
103
|
+
|
|
104
|
+
### upsert(...items)
|
|
105
|
+
|
|
106
|
+
Add-or-replace by ID. Existing items are replaced in-place (preserving array position); new items are appended. Deduplicates input — last occurrence wins. Single notification. No-op if nothing changed (reference comparison).
|
|
107
|
+
|
|
108
|
+
```typescript
|
|
109
|
+
// Add new items:
|
|
110
|
+
collection.upsert({ id: '4', text: 'New', done: false });
|
|
111
|
+
|
|
112
|
+
// Replace existing + add new in one call:
|
|
113
|
+
collection.upsert(
|
|
114
|
+
{ id: '1', text: 'Updated', done: true }, // replaces in position
|
|
115
|
+
{ id: '5', text: 'Also new', done: false }, // appended
|
|
116
|
+
);
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
This is the primary method for **paginated or incremental data loading** — each page of results can be upserted without destroying data from previous pages or other ViewModels:
|
|
120
|
+
|
|
121
|
+
```typescript
|
|
122
|
+
async loadPage(page: number) {
|
|
123
|
+
const data = await this.service.getPage(page, this.disposeSignal);
|
|
124
|
+
this.collection.upsert(...data);
|
|
125
|
+
}
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
Unlike `update()` which does a partial merge, `upsert()` does a **full replacement** of the item object.
|
|
129
|
+
|
|
130
|
+
No-op when called with zero arguments or when all items are reference-identical to existing ones.
|
|
131
|
+
|
|
132
|
+
### remove(...ids)
|
|
133
|
+
|
|
134
|
+
Removes items by ID. Accepts one or more IDs. Notifies once.
|
|
135
|
+
|
|
136
|
+
```typescript
|
|
137
|
+
collection.remove('1');
|
|
138
|
+
collection.remove('2', '3');
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
No-op when called with zero arguments or when none of the IDs match.
|
|
142
|
+
|
|
143
|
+
### update(id, changes)
|
|
144
|
+
|
|
145
|
+
Applies a partial update to an existing item. Preserves the original `id` even if `changes` includes an `id` field. Notifies once.
|
|
146
|
+
|
|
147
|
+
```typescript
|
|
148
|
+
collection.update('1', { done: true });
|
|
149
|
+
collection.update('1', { text: 'Updated', done: false });
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
No-op when:
|
|
153
|
+
- The `id` is not found.
|
|
154
|
+
- Every value in `changes` is identical to the current item (shallow equality check).
|
|
155
|
+
|
|
156
|
+
### reset(items)
|
|
157
|
+
|
|
158
|
+
Replaces the entire collection. Rebuilds the internal index. Always notifies (even if the new array has the same content).
|
|
159
|
+
|
|
160
|
+
```typescript
|
|
161
|
+
collection.reset([
|
|
162
|
+
{ id: '4', text: 'Fresh start', done: false },
|
|
163
|
+
]);
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
Use `reset()` for full replacement loads. For paginated or incremental loads, prefer `upsert()` which preserves existing data.
|
|
167
|
+
|
|
168
|
+
### clear()
|
|
169
|
+
|
|
170
|
+
Removes all items. Notifies once.
|
|
171
|
+
|
|
172
|
+
```typescript
|
|
173
|
+
collection.clear();
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
No-op when the collection is already empty.
|
|
177
|
+
|
|
178
|
+
---
|
|
179
|
+
|
|
180
|
+
## Query Methods
|
|
181
|
+
|
|
182
|
+
Query methods are pure reads. They never notify subscribers.
|
|
183
|
+
|
|
184
|
+
### get(id): T | undefined
|
|
185
|
+
|
|
186
|
+
O(1) lookup by ID via internal index.
|
|
187
|
+
|
|
188
|
+
```typescript
|
|
189
|
+
const user = collection.get(2);
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
### has(id): boolean
|
|
193
|
+
|
|
194
|
+
O(1) existence check.
|
|
195
|
+
|
|
196
|
+
```typescript
|
|
197
|
+
if (collection.has(userId)) { /* ... */ }
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
### find(predicate): T | undefined
|
|
201
|
+
|
|
202
|
+
Returns the first item matching the predicate.
|
|
203
|
+
|
|
204
|
+
```typescript
|
|
205
|
+
const admin = collection.find(u => u.role === 'admin');
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
### filter(predicate): T[]
|
|
209
|
+
|
|
210
|
+
Returns all items matching the predicate.
|
|
211
|
+
|
|
212
|
+
```typescript
|
|
213
|
+
const active = collection.filter(u => u.status === 'active');
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
### sorted(compareFn): T[]
|
|
217
|
+
|
|
218
|
+
Returns a sorted copy. Does not modify the collection.
|
|
219
|
+
|
|
220
|
+
```typescript
|
|
221
|
+
const byAge = collection.sorted((a, b) => a.age - b.age);
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
### map(fn): U[]
|
|
225
|
+
|
|
226
|
+
Transforms items into a new array.
|
|
227
|
+
|
|
228
|
+
```typescript
|
|
229
|
+
const names = collection.map(u => u.name);
|
|
230
|
+
```
|
|
231
|
+
|
|
232
|
+
---
|
|
233
|
+
|
|
234
|
+
## Subscriptions
|
|
235
|
+
|
|
236
|
+
Collection implements `Subscribable<T[]>`. Listeners receive `(currentItems, previousItems)` on every mutation.
|
|
237
|
+
|
|
238
|
+
```typescript
|
|
239
|
+
const unsubscribe = collection.subscribe((items, prev) => {
|
|
240
|
+
console.log(`Changed from ${prev.length} to ${items.length} items`);
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
// Later:
|
|
244
|
+
unsubscribe();
|
|
245
|
+
```
|
|
246
|
+
|
|
247
|
+
In practice, ViewModels subscribe using `this.subscribeTo(collection, callback)` which auto-cleans up on dispose.
|
|
248
|
+
|
|
249
|
+
Subscribing to a disposed collection returns a no-op unsubscribe function (does not throw).
|
|
250
|
+
|
|
251
|
+
---
|
|
252
|
+
|
|
253
|
+
## Optimistic Updates
|
|
254
|
+
|
|
255
|
+
`optimistic(callback)` snapshots the current state, executes the callback (which should contain CRUD calls), and returns a rollback function. If the server call fails, call rollback to restore the pre-callback state.
|
|
256
|
+
|
|
257
|
+
```typescript
|
|
258
|
+
async toggleDone(id: string) {
|
|
259
|
+
const rollback = this.collection.optimistic(() => {
|
|
260
|
+
this.collection.update(id, { done: true });
|
|
261
|
+
});
|
|
262
|
+
try {
|
|
263
|
+
await this.service.update(id, { done: true }, this.disposeSignal);
|
|
264
|
+
} catch (e) {
|
|
265
|
+
if (!isAbortError(e)) rollback(); // guard needed — rollback affects shared Collection
|
|
266
|
+
throw e;
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
```
|
|
270
|
+
|
|
271
|
+
### Behavior
|
|
272
|
+
|
|
273
|
+
- **Mutations inside the callback apply immediately** and notify subscribers normally — the UI updates instantly.
|
|
274
|
+
- **Snapshot is free** — `items` is always frozen, so the snapshot is a reference capture, not a deep clone.
|
|
275
|
+
- **Rollback restores the exact pre-callback state** regardless of any mutations that happened after the callback (including additional `add`, `update`, `remove` calls).
|
|
276
|
+
- **Rollback rebuilds the index** so `get()` and `has()` are consistent after restore.
|
|
277
|
+
- **Rollback notifies subscribers** with `(restoredItems, preRollbackItems)`.
|
|
278
|
+
- **Rollback is idempotent** — calling it multiple times is safe; only the first call has effect.
|
|
279
|
+
- **Rollback is no-op when disposed** — safe to call in async cleanup after the collection is torn down.
|
|
280
|
+
- **Nesting works** — each `optimistic()` call captures its own independent snapshot. Rolling back inner before outer restores to the intermediate state.
|
|
281
|
+
|
|
282
|
+
### Nested Example
|
|
283
|
+
|
|
284
|
+
```typescript
|
|
285
|
+
const rollback1 = collection.optimistic(() => {
|
|
286
|
+
collection.update('1', { text: 'Outer' });
|
|
287
|
+
});
|
|
288
|
+
// collection.get('1').text → 'Outer'
|
|
289
|
+
|
|
290
|
+
const rollback2 = collection.optimistic(() => {
|
|
291
|
+
collection.update('1', { text: 'Inner' });
|
|
292
|
+
});
|
|
293
|
+
// collection.get('1').text → 'Inner'
|
|
294
|
+
|
|
295
|
+
rollback2();
|
|
296
|
+
// collection.get('1').text → 'Outer'
|
|
297
|
+
|
|
298
|
+
rollback1();
|
|
299
|
+
// collection.get('1').text → 'First' (original)
|
|
300
|
+
```
|
|
301
|
+
|
|
302
|
+
---
|
|
303
|
+
|
|
304
|
+
## Lifecycle
|
|
305
|
+
|
|
306
|
+
### disposeSignal
|
|
307
|
+
|
|
308
|
+
Lazily created `AbortSignal` that aborts when `dispose()` is called. If `disposeSignal` is never accessed, no `AbortController` is allocated (zero cost).
|
|
309
|
+
|
|
310
|
+
```typescript
|
|
311
|
+
const signal = collection.disposeSignal;
|
|
312
|
+
// signal.aborted → false
|
|
313
|
+
|
|
314
|
+
collection.dispose();
|
|
315
|
+
// signal.aborted → true
|
|
316
|
+
```
|
|
317
|
+
|
|
318
|
+
The signal is aborted *before* `onDispose()` runs, so subclass cleanup can check it.
|
|
319
|
+
|
|
320
|
+
### addCleanup(fn)
|
|
321
|
+
|
|
322
|
+
Protected method for subclasses to register teardown callbacks. Cleanups fire on `dispose()` before `onDispose()`.
|
|
323
|
+
|
|
324
|
+
```typescript
|
|
325
|
+
class MyCollection extends Collection<Item> {
|
|
326
|
+
setup() {
|
|
327
|
+
const unsub = someExternalSource.subscribe(data => { /* ... */ });
|
|
328
|
+
this.addCleanup(unsub);
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
```
|
|
332
|
+
|
|
333
|
+
### onDispose()
|
|
334
|
+
|
|
335
|
+
Protected optional hook for subclass-specific teardown. Called once during the first `dispose()` call, after cleanups run and after the signal aborts.
|
|
336
|
+
|
|
337
|
+
```typescript
|
|
338
|
+
class MyCollection extends Collection<Item> {
|
|
339
|
+
protected onDispose(): void {
|
|
340
|
+
// custom teardown
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
```
|
|
344
|
+
|
|
345
|
+
### dispose()
|
|
346
|
+
|
|
347
|
+
Tears down the collection:
|
|
348
|
+
|
|
349
|
+
1. Sets `disposed` to `true`
|
|
350
|
+
2. Aborts the `AbortController` (if created)
|
|
351
|
+
3. Runs all `addCleanup` callbacks
|
|
352
|
+
4. Calls `onDispose()` (if defined)
|
|
353
|
+
5. Clears all subscribers
|
|
354
|
+
6. Clears the internal index
|
|
355
|
+
|
|
356
|
+
Dispose is **idempotent** — calling it multiple times is safe. Only the first call has effect.
|
|
357
|
+
|
|
358
|
+
All CRUD methods throw after dispose:
|
|
359
|
+
|
|
360
|
+
```
|
|
361
|
+
"Cannot add to disposed Collection"
|
|
362
|
+
"Cannot upsert on disposed Collection"
|
|
363
|
+
"Cannot remove from disposed Collection"
|
|
364
|
+
"Cannot update disposed Collection"
|
|
365
|
+
"Cannot reset disposed Collection"
|
|
366
|
+
"Cannot clear disposed Collection"
|
|
367
|
+
"Cannot perform optimistic update on disposed Collection"
|
|
368
|
+
```
|
|
369
|
+
|
|
370
|
+
---
|
|
371
|
+
|
|
372
|
+
## Notification Semantics
|
|
373
|
+
|
|
374
|
+
Listeners are called with `(currentItems, previousItems)`. Both are frozen arrays. This enables diffing when needed. ViewModel getters that read from a collection are auto-tracked — they recompute when the collection changes without explicit subscription wiring.
|
|
375
|
+
|
|
376
|
+
### When Notifications Fire
|
|
377
|
+
|
|
378
|
+
| Method | Notifies? |
|
|
379
|
+
|---|---|
|
|
380
|
+
| `add(...items)` | Yes, unless zero items, all duplicates, or all already exist |
|
|
381
|
+
| `upsert(...items)` | Yes, unless zero items or all reference-identical |
|
|
382
|
+
| `remove(...ids)` | Yes, unless zero IDs or no matches |
|
|
383
|
+
| `update(id, changes)` | Yes, unless ID not found or values unchanged |
|
|
384
|
+
| `reset(items)` | Always |
|
|
385
|
+
| `clear()` | Yes, unless already empty |
|
|
386
|
+
| `optimistic()` callback | Via the CRUD calls inside it |
|
|
387
|
+
| Rollback function | Yes (once) |
|
|
388
|
+
| `get`, `has`, `find`, `filter`, `sorted`, `map` | Never |
|
|
389
|
+
|
|
390
|
+
---
|
|
391
|
+
|
|
392
|
+
## Internal Index
|
|
393
|
+
|
|
394
|
+
Collection maintains a `Map<id, T>` for O(1) `get()` and `has()` lookups. The index is rebuilt automatically on `reset()` and on `optimistic()` rollback. Individual `add`, `upsert`, `remove`, and `update` calls maintain the index incrementally.
|
|
395
|
+
|
|
396
|
+
---
|
|
397
|
+
|
|
398
|
+
## Eviction & TTL
|
|
399
|
+
|
|
400
|
+
Collections can auto-evict items based on capacity limits and/or time-to-live. Both features are opt-in via static property overrides and have **zero overhead** when not configured — no timers, no timestamp maps, no extra work in CRUD methods.
|
|
401
|
+
|
|
402
|
+
### Configuration
|
|
403
|
+
|
|
404
|
+
Override static properties on your subclass (follows the Channel pattern for `RECONNECT_BASE`, etc.):
|
|
405
|
+
|
|
406
|
+
```typescript
|
|
407
|
+
class MessagesCollection extends Collection<Message> {
|
|
408
|
+
static MAX_SIZE = 500; // FIFO eviction when exceeded
|
|
409
|
+
static TTL = 5 * 60_000; // 5 minutes
|
|
410
|
+
}
|
|
411
|
+
```
|
|
412
|
+
|
|
413
|
+
| Static Property | Default | Description |
|
|
414
|
+
|---|---|---|
|
|
415
|
+
| `MAX_SIZE` | `0` | Maximum items before FIFO eviction. `0` = unlimited. |
|
|
416
|
+
| `TTL` | `0` | Time-to-live in milliseconds. `0` = no expiry. |
|
|
417
|
+
|
|
418
|
+
### Capacity Eviction (MAX_SIZE)
|
|
419
|
+
|
|
420
|
+
When `add()`, `upsert()`, or `reset()` would push the collection over `MAX_SIZE`, excess items are evicted from the **front of the array** (FIFO — oldest first). Eviction happens synchronously before `Object.freeze()` and notification, so subscribers see a single change (add + eviction combined, not two separate events).
|
|
421
|
+
|
|
422
|
+
```typescript
|
|
423
|
+
class RecentLogs extends Collection<LogEntry> {
|
|
424
|
+
static MAX_SIZE = 1000;
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
const logs = singleton(RecentLogs);
|
|
428
|
+
// Adding item 1001 evicts item 1 — single notification
|
|
429
|
+
```
|
|
430
|
+
|
|
431
|
+
### TTL (Time-to-Live)
|
|
432
|
+
|
|
433
|
+
When `TTL > 0`, the collection tracks insertion timestamps and schedules a single `setTimeout` for the earliest expiry. When the timer fires, all expired items are swept and subscribers are notified.
|
|
434
|
+
|
|
435
|
+
- `add()` and `upsert()` record timestamps. `upsert()` **refreshes** the timestamp (fresh data from server).
|
|
436
|
+
- `update()` does **not** refresh the timestamp (partial merge, not a fresh item).
|
|
437
|
+
- `remove()` and `clear()` clean up timestamps.
|
|
438
|
+
- `reset()` clears all timestamps and records new ones.
|
|
439
|
+
- `dispose()` cancels the timer.
|
|
440
|
+
|
|
441
|
+
```typescript
|
|
442
|
+
class NotificationsCollection extends Collection<Notification> {
|
|
443
|
+
static TTL = 10 * 60_000; // 10 minutes
|
|
444
|
+
}
|
|
445
|
+
```
|
|
446
|
+
|
|
447
|
+
### onEvict Hook
|
|
448
|
+
|
|
449
|
+
Override `onEvict` to control which items get evicted:
|
|
450
|
+
|
|
451
|
+
```typescript
|
|
452
|
+
class ActiveOrdersCollection extends Collection<Order> {
|
|
453
|
+
static MAX_SIZE = 200;
|
|
454
|
+
|
|
455
|
+
protected onEvict(items: Order[], reason: 'capacity' | 'ttl') {
|
|
456
|
+
// Keep in-progress orders, evict the rest
|
|
457
|
+
return items.filter(o => o.status !== 'in_progress');
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
```
|
|
461
|
+
|
|
462
|
+
| Return Value | Effect |
|
|
463
|
+
|---|---|
|
|
464
|
+
| `void` / `undefined` | Proceed with all candidates |
|
|
465
|
+
| `T[]` | Evict only the returned subset |
|
|
466
|
+
| `false` | Veto eviction entirely |
|
|
467
|
+
|
|
468
|
+
The `reason` parameter is `'capacity'` for `MAX_SIZE` eviction and `'ttl'` for expiry eviction, allowing different behavior for each.
|
|
469
|
+
|
|
470
|
+
**DEV warning**: When `onEvict` vetoes capacity eviction and the collection exceeds 2x `MAX_SIZE`, a console warning is logged to flag potential unbounded growth.
|
|
471
|
+
|
|
472
|
+
### Optimistic Rollback
|
|
473
|
+
|
|
474
|
+
`optimistic()` snapshots timestamps alongside items. Rollback restores both, and the TTL timer is rescheduled.
|
|
475
|
+
|
|
476
|
+
### Zero-Overhead Guarantee
|
|
477
|
+
|
|
478
|
+
When `MAX_SIZE = 0` and `TTL = 0` (the defaults):
|
|
479
|
+
- No `_timestamps` Map is allocated
|
|
480
|
+
- No `setTimeout` is scheduled
|
|
481
|
+
- CRUD methods execute the same code paths as before
|
|
482
|
+
|
|
483
|
+
---
|
|
484
|
+
|
|
485
|
+
## Best Practices
|
|
486
|
+
|
|
487
|
+
**One Collection per entity type.** `UsersCollection`, `LocationsCollection`, `OrdersCollection`. Keep them thin.
|
|
488
|
+
|
|
489
|
+
**Don't add methods to Collection subclasses.** Filtering and sorting belong in ViewModel getters. Collections are data stores, not query engines.
|
|
490
|
+
|
|
491
|
+
**Always use `singleton()` for resolution.** Collections are shared across ViewModels. Creating a collection directly (`new UsersCollection()`) breaks the shared-cache guarantee.
|
|
492
|
+
|
|
493
|
+
**Use `reset()` for initial loads, `upsert()` for paginated/incremental loads, and `add`/`update`/`remove` for granular mutations.**
|
|
494
|
+
|
|
495
|
+
**Read collection data via getters, not `subscribeTo` + `set()`.** Auto-tracking handles reactivity when getters read from collection members. Use `subscribeTo()` only for imperative side effects.
|
|
496
|
+
|
|
497
|
+
**Use `collection.optimistic()` for instant UI feedback.** Don't manually snapshot and restore — it's error-prone and verbose.
|
|
498
|
+
|
|
499
|
+
**Check `collection.length > 0` in `onInit` before fetching.** Another ViewModel may have already loaded the data.
|
|
500
|
+
|
|
501
|
+
```typescript
|
|
502
|
+
get items(): Item[] {
|
|
503
|
+
return this.collection.items as Item[];
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
protected onInit() {
|
|
507
|
+
if (this.collection.length === 0) this.load();
|
|
508
|
+
}
|
|
509
|
+
```
|
|
510
|
+
|
|
511
|
+
**Clean up singletons in tests.** Call `teardownAll()` in `beforeEach` to reset the singleton registry between test cases.
|
|
512
|
+
|
|
513
|
+
> See `BEST_PRACTICES.md` for encapsulation patterns, optimistic update best practices, and Collection subscription conventions.
|
|
514
|
+
|
|
515
|
+
---
|
|
516
|
+
|
|
517
|
+
## Persistence
|
|
518
|
+
|
|
519
|
+
For Collections that need to cache/repopulate from storage across sessions, extend `PersistentCollection<T>` instead. See `src/PersistentCollection.md` for the full API and adapter details.
|
|
520
|
+
|
|
521
|
+
Available adapters:
|
|
522
|
+
- **`WebStorageCollection`** (`mvc-kit/web`) — localStorage/sessionStorage, auto-hydrates
|
|
523
|
+
- **`IndexedDBCollection`** (`mvc-kit/web`) — IndexedDB, per-item storage, requires `hydrate()`
|
|
524
|
+
- **`NativeCollection`** (`mvc-kit/react-native`) — configurable async backend, requires `hydrate()`
|
|
525
|
+
|
|
526
|
+
## Method Binding
|
|
527
|
+
|
|
528
|
+
All public methods are auto-bound in the constructor. You can pass them point-free as callbacks without losing `this` context:
|
|
529
|
+
|
|
530
|
+
```tsx
|
|
531
|
+
const { add, upsert } = collection;
|
|
532
|
+
channel.on("update", upsert); // point-free works
|
|
533
|
+
```
|