rxfy 0.2.1 → 0.3.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 +287 -18
- package/dist/index.cjs +359 -4875
- package/dist/index.d.cts +184 -44
- package/dist/index.d.ts +184 -44
- package/dist/index.js +329 -4872
- package/package.json +22 -12
package/README.md
CHANGED
|
@@ -1,29 +1,298 @@
|
|
|
1
1
|
# rxfy
|
|
2
2
|
|
|
3
|
-
rxfy (/ɑɹ ɪks faɪ/)
|
|
3
|
+
rxfy (/ɑɹ ɪks faɪ/) is a small library that lets you declare typed models and the states that query them, then access their data as reactive observables. Normalization keeps your app consistent and reactive at no extra cost. Built on RxJS.
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
## Install
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
```bash
|
|
8
|
+
npm install rxfy
|
|
9
|
+
# or: pnpm add rxfy / yarn add rxfy
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
## Peer dependencies
|
|
13
|
+
|
|
14
|
+
```json
|
|
15
|
+
{
|
|
16
|
+
"rxjs": "^7.0.0",
|
|
17
|
+
"zod": "^3.0.0",
|
|
18
|
+
"lodash": "^4.0.0"
|
|
19
|
+
}
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
---
|
|
23
|
+
|
|
24
|
+
## High-level API
|
|
25
|
+
|
|
26
|
+
Define typed state shapes, normalize entities, and mutate state without manual bookkeeping.
|
|
27
|
+
|
|
28
|
+
### `defineState`
|
|
29
|
+
|
|
30
|
+
Defines a typed state shape: fetch params schema, model fields, and optional mutations.
|
|
31
|
+
|
|
32
|
+
```ts
|
|
33
|
+
import { z } from "zod";
|
|
34
|
+
import { defineState, array } from "rxfy";
|
|
35
|
+
|
|
36
|
+
const todosState = defineState({
|
|
37
|
+
key: "todos", // stable string identity for the SSR query cache
|
|
38
|
+
params: z.object({ filter: z.enum(["all", "active", "done"]) }),
|
|
39
|
+
model: { todos: array(TodoModel) },
|
|
40
|
+
mutations: {
|
|
41
|
+
addTodo: (prev, todo: Todo) => ({ ...prev, todos: [...prev.todos, todo] }),
|
|
42
|
+
},
|
|
43
|
+
});
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
Mutation reducers operate on the full fetch shape (entities). When invoked through `useStateData`, rxfy denormalizes the current ids into fresh entities, runs the reducer, and normalizes the result back into model stores and ids.
|
|
47
|
+
|
|
48
|
+
**Signature:**
|
|
49
|
+
|
|
50
|
+
```ts
|
|
51
|
+
function defineState<TParams, TFields, TMutations>(def: {
|
|
52
|
+
key?: string; // states without a key opt out of SSR caching
|
|
53
|
+
params: z.ZodType<TParams>;
|
|
54
|
+
model: TFields;
|
|
55
|
+
mutations?: TMutations;
|
|
56
|
+
}): StateDescriptor<TParams, ShapeFromFields<TFields>, TMutations>
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
The normalized query shape (what `data$` emits in `rxfy-react`) is derived as `QueryShapeOf<TShape>`: array fields become `string[]` (entity keys), single fields become `string`.
|
|
60
|
+
|
|
61
|
+
### `createModel`
|
|
62
|
+
|
|
63
|
+
Creates a typed model descriptor for normalizing and sharing entities across state slices.
|
|
64
|
+
|
|
65
|
+
```ts
|
|
66
|
+
import { z } from "zod";
|
|
67
|
+
import { createModel } from "rxfy";
|
|
68
|
+
|
|
69
|
+
const TodoModel = createModel(
|
|
70
|
+
z.object({ id: z.string(), title: z.string(), done: z.boolean() }),
|
|
71
|
+
{ getKey: (todo) => todo.id, name: "todo" },
|
|
72
|
+
);
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
**Signature:**
|
|
76
|
+
|
|
77
|
+
```ts
|
|
78
|
+
function createModel<T>(
|
|
79
|
+
schema: z.ZodType<T>,
|
|
80
|
+
opts: { getKey: (item: T) => string; name?: string },
|
|
81
|
+
): ModelDescriptor<T>
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
`name` is the model's stable string identity for SSR; symbols can't cross the server/client boundary, so only named models are included in `dehydrate` output. Models without a name work normally but opt out of SSR serialization (a dev warning fires if they hold data at dehydrate time).
|
|
85
|
+
|
|
86
|
+
### `array` / `single`
|
|
87
|
+
|
|
88
|
+
Field descriptor helpers that declare whether a `defineState` model field holds an array or a single item.
|
|
89
|
+
|
|
90
|
+
```ts
|
|
91
|
+
import { array, single } from "rxfy";
|
|
92
|
+
|
|
93
|
+
const userPageState = defineState({
|
|
94
|
+
params: z.object({ userId: z.string() }),
|
|
95
|
+
model: {
|
|
96
|
+
user: single(UserModel), // one item
|
|
97
|
+
friends: array(UserModel), // array of items
|
|
98
|
+
},
|
|
99
|
+
});
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
**Signatures:**
|
|
103
|
+
|
|
104
|
+
```ts
|
|
105
|
+
function array<T>(model: ModelDescriptor<T>): FieldDescriptor<T[]>
|
|
106
|
+
function single<T>(model: ModelDescriptor<T>): FieldDescriptor<T>
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
### `createModelRegistry` / `createModelStore`
|
|
110
|
+
|
|
111
|
+
Low-level normalized storage. In React apps these are wired automatically by `StoreProvider` from `rxfy-react`; use directly for non-React or custom setups.
|
|
112
|
+
|
|
113
|
+
```ts
|
|
114
|
+
import { z } from "zod";
|
|
115
|
+
import { createModel, createModelRegistry } from "rxfy";
|
|
116
|
+
|
|
117
|
+
const UserModel = createModel(
|
|
118
|
+
z.object({ id: z.string(), name: z.string() }),
|
|
119
|
+
{ getKey: (u) => u.id },
|
|
120
|
+
);
|
|
121
|
+
|
|
122
|
+
const registry = createModelRegistry();
|
|
123
|
+
const users = registry.model(UserModel);
|
|
124
|
+
|
|
125
|
+
users.set("1", { id: "1", name: "Alice" });
|
|
126
|
+
users.get("1").subscribe(console.log); // emits { id: "1", name: "Alice" }
|
|
127
|
+
// Note: get() on a key that has never had set() called returns an Observable that
|
|
128
|
+
// never emits until set() or setMany() is called for that key.
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
**Signatures:**
|
|
132
|
+
|
|
133
|
+
```ts
|
|
134
|
+
function createModelRegistry(): IModelRegistry
|
|
135
|
+
// IModelRegistry: {
|
|
136
|
+
// model<T>(descriptor: ModelDescriptor<T>): ModelStore<T>;
|
|
137
|
+
// queries: QueryCache; // SSR query cache (fulfilled/rejected entries)
|
|
138
|
+
// namedStores(): ReadonlyMap<string, ModelStore<any>>;
|
|
139
|
+
// stores(): { descriptor; store }[];
|
|
140
|
+
// stashHydration(name: string, entities: Record<string, unknown>): void;
|
|
141
|
+
// }
|
|
142
|
+
|
|
143
|
+
function createModelStore<T>(descriptor: ModelDescriptor<T>): ModelStore<T>
|
|
144
|
+
// ModelStore<T>: {
|
|
145
|
+
// get(key: string): Observable<T>; // reactive read; emits on every change
|
|
146
|
+
// set(key, val): void; // write one entity
|
|
147
|
+
// setMany(items): void; // write many; key derived via getKey
|
|
148
|
+
// getValue(key: string): T | undefined; // synchronous read of the latest value
|
|
149
|
+
// entity(key: string): IAtom<T>; // writable handle over one entity's cell
|
|
150
|
+
// valueEntries(): [string, T][]; // snapshot of all loaded [key, value] pairs
|
|
151
|
+
// added$: Observable<string>; // a key, the first time its entity appears
|
|
152
|
+
// }
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
---
|
|
156
|
+
|
|
157
|
+
## SSR
|
|
158
|
+
|
|
159
|
+
The registry round-trips across the server/client boundary: queries serialize as normalized ids, named model stores serialize their entities. The React side (`rxfy-react`) drives fetching and ingestion; these are the core primitives it builds on.
|
|
160
|
+
|
|
161
|
+
### `dehydrate` / `hydrate`
|
|
8
162
|
|
|
9
|
-
|
|
163
|
+
```ts
|
|
164
|
+
import { createModelRegistry, dehydrate, hydrate } from "rxfy";
|
|
165
|
+
|
|
166
|
+
// server: after rendering settles
|
|
167
|
+
const state = dehydrate(registry);
|
|
168
|
+
// { queries: { "todos:{...}": { status: "fulfilled", value: { todos: ["1"] } } },
|
|
169
|
+
// models: { todo: { "1": { id: "1", title: "..." } } } }
|
|
10
170
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
- Edge — data handler and accessor
|
|
171
|
+
// client: into a fresh registry before first render
|
|
172
|
+
hydrate(clientRegistry, state);
|
|
173
|
+
```
|
|
15
174
|
|
|
16
|
-
|
|
175
|
+
**Signatures:**
|
|
17
176
|
|
|
18
177
|
```ts
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
import { createAtom, createState, createStore } from "rxfy";
|
|
178
|
+
function dehydrate(registry: IModelRegistry): DehydratedState
|
|
179
|
+
function hydrate(registry: IModelRegistry, state: DehydratedState): void
|
|
22
180
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
const user$ = userStore.get("42").toObservable();
|
|
28
|
-
user$.subscribe((x) => console.log(x));
|
|
181
|
+
type DehydratedState = {
|
|
182
|
+
queries: Record<string, QueryEntry>;
|
|
183
|
+
models: Record<string, Record<string, unknown>>;
|
|
184
|
+
};
|
|
29
185
|
```
|
|
186
|
+
|
|
187
|
+
### `hydrationScript`
|
|
188
|
+
|
|
189
|
+
Complete inline `<script>` tag pushing a snapshot onto `window.__RXFY_SSR__`, the queue the client `StoreProvider` drains automatically, so the client side needs no hydration wiring. Inject it into the served HTML before the client entry script.
|
|
190
|
+
|
|
191
|
+
```ts
|
|
192
|
+
import { dehydrate, hydrationScript } from "rxfy";
|
|
193
|
+
|
|
194
|
+
const html = template.replace("<!--app-state-->", hydrationScript(dehydrate(registry)));
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
**Signature:**
|
|
198
|
+
|
|
199
|
+
```ts
|
|
200
|
+
function hydrationScript(state: DehydratedState): string
|
|
201
|
+
// '<script>(window.__RXFY_SSR__=window.__RXFY_SSR__||[]).push({...})</script>'
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
The payload is embedded via `serializeForHtml` (also exported): JSON with `<` and U+2028/U+2029 escaped so it cannot break out of the script tag.
|
|
205
|
+
|
|
206
|
+
### Internal primitives
|
|
207
|
+
|
|
208
|
+
`stableStringify`, `normalizeResult`, `denormalizeValue`, `createQueryCache`, `markSync`, `isSyncMarked`, `attachReload`, `getAttachedReload`, `serializeError`, `rehydrateError` are exported because `rxfy-react` consumes them across the package boundary. They are implementation plumbing, not the intended app-facing surface; prefer the APIs above.
|
|
209
|
+
|
|
210
|
+
---
|
|
211
|
+
|
|
212
|
+
## Primitive API
|
|
213
|
+
|
|
214
|
+
Lower-level building blocks. Use these for custom reactive patterns or when the high-level API doesn't fit.
|
|
215
|
+
|
|
216
|
+
### `Atom` / `createAtom`
|
|
217
|
+
|
|
218
|
+
A reactive cell that extends `Observable<T>` with synchronous `get()`, `set()`, and `modify()`, backed by a `BehaviorSubject`.
|
|
219
|
+
|
|
220
|
+
```ts
|
|
221
|
+
import { createAtom } from "rxfy";
|
|
222
|
+
|
|
223
|
+
const count = createAtom(0);
|
|
224
|
+
|
|
225
|
+
count.get(); // 0
|
|
226
|
+
count.set(5);
|
|
227
|
+
count.modify((n) => n + 1);
|
|
228
|
+
count.get(); // 6
|
|
229
|
+
|
|
230
|
+
count.subscribe((n) => console.log(n)); // emits current value then future changes
|
|
231
|
+
```
|
|
232
|
+
|
|
233
|
+
**Signature:**
|
|
234
|
+
|
|
235
|
+
```ts
|
|
236
|
+
function createAtom<T>(value: T): Atom<T>
|
|
237
|
+
// Atom<T>: Observable<T> & { get(): T; set(val: T): void; modify(fn: (val: T) => T): void }
|
|
238
|
+
```
|
|
239
|
+
|
|
240
|
+
### `Lens` / `createLens` / `keyLens`
|
|
241
|
+
|
|
242
|
+
A focused, bidirectional view into an `Atom`. Reads and writes propagate in both directions. Uses `lodash.isEqual` for change detection.
|
|
243
|
+
|
|
244
|
+
```ts
|
|
245
|
+
import { createAtom, createLens, keyLens } from "rxfy";
|
|
246
|
+
|
|
247
|
+
const user = createAtom({ id: "1", name: "Alice" });
|
|
248
|
+
const name = createLens(user, keyLens("name"));
|
|
249
|
+
|
|
250
|
+
name.get(); // "Alice"
|
|
251
|
+
name.set("Bob");
|
|
252
|
+
user.get(); // { id: "1", name: "Bob" }
|
|
253
|
+
```
|
|
254
|
+
|
|
255
|
+
**Signatures:**
|
|
256
|
+
|
|
257
|
+
```ts
|
|
258
|
+
function createLens<S, T>(source$: IAtom<S>, lens: ILens<S, T>): Lens<S, T>
|
|
259
|
+
|
|
260
|
+
function keyLens<S, K extends keyof S>(key: K): ILens<S, S[K]>
|
|
261
|
+
// ILens<S, T>: { get(source: S): T; set(current: T, source: S): S }
|
|
262
|
+
```
|
|
263
|
+
|
|
264
|
+
### `IWrapped` / `StatusEnum` / helpers
|
|
265
|
+
|
|
266
|
+
Discriminated union for async state, the `IDLE / PENDING / FULFILLED / REJECTED` pattern used throughout rxfy. It is what the query cache holds per key and what `usePending` returns in `rxfy-react`; also available for custom async primitives.
|
|
267
|
+
|
|
268
|
+
```ts
|
|
269
|
+
import { createIdle, createPending, createFulfilled, createRejected } from "rxfy";
|
|
270
|
+
|
|
271
|
+
createIdle<string>(); // { type: "IDLE" }
|
|
272
|
+
createPending<string>(); // { type: "PENDING" }
|
|
273
|
+
createFulfilled("hi"); // { type: "FULFILLED", value: "hi" }
|
|
274
|
+
createRejected("oops"); // { type: "REJECTED", error: "oops" }
|
|
275
|
+
```
|
|
276
|
+
|
|
277
|
+
**Signatures:**
|
|
278
|
+
|
|
279
|
+
```ts
|
|
280
|
+
function createIdle<T>(): IWrapped<T, StatusEnum.IDLE>
|
|
281
|
+
function createPending<T>(): IWrapped<T, StatusEnum.PENDING>
|
|
282
|
+
function createFulfilled<T>(value: T): IWrapped<T, StatusEnum.FULFILLED>
|
|
283
|
+
function createRejected<T>(error: unknown): IWrapped<T, StatusEnum.REJECTED>
|
|
284
|
+
```
|
|
285
|
+
|
|
286
|
+
| Helper | Returns |
|
|
287
|
+
|---|---|
|
|
288
|
+
| `createIdle<T>()` | `{ type: "IDLE" }` |
|
|
289
|
+
| `createPending<T>()` | `{ type: "PENDING" }` |
|
|
290
|
+
| `createFulfilled<T>(value)` | `{ type: "FULFILLED", value }` |
|
|
291
|
+
| `createRejected<T>(error)` | `{ type: "REJECTED", error }` |
|
|
292
|
+
|
|
293
|
+
---
|
|
294
|
+
|
|
295
|
+
## See also
|
|
296
|
+
|
|
297
|
+
- [rxfy-react](../rxfy-react/README.md): React bindings
|
|
298
|
+
- [examples/vite-todo](../../examples/vite-todo): full working example
|