entity-repository 0.1.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/LICENSE +21 -0
- package/README.md +390 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +5 -0
- package/dist/list-query.d.ts +36 -0
- package/dist/list-query.d.ts.map +1 -0
- package/dist/list-query.js +109 -0
- package/dist/react.d.ts +16 -0
- package/dist/react.d.ts.map +1 -0
- package/dist/react.js +61 -0
- package/dist/record-query.d.ts +15 -0
- package/dist/record-query.d.ts.map +1 -0
- package/dist/record-query.js +55 -0
- package/dist/repository.d.ts +24 -0
- package/dist/repository.d.ts.map +1 -0
- package/dist/repository.js +141 -0
- package/dist/types.d.ts +34 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +1 -0
- package/package.json +56 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Kurt Lee
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,390 @@
|
|
|
1
|
+
# entity-repository
|
|
2
|
+
|
|
3
|
+
Type-safe entity caching and state management with RxJS observables and React integration.
|
|
4
|
+
|
|
5
|
+
## Features
|
|
6
|
+
|
|
7
|
+
- **Type-safe entity management** - Full TypeScript generics for entity definitions and ID types
|
|
8
|
+
- **In-memory caching** - Fast Map-based cache by table and entity ID
|
|
9
|
+
- **RxJS-based reactivity** - Observable streams for real-time updates
|
|
10
|
+
- **Query layer** - `RecordQuery` for single entities, `ListQuery` for filtered/sorted lists
|
|
11
|
+
- **React integration** - Context provider and hooks for seamless React usage
|
|
12
|
+
- **Request deduplication** - Prevents duplicate fetch requests for the same entity
|
|
13
|
+
- **Real-time event system** - Insert/update/delete events for reactive list updates
|
|
14
|
+
|
|
15
|
+
## Installation
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
npm install entity-repository rxjs
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
## Quick Start
|
|
22
|
+
|
|
23
|
+
### 1. Define your entity types
|
|
24
|
+
|
|
25
|
+
```typescript
|
|
26
|
+
import type { EntityConfig } from "entity-repository";
|
|
27
|
+
|
|
28
|
+
// Define the shape of your entities
|
|
29
|
+
type Entities = {
|
|
30
|
+
users: { id: string; name: string; email: string };
|
|
31
|
+
posts: { id: string; title: string; authorId: string; createdAt: string };
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
// Configure which field is the ID for each entity
|
|
35
|
+
const entityConfig = {
|
|
36
|
+
users: { id: "id" },
|
|
37
|
+
posts: { id: "id" },
|
|
38
|
+
} as const satisfies EntityConfig<Entities>;
|
|
39
|
+
|
|
40
|
+
type MyEntityConfig = typeof entityConfig;
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
### 2. Create the repository
|
|
44
|
+
|
|
45
|
+
```typescript
|
|
46
|
+
import { Repository } from "entity-repository";
|
|
47
|
+
|
|
48
|
+
const repository = new Repository<Entities, MyEntityConfig>({
|
|
49
|
+
entities: entityConfig,
|
|
50
|
+
});
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
### 3. Basic operations
|
|
54
|
+
|
|
55
|
+
```typescript
|
|
56
|
+
// Store an entity
|
|
57
|
+
repository.set("users", { id: "1", name: "Alice", email: "alice@example.com" });
|
|
58
|
+
|
|
59
|
+
// Get an entity (returns null if not cached)
|
|
60
|
+
const user = repository.get("users", { id: "1" });
|
|
61
|
+
|
|
62
|
+
// Fetch with caching (fetches only if not cached, deduplicates concurrent requests)
|
|
63
|
+
const user = await repository.fetch("users", { id: "1" }, async (id) => {
|
|
64
|
+
const response = await fetch(`/api/users/${id.id}`);
|
|
65
|
+
return response.json();
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
// Delete an entity
|
|
69
|
+
repository.del("users", { id: "1" });
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
### 4. Reactive subscriptions
|
|
73
|
+
|
|
74
|
+
```typescript
|
|
75
|
+
// Subscribe to entity changes
|
|
76
|
+
const observable = repository.getObservable("users", { id: "1" });
|
|
77
|
+
observable.subscribe((user) => {
|
|
78
|
+
console.log("User changed:", user);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
// Subscribe to all events for a table (insert/update/delete)
|
|
82
|
+
repository.getEvents("users").subscribe((event) => {
|
|
83
|
+
if (event.type === "insert") console.log("New user:", event.new);
|
|
84
|
+
if (event.type === "update") console.log("Updated:", event.old, "->", event.new);
|
|
85
|
+
if (event.type === "delete") console.log("Deleted:", event.old);
|
|
86
|
+
});
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
## React Integration
|
|
90
|
+
|
|
91
|
+
### Setup
|
|
92
|
+
|
|
93
|
+
```typescript
|
|
94
|
+
import { useMemo } from "react";
|
|
95
|
+
import { createRepositoryContext, Repository } from "entity-repository";
|
|
96
|
+
|
|
97
|
+
// Create typed context and hooks
|
|
98
|
+
export const {
|
|
99
|
+
RepositoryProvider,
|
|
100
|
+
useRepository,
|
|
101
|
+
useRepositoryQuery,
|
|
102
|
+
useRepositoryListQuery,
|
|
103
|
+
useSubscribedState,
|
|
104
|
+
} = createRepositoryContext<Entities, MyEntityConfig>();
|
|
105
|
+
|
|
106
|
+
// In your app root
|
|
107
|
+
function App() {
|
|
108
|
+
const repository = useMemo(
|
|
109
|
+
() => new Repository<Entities, MyEntityConfig>({ entities: entityConfig }),
|
|
110
|
+
[]
|
|
111
|
+
);
|
|
112
|
+
|
|
113
|
+
return (
|
|
114
|
+
<RepositoryProvider repository={repository}>
|
|
115
|
+
<YourApp />
|
|
116
|
+
</RepositoryProvider>
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
### useRepositoryQuery - Single entity
|
|
122
|
+
|
|
123
|
+
```typescript
|
|
124
|
+
function UserProfile({ userId }: { userId: string }) {
|
|
125
|
+
const { entity: user, status } = useRepositoryQuery(
|
|
126
|
+
"users",
|
|
127
|
+
{ id: userId },
|
|
128
|
+
async (id) => {
|
|
129
|
+
const response = await fetch(`/api/users/${id.id}`);
|
|
130
|
+
return response.json();
|
|
131
|
+
}
|
|
132
|
+
);
|
|
133
|
+
|
|
134
|
+
if (status === "fetching") return <div>Loading...</div>;
|
|
135
|
+
if (status === "error") return <div>Error loading user</div>;
|
|
136
|
+
if (!user) return <div>User not found</div>;
|
|
137
|
+
|
|
138
|
+
return <div>{user.name}</div>;
|
|
139
|
+
}
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
### useRepositoryListQuery - Entity lists with filter/sort
|
|
143
|
+
|
|
144
|
+
```typescript
|
|
145
|
+
function UserPosts({ authorId }: { authorId: string }) {
|
|
146
|
+
const { records: posts, status } = useRepositoryListQuery(
|
|
147
|
+
"posts",
|
|
148
|
+
{ authorId }, // param - query re-runs when this changes
|
|
149
|
+
{
|
|
150
|
+
filter: (post) => post.authorId === authorId,
|
|
151
|
+
order: (a, b) => b.createdAt.localeCompare(a.createdAt),
|
|
152
|
+
},
|
|
153
|
+
async (param) => {
|
|
154
|
+
const response = await fetch(`/api/posts?author=${param.authorId}`);
|
|
155
|
+
return response.json();
|
|
156
|
+
}
|
|
157
|
+
);
|
|
158
|
+
|
|
159
|
+
if (status === "fetching" && posts.length === 0) return <div>Loading...</div>;
|
|
160
|
+
|
|
161
|
+
return (
|
|
162
|
+
<ul>
|
|
163
|
+
{posts.map((post) => (
|
|
164
|
+
<li key={post.id}>{post.title}</li>
|
|
165
|
+
))}
|
|
166
|
+
</ul>
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
### useSubscribedState - Subscribe to any RxJS observable
|
|
172
|
+
|
|
173
|
+
```typescript
|
|
174
|
+
import { interval } from "rxjs";
|
|
175
|
+
|
|
176
|
+
function Timer() {
|
|
177
|
+
const seconds = useSubscribedState(interval(1000), 0);
|
|
178
|
+
return <div>Seconds: {seconds}</div>;
|
|
179
|
+
}
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
## Real-time Updates Integration
|
|
183
|
+
|
|
184
|
+
The repository emits events when entities change. Connect to your real-time backend:
|
|
185
|
+
|
|
186
|
+
```typescript
|
|
187
|
+
// Example with Supabase Realtime
|
|
188
|
+
useEffect(() => {
|
|
189
|
+
const channel = supabase
|
|
190
|
+
.channel("changes")
|
|
191
|
+
.on("postgres_changes", { event: "*", schema: "public" }, (payload) => {
|
|
192
|
+
const table = payload.table as keyof Entities;
|
|
193
|
+
|
|
194
|
+
if (payload.eventType === "DELETE") {
|
|
195
|
+
repository.del(table, { id: payload.old.id });
|
|
196
|
+
} else {
|
|
197
|
+
repository.set(table, payload.new);
|
|
198
|
+
}
|
|
199
|
+
})
|
|
200
|
+
.subscribe();
|
|
201
|
+
|
|
202
|
+
return () => supabase.removeChannel(channel);
|
|
203
|
+
}, [repository, supabase]);
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
## Advanced Usage
|
|
207
|
+
|
|
208
|
+
### RecordQuery - Standalone single-entity query
|
|
209
|
+
|
|
210
|
+
```typescript
|
|
211
|
+
const query = repository.recordQuery("users", { id: "1" }, async (id) => {
|
|
212
|
+
const response = await fetch(`/api/users/${id.id}`);
|
|
213
|
+
return response.json();
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
// Subscribe to state changes
|
|
217
|
+
query.$state.subscribe((state) => {
|
|
218
|
+
console.log("Status:", state.status);
|
|
219
|
+
console.log("Entity:", state.entity);
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
// Manually trigger a fetch
|
|
223
|
+
await query.fetch();
|
|
224
|
+
|
|
225
|
+
// Cleanup when done
|
|
226
|
+
query.dispose();
|
|
227
|
+
```
|
|
228
|
+
|
|
229
|
+
### ListQuery - Standalone list query
|
|
230
|
+
|
|
231
|
+
```typescript
|
|
232
|
+
const query = repository.listQuery(
|
|
233
|
+
"posts",
|
|
234
|
+
{
|
|
235
|
+
filter: (post) => post.authorId === "1",
|
|
236
|
+
order: (a, b) => b.createdAt.localeCompare(a.createdAt),
|
|
237
|
+
},
|
|
238
|
+
async () => {
|
|
239
|
+
const response = await fetch("/api/posts?author=1");
|
|
240
|
+
return response.json();
|
|
241
|
+
}
|
|
242
|
+
);
|
|
243
|
+
|
|
244
|
+
// Subscribe to records and status separately
|
|
245
|
+
query.$records.subscribe((posts) => {
|
|
246
|
+
console.log("Posts:", posts);
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
query.$status.subscribe((status) => {
|
|
250
|
+
console.log("Status:", status.status);
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
// Manually trigger a refetch
|
|
254
|
+
await query.refetch();
|
|
255
|
+
|
|
256
|
+
// Cleanup when done
|
|
257
|
+
query.dispose();
|
|
258
|
+
```
|
|
259
|
+
|
|
260
|
+
### Cache key utilities
|
|
261
|
+
|
|
262
|
+
```typescript
|
|
263
|
+
// Get the cache key for an entity ID
|
|
264
|
+
const key = repository.getCacheKey("users", { id: "1" }); // "1"
|
|
265
|
+
|
|
266
|
+
// Get the cache key from an entity object
|
|
267
|
+
const key = repository.getEntityKey("users", { id: "1", name: "Alice", email: "..." }); // "1"
|
|
268
|
+
```
|
|
269
|
+
|
|
270
|
+
## API Reference
|
|
271
|
+
|
|
272
|
+
### Repository
|
|
273
|
+
|
|
274
|
+
| Method | Description |
|
|
275
|
+
|--------|-------------|
|
|
276
|
+
| `set(table, entity)` | Store entity in cache, emits insert/update event |
|
|
277
|
+
| `get(table, id)` | Get cached entity or null |
|
|
278
|
+
| `del(table, id)` | Remove entity from cache, emits delete event |
|
|
279
|
+
| `fetch(table, id, fetcher)` | Get cached or fetch, deduplicates concurrent requests |
|
|
280
|
+
| `getObservable(table, id)` | BehaviorSubject for entity changes |
|
|
281
|
+
| `getEvents(table)` | Subject emitting insert/update/delete events |
|
|
282
|
+
| `recordQuery(table, id, fetcher)` | Create RecordQuery instance |
|
|
283
|
+
| `listQuery(table, options, fetcher)` | Create ListQuery instance |
|
|
284
|
+
| `getCacheKey(table, id)` | Get cache key string from entity ID |
|
|
285
|
+
| `getEntityKey(table, entity)` | Get cache key string from entity object |
|
|
286
|
+
|
|
287
|
+
### RecordQuery
|
|
288
|
+
|
|
289
|
+
Manages single-entity queries with status tracking.
|
|
290
|
+
|
|
291
|
+
| Property/Method | Description |
|
|
292
|
+
|-----------------|-------------|
|
|
293
|
+
| `$state` | `BehaviorSubject<{ entity, status }>` - subscribe to state changes |
|
|
294
|
+
| `fetch()` | Manually trigger fetch, returns entity |
|
|
295
|
+
| `dispose()` | Cleanup subscriptions |
|
|
296
|
+
|
|
297
|
+
### ListQuery
|
|
298
|
+
|
|
299
|
+
Manages list queries with filter/sort and real-time updates.
|
|
300
|
+
|
|
301
|
+
| Property/Method | Description |
|
|
302
|
+
|-----------------|-------------|
|
|
303
|
+
| `$records` | `BehaviorSubject<Entity[]>` - subscribe to record list |
|
|
304
|
+
| `$status` | `BehaviorSubject<{ status, error? }>` - subscribe to status |
|
|
305
|
+
| `refetch()` | Manually trigger refetch, returns records |
|
|
306
|
+
| `dispose()` | Cleanup subscriptions |
|
|
307
|
+
|
|
308
|
+
### React Hooks
|
|
309
|
+
|
|
310
|
+
| Hook | Description |
|
|
311
|
+
|------|-------------|
|
|
312
|
+
| `useRepository()` | Access repository instance from context |
|
|
313
|
+
| `useRepositoryQuery(table, id, fetcher)` | Subscribe to single entity query |
|
|
314
|
+
| `useRepositoryListQuery(table, param, options, fetcher)` | Subscribe to filtered/sorted list query |
|
|
315
|
+
| `useSubscribedState(observable, initial)` | Subscribe to any RxJS observable |
|
|
316
|
+
|
|
317
|
+
## Types
|
|
318
|
+
|
|
319
|
+
```typescript
|
|
320
|
+
// Entity configuration - maps tables to their ID field
|
|
321
|
+
type EntityConfig<Definitions> = {
|
|
322
|
+
[Table in keyof Definitions]: { id: keyof Definitions[Table] & string };
|
|
323
|
+
};
|
|
324
|
+
|
|
325
|
+
// Entity ID tuple - picks only the ID field from entity
|
|
326
|
+
type EntityIdTuple<Definitions, Config, Table> =
|
|
327
|
+
Pick<Definitions[Table], Config[Table]["id"]>;
|
|
328
|
+
|
|
329
|
+
// Entity event types - emitted on insert/update/delete
|
|
330
|
+
type EntityEvent<Entity> = {
|
|
331
|
+
timestamp: Date;
|
|
332
|
+
} & (
|
|
333
|
+
| { type: "insert"; new: Entity }
|
|
334
|
+
| { type: "update"; old: Entity; new: Entity }
|
|
335
|
+
| { type: "delete"; old: Entity }
|
|
336
|
+
);
|
|
337
|
+
|
|
338
|
+
// Single-entity query state
|
|
339
|
+
type RepositoryQuery<Entity> = {
|
|
340
|
+
entity: Entity | null;
|
|
341
|
+
} & (
|
|
342
|
+
| { status: "fetching" }
|
|
343
|
+
| { status: "idle" }
|
|
344
|
+
| { status: "error"; error: Error }
|
|
345
|
+
);
|
|
346
|
+
|
|
347
|
+
// List query options
|
|
348
|
+
type ListQueryOptions<Entity> = {
|
|
349
|
+
filter?: (entity: Entity) => boolean;
|
|
350
|
+
order?: (left: Entity, right: Entity) => number;
|
|
351
|
+
};
|
|
352
|
+
|
|
353
|
+
// List query status
|
|
354
|
+
type ListQueryStatus =
|
|
355
|
+
| { status: "idle" }
|
|
356
|
+
| { status: "fetching" }
|
|
357
|
+
| { status: "error"; error: Error };
|
|
358
|
+
|
|
359
|
+
// List query state (returned by useRepositoryListQuery)
|
|
360
|
+
type ListQueryState<Entity> = {
|
|
361
|
+
records: Entity[];
|
|
362
|
+
} & ListQueryStatus;
|
|
363
|
+
```
|
|
364
|
+
|
|
365
|
+
## How It Works
|
|
366
|
+
|
|
367
|
+
### Caching Strategy
|
|
368
|
+
|
|
369
|
+
The repository uses a simple Map-based cache keyed by table name and entity ID:
|
|
370
|
+
|
|
371
|
+
1. **Set**: Stores entity and emits insert (new) or update (existing) event
|
|
372
|
+
2. **Get**: Returns cached entity or null (synchronous)
|
|
373
|
+
3. **Fetch**: Returns cached entity, or fetches and caches if not present
|
|
374
|
+
4. **Request deduplication**: Concurrent fetches for the same entity share a single request
|
|
375
|
+
|
|
376
|
+
### Reactivity
|
|
377
|
+
|
|
378
|
+
- `getObservable()` returns a `BehaviorSubject` that emits when an entity changes
|
|
379
|
+
- `getEvents()` returns a `Subject` that emits insert/update/delete events for a table
|
|
380
|
+
- `ListQuery` subscribes to table events and automatically updates its filtered list
|
|
381
|
+
|
|
382
|
+
### React Integration
|
|
383
|
+
|
|
384
|
+
- `useRepositoryQuery` creates a `RecordQuery` and subscribes to its state
|
|
385
|
+
- `useRepositoryListQuery` creates a `ListQuery` and subscribes to records + status
|
|
386
|
+
- Both hooks use `JSON.stringify(id/param)` for stable memoization keys
|
|
387
|
+
|
|
388
|
+
## License
|
|
389
|
+
|
|
390
|
+
MIT
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,cAAc,SAAS,CAAC;AACxB,cAAc,gBAAgB,CAAC;AAC/B,cAAc,cAAc,CAAC;AAC7B,cAAc,cAAc,CAAC;AAC7B,cAAc,SAAS,CAAC"}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { BehaviorSubject } from "rxjs";
|
|
2
|
+
import type { EntityConfig, EntityDefinitions } from "./types";
|
|
3
|
+
import type { Repository } from "./repository";
|
|
4
|
+
export type ListQueryOptions<Entity> = {
|
|
5
|
+
order?: (left: Entity, right: Entity) => number;
|
|
6
|
+
filter?: (entity: Entity) => boolean;
|
|
7
|
+
};
|
|
8
|
+
export type ListQueryStatus = {
|
|
9
|
+
status: "idle";
|
|
10
|
+
} | {
|
|
11
|
+
status: "fetching";
|
|
12
|
+
} | {
|
|
13
|
+
status: "error";
|
|
14
|
+
error: Error;
|
|
15
|
+
};
|
|
16
|
+
export type ListQueryState<Entity> = {
|
|
17
|
+
records: Entity[];
|
|
18
|
+
} & ListQueryStatus;
|
|
19
|
+
export declare class ListQuery<Definitions extends EntityDefinitions, Config extends EntityConfig<Definitions>, Table extends keyof Definitions> {
|
|
20
|
+
readonly $records: BehaviorSubject<Definitions[Table][]>;
|
|
21
|
+
readonly $status: BehaviorSubject<ListQueryStatus>;
|
|
22
|
+
private repository;
|
|
23
|
+
private table;
|
|
24
|
+
private fetcher;
|
|
25
|
+
private filter;
|
|
26
|
+
private order;
|
|
27
|
+
private subscription;
|
|
28
|
+
constructor(repository: Repository<Definitions, Config>, table: Table, options: ListQueryOptions<Definitions[Table]>, fetcher: () => Promise<Definitions[Table][]>);
|
|
29
|
+
refetch(): Promise<Definitions[Table][]>;
|
|
30
|
+
dispose(): void;
|
|
31
|
+
private applyEvent;
|
|
32
|
+
private upsertRecord;
|
|
33
|
+
private removeRecord;
|
|
34
|
+
private applyOrdering;
|
|
35
|
+
}
|
|
36
|
+
//# sourceMappingURL=list-query.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"list-query.d.ts","sourceRoot":"","sources":["../src/list-query.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,eAAe,EAAgB,MAAM,MAAM,CAAC;AAErD,OAAO,KAAK,EAAE,YAAY,EAAE,iBAAiB,EAAe,MAAM,SAAS,CAAC;AAC5E,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,cAAc,CAAC;AAE/C,MAAM,MAAM,gBAAgB,CAAC,MAAM,IAAI;IACrC,KAAK,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,KAAK,MAAM,CAAC;IAChD,MAAM,CAAC,EAAE,CAAC,MAAM,EAAE,MAAM,KAAK,OAAO,CAAC;CACtC,CAAC;AAEF,MAAM,MAAM,eAAe,GACvB;IAAE,MAAM,EAAE,MAAM,CAAA;CAAE,GAClB;IAAE,MAAM,EAAE,UAAU,CAAA;CAAE,GACtB;IAAE,MAAM,EAAE,OAAO,CAAC;IAAC,KAAK,EAAE,KAAK,CAAA;CAAE,CAAC;AAEtC,MAAM,MAAM,cAAc,CAAC,MAAM,IAAI;IACnC,OAAO,EAAE,MAAM,EAAE,CAAC;CACnB,GAAG,eAAe,CAAC;AAEpB,qBAAa,SAAS,CACpB,WAAW,SAAS,iBAAiB,EACrC,MAAM,SAAS,YAAY,CAAC,WAAW,CAAC,EACxC,KAAK,SAAS,MAAM,WAAW;IAE/B,QAAQ,CAAC,QAAQ,EAAE,eAAe,CAAC,WAAW,CAAC,KAAK,CAAC,EAAE,CAAC,CAAC;IACzD,QAAQ,CAAC,OAAO,EAAE,eAAe,CAAC,eAAe,CAAC,CAAC;IACnD,OAAO,CAAC,UAAU,CAAkC;IACpD,OAAO,CAAC,KAAK,CAAQ;IACrB,OAAO,CAAC,OAAO,CAAsC;IACrD,OAAO,CAAC,MAAM,CAA0C;IACxD,OAAO,CAAC,KAAK,CAA2E;IACxF,OAAO,CAAC,YAAY,CAAe;gBAGjC,UAAU,EAAE,UAAU,CAAC,WAAW,EAAE,MAAM,CAAC,EAC3C,KAAK,EAAE,KAAK,EACZ,OAAO,EAAE,gBAAgB,CAAC,WAAW,CAAC,KAAK,CAAC,CAAC,EAC7C,OAAO,EAAE,MAAM,OAAO,CAAC,WAAW,CAAC,KAAK,CAAC,EAAE,CAAC;IAkBxC,OAAO;IAmBb,OAAO;IAIP,OAAO,CAAC,UAAU;IAoBlB,OAAO,CAAC,YAAY;IAyCpB,OAAO,CAAC,YAAY;IAYpB,OAAO,CAAC,aAAa;CAOtB"}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { BehaviorSubject } from "rxjs";
|
|
2
|
+
export class ListQuery {
|
|
3
|
+
$records;
|
|
4
|
+
$status;
|
|
5
|
+
repository;
|
|
6
|
+
table;
|
|
7
|
+
fetcher;
|
|
8
|
+
filter;
|
|
9
|
+
order;
|
|
10
|
+
subscription;
|
|
11
|
+
constructor(repository, table, options, fetcher) {
|
|
12
|
+
this.repository = repository;
|
|
13
|
+
this.table = table;
|
|
14
|
+
this.fetcher = fetcher;
|
|
15
|
+
this.filter = options.filter ?? (() => true);
|
|
16
|
+
this.order = options.order ?? null;
|
|
17
|
+
this.$records = new BehaviorSubject([]);
|
|
18
|
+
this.$status = new BehaviorSubject({ status: "fetching" });
|
|
19
|
+
this.subscription = repository.getEvents(table).subscribe((event) => {
|
|
20
|
+
this.applyEvent(event);
|
|
21
|
+
});
|
|
22
|
+
void this.refetch();
|
|
23
|
+
}
|
|
24
|
+
async refetch() {
|
|
25
|
+
this.$status.next({ status: "fetching" });
|
|
26
|
+
try {
|
|
27
|
+
const records = await this.fetcher();
|
|
28
|
+
records.forEach((record) => {
|
|
29
|
+
this.repository.set(this.table, record);
|
|
30
|
+
});
|
|
31
|
+
const nextRecords = this.applyOrdering(records.filter(this.filter));
|
|
32
|
+
this.$records.next(nextRecords);
|
|
33
|
+
this.$status.next({ status: "idle" });
|
|
34
|
+
return nextRecords;
|
|
35
|
+
}
|
|
36
|
+
catch (error) {
|
|
37
|
+
const normalizedError = error instanceof Error ? error : new Error("ListQuery fetch failed");
|
|
38
|
+
this.$status.next({ status: "error", error: normalizedError });
|
|
39
|
+
throw normalizedError;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
dispose() {
|
|
43
|
+
this.subscription.unsubscribe();
|
|
44
|
+
}
|
|
45
|
+
applyEvent(event) {
|
|
46
|
+
const current = this.$records.value;
|
|
47
|
+
switch (event.type) {
|
|
48
|
+
case "insert":
|
|
49
|
+
this.upsertRecord(current, event.new);
|
|
50
|
+
return;
|
|
51
|
+
case "update":
|
|
52
|
+
this.upsertRecord(current, event.new, event.old);
|
|
53
|
+
return;
|
|
54
|
+
case "delete":
|
|
55
|
+
this.removeRecord(current, event.old);
|
|
56
|
+
return;
|
|
57
|
+
default: {
|
|
58
|
+
const _exhaustive = event;
|
|
59
|
+
return _exhaustive;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
upsertRecord(current, nextRecord, previousRecord) {
|
|
64
|
+
const nextPasses = this.filter(nextRecord);
|
|
65
|
+
const nextKey = this.repository.getEntityKey(this.table, nextRecord);
|
|
66
|
+
const previousKey = previousRecord
|
|
67
|
+
? this.repository.getEntityKey(this.table, previousRecord)
|
|
68
|
+
: null;
|
|
69
|
+
const index = current.findIndex((record) => this.repository.getEntityKey(this.table, record) === nextKey);
|
|
70
|
+
if (!nextPasses) {
|
|
71
|
+
if (index !== -1) {
|
|
72
|
+
const next = current.slice();
|
|
73
|
+
next.splice(index, 1);
|
|
74
|
+
this.$records.next(this.applyOrdering(next));
|
|
75
|
+
}
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
const next = current.slice();
|
|
79
|
+
if (index === -1) {
|
|
80
|
+
next.push(nextRecord);
|
|
81
|
+
}
|
|
82
|
+
else {
|
|
83
|
+
next[index] = nextRecord;
|
|
84
|
+
}
|
|
85
|
+
if (previousKey && previousKey !== nextKey) {
|
|
86
|
+
const previousIndex = next.findIndex((record, recordIndex) => recordIndex !== index && this.repository.getEntityKey(this.table, record) === previousKey);
|
|
87
|
+
if (previousIndex !== -1) {
|
|
88
|
+
next.splice(previousIndex, 1);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
this.$records.next(this.applyOrdering(next));
|
|
92
|
+
}
|
|
93
|
+
removeRecord(current, record) {
|
|
94
|
+
const key = this.repository.getEntityKey(this.table, record);
|
|
95
|
+
const index = current.findIndex((existing) => this.repository.getEntityKey(this.table, existing) === key);
|
|
96
|
+
if (index === -1) {
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
const next = current.slice();
|
|
100
|
+
next.splice(index, 1);
|
|
101
|
+
this.$records.next(this.applyOrdering(next));
|
|
102
|
+
}
|
|
103
|
+
applyOrdering(records) {
|
|
104
|
+
if (!this.order) {
|
|
105
|
+
return records;
|
|
106
|
+
}
|
|
107
|
+
return [...records].sort(this.order);
|
|
108
|
+
}
|
|
109
|
+
}
|
package/dist/react.d.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { ReactNode } from "react";
|
|
2
|
+
import { type Observable } from "rxjs";
|
|
3
|
+
import type { ListQueryOptions, ListQueryState } from "./list-query";
|
|
4
|
+
import type { EntityConfig, EntityDefinitions, EntityIdTuple, RepositoryQuery } from "./types";
|
|
5
|
+
import { Repository } from "./repository";
|
|
6
|
+
export declare function createRepositoryContext<Definitions extends EntityDefinitions, Config extends EntityConfig<Definitions> = EntityConfig<Definitions>>(): {
|
|
7
|
+
RepositoryProvider: ({ repository, children, }: {
|
|
8
|
+
repository: Repository<Definitions, Config>;
|
|
9
|
+
children: ReactNode;
|
|
10
|
+
}) => import("react/jsx-runtime").JSX.Element;
|
|
11
|
+
useRepository: () => Repository<Definitions, Config>;
|
|
12
|
+
useRepositoryQuery: <Table extends keyof Definitions>(table: Table, id: EntityIdTuple<Definitions, Config, Table>, fetcher: (id: EntityIdTuple<Definitions, Config, Table>) => Promise<Definitions[Table]>) => RepositoryQuery<Definitions[Table]>;
|
|
13
|
+
useRepositoryListQuery: <Table extends keyof Definitions, Param>(table: Table, param: Param, options: ListQueryOptions<Definitions[Table]>, fetcher: (param: Param) => Promise<Definitions[Table][]>) => ListQueryState<Definitions[Table]>;
|
|
14
|
+
useSubscribedState: <Value>(observable: Observable<Value>, initialValue: Value) => Value;
|
|
15
|
+
};
|
|
16
|
+
//# sourceMappingURL=react.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"react.d.ts","sourceRoot":"","sources":["../src/react.tsx"],"names":[],"mappings":"AAAA,OAAO,EAAiB,SAAS,EAA4C,MAAM,OAAO,CAAC;AAE3F,OAAO,EAAsB,KAAK,UAAU,EAAE,MAAM,MAAM,CAAC;AAE3D,OAAO,KAAK,EAAE,gBAAgB,EAAE,cAAc,EAAE,MAAM,cAAc,CAAC;AACrE,OAAO,KAAK,EAAE,YAAY,EAAE,iBAAiB,EAAE,aAAa,EAAE,eAAe,EAAE,MAAM,SAAS,CAAC;AAC/F,OAAO,EAAE,UAAU,EAAE,MAAM,cAAc,CAAC;AAE1C,wBAAgB,uBAAuB,CACrC,WAAW,SAAS,iBAAiB,EACrC,MAAM,SAAS,YAAY,CAAC,WAAW,CAAC,GAAG,YAAY,CAAC,WAAW,CAAC;oDAOjE;QACD,UAAU,EAAE,UAAU,CAAC,WAAW,EAAE,MAAM,CAAC,CAAC;QAC5C,QAAQ,EAAE,SAAS,CAAC;KACrB;;yBAoC2B,KAAK,SAAS,MAAM,WAAW,SAClD,KAAK,MACR,aAAa,CAAC,WAAW,EAAE,MAAM,EAAE,KAAK,CAAC,WACpC,CAAC,EAAE,EAAE,aAAa,CAAC,WAAW,EAAE,MAAM,EAAE,KAAK,CAAC,KAAK,OAAO,CAAC,WAAW,CAAC,KAAK,CAAC,CAAC,KACtF,eAAe,CAAC,WAAW,CAAC,KAAK,CAAC,CAAC;6BAiBN,KAAK,SAAS,MAAM,WAAW,EAAE,KAAK,SAC7D,KAAK,SACL,KAAK,WACH,gBAAgB,CAAC,WAAW,CAAC,KAAK,CAAC,CAAC,WACpC,CAAC,KAAK,EAAE,KAAK,KAAK,OAAO,CAAC,WAAW,CAAC,KAAK,CAAC,EAAE,CAAC,KACvD,cAAc,CAAC,WAAW,CAAC,KAAK,CAAC,CAAC;yBA9CT,KAAK,cAAc,UAAU,CAAC,KAAK,CAAC,gBAAgB,KAAK;EA2EtF"}
|
package/dist/react.js
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { createContext, useContext, useEffect, useMemo, useState } from "react";
|
|
3
|
+
import { combineLatest, map } from "rxjs";
|
|
4
|
+
export function createRepositoryContext() {
|
|
5
|
+
const RepositoryReactContext = createContext(null);
|
|
6
|
+
function RepositoryProvider({ repository, children, }) {
|
|
7
|
+
return (_jsx(RepositoryReactContext.Provider, { value: repository, children: children }));
|
|
8
|
+
}
|
|
9
|
+
function useRepository() {
|
|
10
|
+
const context = useContext(RepositoryReactContext);
|
|
11
|
+
if (!context) {
|
|
12
|
+
throw new Error("RepositoryProvider is missing.");
|
|
13
|
+
}
|
|
14
|
+
return context;
|
|
15
|
+
}
|
|
16
|
+
function useSubscribedState(observable, initialValue) {
|
|
17
|
+
const [state, setState] = useState(initialValue);
|
|
18
|
+
useEffect(() => {
|
|
19
|
+
const subscription = observable.subscribe((value) => {
|
|
20
|
+
setState(value);
|
|
21
|
+
});
|
|
22
|
+
return () => subscription.unsubscribe();
|
|
23
|
+
}, [observable]);
|
|
24
|
+
return state;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Subscribes to a single-record query keyed only by `id`.
|
|
28
|
+
*
|
|
29
|
+
* NOTE: `table` and `fetcher` changes are intentionally ignored so the
|
|
30
|
+
* query instance remains stable for a given `id`.
|
|
31
|
+
*/
|
|
32
|
+
function useRepositoryQuery(table, id, fetcher) {
|
|
33
|
+
const repository = useRepository();
|
|
34
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps -- table/fetcher intentionally ignored
|
|
35
|
+
const recordQuery = useMemo(() => repository.recordQuery(table, id, fetcher), [repository, JSON.stringify(id)]);
|
|
36
|
+
return useSubscribedState(recordQuery.$state, recordQuery.$state.value);
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Subscribes to a list query keyed only by `param`.
|
|
40
|
+
*
|
|
41
|
+
* NOTE: `options` and `fetcher` changes are intentionally ignored so the
|
|
42
|
+
* list query instance remains stable for a given `param`.
|
|
43
|
+
*/
|
|
44
|
+
function useRepositoryListQuery(table, param, options, fetcher) {
|
|
45
|
+
const repository = useRepository();
|
|
46
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps -- table/options/fetcher intentionally ignored
|
|
47
|
+
const listQuery = useMemo(() => repository.listQuery(table, options, () => fetcher(param)), [repository, JSON.stringify(param)]);
|
|
48
|
+
useEffect(() => {
|
|
49
|
+
return () => listQuery.dispose();
|
|
50
|
+
}, [listQuery]);
|
|
51
|
+
const $state = useMemo(() => combineLatest([listQuery.$records, listQuery.$status]).pipe(map(([records, status]) => ({ records, ...status }))), [listQuery]);
|
|
52
|
+
return useSubscribedState($state, { records: listQuery.$records.value, ...listQuery.$status.value });
|
|
53
|
+
}
|
|
54
|
+
return {
|
|
55
|
+
RepositoryProvider,
|
|
56
|
+
useRepository,
|
|
57
|
+
useRepositoryQuery,
|
|
58
|
+
useRepositoryListQuery,
|
|
59
|
+
useSubscribedState,
|
|
60
|
+
};
|
|
61
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { BehaviorSubject } from "rxjs";
|
|
2
|
+
import type { EntityConfig, EntityDefinitions, EntityIdTuple, RepositoryQuery } from "./types";
|
|
3
|
+
import type { Repository } from "./repository";
|
|
4
|
+
export declare class RecordQuery<Definitions extends EntityDefinitions, Config extends EntityConfig<Definitions>, Table extends keyof Definitions> {
|
|
5
|
+
readonly $state: BehaviorSubject<RepositoryQuery<Definitions[Table]>>;
|
|
6
|
+
private subscription;
|
|
7
|
+
private repository;
|
|
8
|
+
private table;
|
|
9
|
+
private id;
|
|
10
|
+
private fetcher;
|
|
11
|
+
constructor(repository: Repository<Definitions, Config>, table: Table, id: EntityIdTuple<Definitions, Config, Table>, fetcher: (id: EntityIdTuple<Definitions, Config, Table>) => Promise<Definitions[Table]>);
|
|
12
|
+
fetch(): Promise<Definitions[Table]>;
|
|
13
|
+
dispose(): void;
|
|
14
|
+
}
|
|
15
|
+
//# sourceMappingURL=record-query.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"record-query.d.ts","sourceRoot":"","sources":["../src/record-query.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,eAAe,EAAgB,MAAM,MAAM,CAAC;AAErD,OAAO,KAAK,EACV,YAAY,EACZ,iBAAiB,EACjB,aAAa,EACb,eAAe,EAChB,MAAM,SAAS,CAAC;AACjB,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,cAAc,CAAC;AAE/C,qBAAa,WAAW,CACtB,WAAW,SAAS,iBAAiB,EACrC,MAAM,SAAS,YAAY,CAAC,WAAW,CAAC,EACxC,KAAK,SAAS,MAAM,WAAW;IAE/B,QAAQ,CAAC,MAAM,EAAE,eAAe,CAAC,eAAe,CAAC,WAAW,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC;IACtE,OAAO,CAAC,YAAY,CAAe;IACnC,OAAO,CAAC,UAAU,CAAkC;IACpD,OAAO,CAAC,KAAK,CAAQ;IACrB,OAAO,CAAC,EAAE,CAA4C;IACtD,OAAO,CAAC,OAAO,CAAiF;gBAG9F,UAAU,EAAE,UAAU,CAAC,WAAW,EAAE,MAAM,CAAC,EAC3C,KAAK,EAAE,KAAK,EACZ,EAAE,EAAE,aAAa,CAAC,WAAW,EAAE,MAAM,EAAE,KAAK,CAAC,EAC7C,OAAO,EAAE,CAAC,EAAE,EAAE,aAAa,CAAC,WAAW,EAAE,MAAM,EAAE,KAAK,CAAC,KAAK,OAAO,CAAC,WAAW,CAAC,KAAK,CAAC,CAAC;IAuBnF,KAAK;IA2BX,OAAO;CAGR"}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { BehaviorSubject } from "rxjs";
|
|
2
|
+
export class RecordQuery {
|
|
3
|
+
$state;
|
|
4
|
+
subscription;
|
|
5
|
+
repository;
|
|
6
|
+
table;
|
|
7
|
+
id;
|
|
8
|
+
fetcher;
|
|
9
|
+
constructor(repository, table, id, fetcher) {
|
|
10
|
+
this.repository = repository;
|
|
11
|
+
this.table = table;
|
|
12
|
+
this.id = id;
|
|
13
|
+
this.fetcher = fetcher;
|
|
14
|
+
const entity = repository.get(table, id);
|
|
15
|
+
const initialState = entity
|
|
16
|
+
? { entity, status: "idle" }
|
|
17
|
+
: { entity: null, status: "fetching" };
|
|
18
|
+
this.$state = new BehaviorSubject(initialState);
|
|
19
|
+
this.subscription = repository.getObservable(table, id).subscribe((value) => {
|
|
20
|
+
this.$state.next({ ...this.$state.value, entity: value });
|
|
21
|
+
});
|
|
22
|
+
if (!entity) {
|
|
23
|
+
void this.fetch();
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
async fetch() {
|
|
27
|
+
const current = this.repository.get(this.table, this.id);
|
|
28
|
+
if (current) {
|
|
29
|
+
this.$state.next({ entity: current, status: "idle" });
|
|
30
|
+
return current;
|
|
31
|
+
}
|
|
32
|
+
this.$state.next({ ...this.$state.value, status: "fetching" });
|
|
33
|
+
try {
|
|
34
|
+
const value = await this.repository.fetch(this.table, this.id, this.fetcher);
|
|
35
|
+
this.$state.next({ entity: value, status: "idle" });
|
|
36
|
+
return value;
|
|
37
|
+
}
|
|
38
|
+
catch (error) {
|
|
39
|
+
if (error instanceof Error) {
|
|
40
|
+
this.$state.next({ entity: this.$state.value.entity, status: "error", error });
|
|
41
|
+
}
|
|
42
|
+
else {
|
|
43
|
+
this.$state.next({
|
|
44
|
+
entity: this.$state.value.entity,
|
|
45
|
+
status: "error",
|
|
46
|
+
error: new Error("RecordQuery fetch failed"),
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
throw error;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
dispose() {
|
|
53
|
+
this.subscription.unsubscribe();
|
|
54
|
+
}
|
|
55
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { BehaviorSubject, Subject } from "rxjs";
|
|
2
|
+
import type { EntityConfig, EntityDefinitions, EntityEvent, EntityIdTuple, RepositoryConfig } from "./types";
|
|
3
|
+
import { ListQuery, type ListQueryOptions } from "./list-query";
|
|
4
|
+
import { RecordQuery } from "./record-query";
|
|
5
|
+
export declare class Repository<Definitions extends EntityDefinitions, Config extends EntityConfig<Definitions> = EntityConfig<Definitions>> {
|
|
6
|
+
private stores;
|
|
7
|
+
private config;
|
|
8
|
+
constructor(config: RepositoryConfig<Definitions, Config>);
|
|
9
|
+
set<Table extends keyof Definitions>(table: Table, entity: Definitions[Table]): void;
|
|
10
|
+
del<Table extends keyof Definitions>(table: Table, id: EntityIdTuple<Definitions, Config, Table>): void;
|
|
11
|
+
get<Table extends keyof Definitions>(table: Table, id: EntityIdTuple<Definitions, Config, Table>): Definitions[Table] | null;
|
|
12
|
+
fetch<Table extends keyof Definitions>(table: Table, id: EntityIdTuple<Definitions, Config, Table>, fetcher: (id: EntityIdTuple<Definitions, Config, Table>) => Promise<Definitions[Table]>): Promise<Definitions[Table]>;
|
|
13
|
+
getObservable<Table extends keyof Definitions>(table: Table, id: EntityIdTuple<Definitions, Config, Table>): BehaviorSubject<Definitions[Table] | null>;
|
|
14
|
+
getEvents<Table extends keyof Definitions>(table: Table): Subject<EntityEvent<Definitions[Table]>>;
|
|
15
|
+
recordQuery<Table extends keyof Definitions>(table: Table, id: EntityIdTuple<Definitions, Config, Table>, fetcher: (id: EntityIdTuple<Definitions, Config, Table>) => Promise<Definitions[Table]>): RecordQuery<Definitions, Config, Table>;
|
|
16
|
+
getCacheKey<Table extends keyof Definitions>(table: Table, id: EntityIdTuple<Definitions, Config, Table>): string;
|
|
17
|
+
getEntityKey<Table extends keyof Definitions>(table: Table, entity: Definitions[Table]): string;
|
|
18
|
+
listQuery<Table extends keyof Definitions>(table: Table, options: ListQueryOptions<Definitions[Table]>, fetcher: () => Promise<Definitions[Table][]>): ListQuery<Definitions, Config, Table>;
|
|
19
|
+
private getStore;
|
|
20
|
+
private getIdKey;
|
|
21
|
+
private getCacheKeyFromId;
|
|
22
|
+
private getCacheKeyFromEntity;
|
|
23
|
+
}
|
|
24
|
+
//# sourceMappingURL=repository.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"repository.d.ts","sourceRoot":"","sources":["../src/repository.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { BehaviorSubject, Subject } from "rxjs";
|
|
2
|
+
import { ListQuery } from "./list-query";
|
|
3
|
+
import { RecordQuery } from "./record-query";
|
|
4
|
+
export class Repository {
|
|
5
|
+
stores = new Map();
|
|
6
|
+
config;
|
|
7
|
+
constructor(config) {
|
|
8
|
+
this.config = config;
|
|
9
|
+
}
|
|
10
|
+
set(table, entity) {
|
|
11
|
+
const store = this.getStore(table);
|
|
12
|
+
const cacheKey = this.getCacheKeyFromEntity(table, entity);
|
|
13
|
+
const existing = store.records.get(cacheKey);
|
|
14
|
+
store.records.set(cacheKey, entity);
|
|
15
|
+
if (existing) {
|
|
16
|
+
store.events$.next({
|
|
17
|
+
timestamp: new Date(),
|
|
18
|
+
type: "update",
|
|
19
|
+
old: existing,
|
|
20
|
+
new: entity,
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
else {
|
|
24
|
+
store.events$.next({
|
|
25
|
+
timestamp: new Date(),
|
|
26
|
+
type: "insert",
|
|
27
|
+
new: entity,
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
const subject = store.subjects.get(cacheKey);
|
|
31
|
+
if (subject) {
|
|
32
|
+
subject.next(entity);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
del(table, id) {
|
|
36
|
+
const store = this.getStore(table);
|
|
37
|
+
const cacheKey = this.getCacheKeyFromId(table, id);
|
|
38
|
+
const existing = store.records.get(cacheKey);
|
|
39
|
+
store.records.delete(cacheKey);
|
|
40
|
+
if (existing) {
|
|
41
|
+
store.events$.next({
|
|
42
|
+
timestamp: new Date(),
|
|
43
|
+
type: "delete",
|
|
44
|
+
old: existing,
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
const subject = store.subjects.get(cacheKey);
|
|
48
|
+
if (subject) {
|
|
49
|
+
subject.next(null);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
get(table, id) {
|
|
53
|
+
const store = this.getStore(table);
|
|
54
|
+
const cacheKey = this.getCacheKeyFromId(table, id);
|
|
55
|
+
const record = store.records.get(cacheKey);
|
|
56
|
+
return record ?? null;
|
|
57
|
+
}
|
|
58
|
+
async fetch(table, id, fetcher) {
|
|
59
|
+
const store = this.getStore(table);
|
|
60
|
+
const cacheKey = this.getCacheKeyFromId(table, id);
|
|
61
|
+
const record = store.records.get(cacheKey);
|
|
62
|
+
if (record) {
|
|
63
|
+
return record;
|
|
64
|
+
}
|
|
65
|
+
const inflight = store.inflight.get(cacheKey);
|
|
66
|
+
if (inflight) {
|
|
67
|
+
return inflight;
|
|
68
|
+
}
|
|
69
|
+
const request = (async () => {
|
|
70
|
+
const value = await fetcher(id);
|
|
71
|
+
this.set(table, value);
|
|
72
|
+
return value;
|
|
73
|
+
})();
|
|
74
|
+
store.inflight.set(cacheKey, request);
|
|
75
|
+
request.finally(() => {
|
|
76
|
+
store.inflight.delete(cacheKey);
|
|
77
|
+
});
|
|
78
|
+
return request;
|
|
79
|
+
}
|
|
80
|
+
getObservable(table, id) {
|
|
81
|
+
const store = this.getStore(table);
|
|
82
|
+
const cacheKey = this.getCacheKeyFromId(table, id);
|
|
83
|
+
const existing = store.subjects.get(cacheKey);
|
|
84
|
+
if (existing) {
|
|
85
|
+
return existing;
|
|
86
|
+
}
|
|
87
|
+
const record = store.records.get(cacheKey);
|
|
88
|
+
const subject = new BehaviorSubject(record ?? null);
|
|
89
|
+
store.subjects.set(cacheKey, subject);
|
|
90
|
+
return subject;
|
|
91
|
+
}
|
|
92
|
+
getEvents(table) {
|
|
93
|
+
const store = this.getStore(table);
|
|
94
|
+
return store.events$;
|
|
95
|
+
}
|
|
96
|
+
recordQuery(table, id, fetcher) {
|
|
97
|
+
return new RecordQuery(this, table, id, fetcher);
|
|
98
|
+
}
|
|
99
|
+
getCacheKey(table, id) {
|
|
100
|
+
return this.getCacheKeyFromId(table, id);
|
|
101
|
+
}
|
|
102
|
+
getEntityKey(table, entity) {
|
|
103
|
+
return this.getCacheKeyFromEntity(table, entity);
|
|
104
|
+
}
|
|
105
|
+
listQuery(table, options, fetcher) {
|
|
106
|
+
return new ListQuery(this, table, options, fetcher);
|
|
107
|
+
}
|
|
108
|
+
getStore(table) {
|
|
109
|
+
const existing = this.stores.get(table);
|
|
110
|
+
if (existing) {
|
|
111
|
+
return existing;
|
|
112
|
+
}
|
|
113
|
+
const store = {
|
|
114
|
+
records: new Map(),
|
|
115
|
+
subjects: new Map(),
|
|
116
|
+
inflight: new Map(),
|
|
117
|
+
events$: new Subject(),
|
|
118
|
+
};
|
|
119
|
+
this.stores.set(table, store);
|
|
120
|
+
return store;
|
|
121
|
+
}
|
|
122
|
+
getIdKey(table) {
|
|
123
|
+
return this.config.entities[table].id;
|
|
124
|
+
}
|
|
125
|
+
getCacheKeyFromId(table, id) {
|
|
126
|
+
const idKey = this.getIdKey(table);
|
|
127
|
+
const idValue = id[idKey];
|
|
128
|
+
if (idValue === undefined || idValue === null) {
|
|
129
|
+
throw new Error(`Missing identifier "${String(idKey)}" for ${String(table)}`);
|
|
130
|
+
}
|
|
131
|
+
return String(idValue);
|
|
132
|
+
}
|
|
133
|
+
getCacheKeyFromEntity(table, entity) {
|
|
134
|
+
const idKey = this.getIdKey(table);
|
|
135
|
+
const idValue = entity[idKey];
|
|
136
|
+
if (idValue === undefined || idValue === null) {
|
|
137
|
+
throw new Error(`Missing identifier "${String(idKey)}" for ${String(table)}`);
|
|
138
|
+
}
|
|
139
|
+
return String(idValue);
|
|
140
|
+
}
|
|
141
|
+
}
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
export type EntityDefinitions = Record<string, Record<string, unknown>>;
|
|
2
|
+
export type EntityConfig<Definitions extends EntityDefinitions> = {
|
|
3
|
+
[Table in keyof Definitions]: {
|
|
4
|
+
id: keyof Definitions[Table] & string;
|
|
5
|
+
};
|
|
6
|
+
};
|
|
7
|
+
export type RepositoryConfig<Definitions extends EntityDefinitions, Config extends EntityConfig<Definitions>> = {
|
|
8
|
+
entities: Config;
|
|
9
|
+
};
|
|
10
|
+
export type EntityIdTuple<Definitions extends EntityDefinitions, Config extends EntityConfig<Definitions>, Table extends keyof Definitions> = Pick<Definitions[Table], Config[Table]["id"]>;
|
|
11
|
+
export type EntityEvent<Entity> = {
|
|
12
|
+
timestamp: Date;
|
|
13
|
+
} & ({
|
|
14
|
+
type: "insert";
|
|
15
|
+
new: Entity;
|
|
16
|
+
} | {
|
|
17
|
+
type: "update";
|
|
18
|
+
old: Entity;
|
|
19
|
+
new: Entity;
|
|
20
|
+
} | {
|
|
21
|
+
type: "delete";
|
|
22
|
+
old: Entity;
|
|
23
|
+
});
|
|
24
|
+
export type RepositoryQuery<Entity> = {
|
|
25
|
+
entity: Entity | null;
|
|
26
|
+
} & ({
|
|
27
|
+
status: "fetching";
|
|
28
|
+
} | {
|
|
29
|
+
status: "idle";
|
|
30
|
+
} | {
|
|
31
|
+
status: "error";
|
|
32
|
+
error: Error;
|
|
33
|
+
});
|
|
34
|
+
//# sourceMappingURL=types.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA,MAAM,MAAM,iBAAiB,GAAG,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC,CAAC;AAExE,MAAM,MAAM,YAAY,CAAC,WAAW,SAAS,iBAAiB,IAAI;KAC/D,KAAK,IAAI,MAAM,WAAW,GAAG;QAAE,EAAE,EAAE,MAAM,WAAW,CAAC,KAAK,CAAC,GAAG,MAAM,CAAA;KAAE;CACxE,CAAC;AAEF,MAAM,MAAM,gBAAgB,CAC1B,WAAW,SAAS,iBAAiB,EACrC,MAAM,SAAS,YAAY,CAAC,WAAW,CAAC,IACtC;IACF,QAAQ,EAAE,MAAM,CAAC;CAClB,CAAC;AAEF,MAAM,MAAM,aAAa,CACvB,WAAW,SAAS,iBAAiB,EACrC,MAAM,SAAS,YAAY,CAAC,WAAW,CAAC,EACxC,KAAK,SAAS,MAAM,WAAW,IAC7B,IAAI,CAAC,WAAW,CAAC,KAAK,CAAC,EAAE,MAAM,CAAC,KAAK,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC;AAElD,MAAM,MAAM,WAAW,CAAC,MAAM,IAAI;IAChC,SAAS,EAAE,IAAI,CAAC;CACjB,GAAG,CACA;IAAE,IAAI,EAAE,QAAQ,CAAC;IAAC,GAAG,EAAE,MAAM,CAAA;CAAE,GAC/B;IAAE,IAAI,EAAE,QAAQ,CAAC;IAAC,GAAG,EAAE,MAAM,CAAC;IAAC,GAAG,EAAE,MAAM,CAAA;CAAE,GAC5C;IAAE,IAAI,EAAE,QAAQ,CAAC;IAAC,GAAG,EAAE,MAAM,CAAA;CAAE,CAClC,CAAC;AAEF,MAAM,MAAM,eAAe,CAAC,MAAM,IAAI;IACpC,MAAM,EAAE,MAAM,GAAG,IAAI,CAAC;CACvB,GAAG,CACA;IAAE,MAAM,EAAE,UAAU,CAAA;CAAE,GACtB;IAAE,MAAM,EAAE,MAAM,CAAA;CAAE,GAClB;IAAE,MAAM,EAAE,OAAO,CAAC;IAAC,KAAK,EAAE,KAAK,CAAA;CAAE,CACpC,CAAC"}
|
package/dist/types.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/package.json
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "entity-repository",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Type-safe entity caching and state management with RxJS and React",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./dist/index.js",
|
|
7
|
+
"types": "./dist/index.d.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"types": "./dist/index.d.ts",
|
|
11
|
+
"import": "./dist/index.js"
|
|
12
|
+
}
|
|
13
|
+
},
|
|
14
|
+
"files": [
|
|
15
|
+
"dist"
|
|
16
|
+
],
|
|
17
|
+
"scripts": {
|
|
18
|
+
"build": "tsc",
|
|
19
|
+
"clean": "rm -rf dist",
|
|
20
|
+
"prepublishOnly": "npm run clean && npm run build"
|
|
21
|
+
},
|
|
22
|
+
"keywords": [
|
|
23
|
+
"entity",
|
|
24
|
+
"repository",
|
|
25
|
+
"cache",
|
|
26
|
+
"rxjs",
|
|
27
|
+
"react",
|
|
28
|
+
"state-management",
|
|
29
|
+
"typescript"
|
|
30
|
+
],
|
|
31
|
+
"author": "Kurt Lee",
|
|
32
|
+
"license": "MIT",
|
|
33
|
+
"repository": {
|
|
34
|
+
"type": "git",
|
|
35
|
+
"url": "git+https://github.com/breath103/entity-repository.git"
|
|
36
|
+
},
|
|
37
|
+
"homepage": "https://github.com/breath103/entity-repository#readme",
|
|
38
|
+
"bugs": {
|
|
39
|
+
"url": "https://github.com/breath103/entity-repository/issues"
|
|
40
|
+
},
|
|
41
|
+
"dependencies": {
|
|
42
|
+
"rxjs": "^7.8.1"
|
|
43
|
+
},
|
|
44
|
+
"peerDependencies": {
|
|
45
|
+
"react": ">=18.0.0"
|
|
46
|
+
},
|
|
47
|
+
"peerDependenciesMeta": {
|
|
48
|
+
"react": {
|
|
49
|
+
"optional": true
|
|
50
|
+
}
|
|
51
|
+
},
|
|
52
|
+
"devDependencies": {
|
|
53
|
+
"@types/react": "^19.2.8",
|
|
54
|
+
"typescript": "^5.9.3"
|
|
55
|
+
}
|
|
56
|
+
}
|