Skip to content

Commit 620ce30

Browse files
committed
feat: add version shorthand + createCrudTransitions helper
1 parent 6731a54 commit 620ce30

11 files changed

Lines changed: 230 additions & 42 deletions

File tree

ARCHITECTURE.md

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -329,9 +329,26 @@ createTransitions('todos::add')({
329329
});
330330
```
331331

332+
### `createCrudTransitions<T>(namespace, key)` / `createCrudTransitions<T>()(namespace, keys)`
333+
334+
High-level helper that composes `crudPrepare` + `createTransitions` with golden-path modes:
335+
336+
```typescript
337+
// Single-key
338+
const todo = createCrudTransitions<Todo>('todos', 'id');
339+
// todo.create → DISPOSABLE (drop on fail)
340+
// todo.update → DEFAULT (flag on fail)
341+
// todo.remove → REVERTIBLE (stash on fail)
342+
343+
// Multi-key — curried for key inference
344+
const item = createCrudTransitions<ProjectTodo>()('projects', ['projectId', 'id']);
345+
```
346+
347+
Each operation returns a full transition set (`.stage`, `.amend`, `.commit`, `.fail`, `.stash`, `.match`).
348+
332349
### `crudPrepare<T>(key)` / `crudPrepare<T>()(keys)`
333350

334-
Factory for CRUD prepare functions that couple `transitionId === entityId`:
351+
Factory for CRUD prepare functions that couple `transitionId === entityId`. Use when you need custom modes or per-operation preparators:
335352

336353
```typescript
337354
// Single-key (recordState, listState)

README.md

Lines changed: 21 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -62,46 +62,43 @@ npm install @lostsolution/optimistron
6262

6363
```typescript
6464
import { configureStore, createSelector } from '@reduxjs/toolkit';
65-
import { optimistron, createTransitions, crudPrepare, recordState, TransitionMode } from '@lostsolution/optimistron';
65+
import { optimistron, createCrudTransitions, recordState } from '@lostsolution/optimistron';
6666

6767
// 1. Define your entity
6868
type Todo = { id: string; value: string; done: boolean; revision: number };
6969

70-
// 2. Create CRUD prepare functions (couples transitionId === entityId)
71-
const crud = crudPrepare<Todo>('id');
72-
73-
// 3. Create transition action creators
74-
const createTodo = createTransitions('todos::add', TransitionMode.DISPOSABLE)(crud.create);
75-
const editTodo = createTransitions('todos::edit')(crud.update); // DEFAULT mode
76-
const deleteTodo = createTransitions('todos::delete', TransitionMode.REVERTIBLE)(crud.remove);
70+
// 2. Create CRUD transition actions (golden-path modes built in)
71+
const todo = createCrudTransitions<Todo>('todos', 'id');
7772

78-
// 4. Create the optimistic reducer
73+
// 3. Create the optimistic reducer
7974
const { reducer: todos, selectors } = optimistron(
8075
'todos',
8176
{} as Record<string, Todo>,
8277
recordState<Todo>({
8378
key: 'id',
84-
compare: (a, b) => (a.revision === b.revision ? 0 : a.revision > b.revision ? 1 : -1),
79+
version: (t) => t.revision,
8580
eq: (a, b) => a.done === b.done && a.value === b.value,
8681
}),
87-
{ create: createTodo, update: editTodo, remove: deleteTodo },
82+
{ create: todo.create, update: todo.update, remove: todo.remove },
8883
);
8984

90-
// 5. Wire up the store
85+
// 4. Wire up the store
9186
const store = configureStore({ reducer: { todos } });
9287

93-
// 6. Select optimistic state (memoize with createSelector)
88+
// 5. Select optimistic state (memoize with createSelector)
9489
const selectTodos = createSelector(
9590
(state: RootState) => state.todos,
9691
selectors.selectOptimistic((todos) => Object.values(todos.committed)),
9792
);
9893

99-
// 7. Dispatch transitions
100-
dispatch(createTodo.stage(todo)); // optimistic — shows immediately
101-
dispatch(createTodo.commit(todo.id)); // server confirmed — becomes committed state
102-
dispatch(createTodo.fail(todo.id, error)); // server rejected — flagged as failed
94+
// 6. Dispatch transitions
95+
dispatch(todo.create.stage(item)); // optimistic — shows immediately
96+
dispatch(todo.create.commit(item.id)); // server confirmed — becomes committed state
97+
dispatch(todo.create.fail(item.id, error)); // server rejected — dropped (DISPOSABLE)
10398
```
10499

100+
> `createCrudTransitions` composes `crudPrepare` + `createTransitions` with golden-path modes: **DISPOSABLE** create, **DEFAULT** update, **REVERTIBLE** remove. For custom modes or per-operation preparators, use `crudPrepare` + `createTransitions` directly.
101+
105102
---
106103

107104
## Three Rules
@@ -117,8 +114,13 @@ dispatch(createTodo.fail(todo.id, error)); // server rejected — flagged as fai
117114
Entities need a **monotonically increasing version**`revision`, `updatedAt`, a sequence number. This is how sanitization tells "newer" from "stale":
118115

119116
```typescript
120-
compare: (a, b) => 0 | 1 | -1; // version ordering
121-
eq: (a, b) => boolean; // content equality at same version
117+
// Shorthand — extracts version, compare is generated automatically
118+
version: (item) => item.revision,
119+
eq: (a, b) => boolean,
120+
121+
// Full control — provide your own compare
122+
compare: (a, b) => 0 | 1 | -1,
123+
eq: (a, b) => boolean,
122124
```
123125

124126
Without versioning, conflict detection degrades to content equality only.
@@ -227,8 +229,6 @@ All selectors are returned from `optimistron()` on the `selectors` object — th
227229
### Optimistic state
228230

229231
```typescript
230-
const { selectors } = optimistron('todos', initial, handler, config);
231-
232232
const selectTodos = createSelector(
233233
(state: RootState) => state.todos,
234234
selectors.selectOptimistic((todos) => Object.values(todos.committed)),
@@ -238,7 +238,6 @@ const selectTodos = createSelector(
238238
### Per-entity status
239239

240240
```typescript
241-
const { selectors } = optimistron('todos', initial, handler, config);
242241

243242
selectors.selectIsOptimistic(id)(state.todos); // pending?
244243
selectors.selectIsFailed(id)(state.todos); // failed?

src/actions/crud-transitions.ts

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import { TransitionMode } from '~/transitions';
2+
import { crudPrepare } from './crud';
3+
import { createTransitions } from './transitions';
4+
5+
/**
6+
* High-level helper that composes `crudPrepare` + `createTransitions`
7+
* with golden-path transition modes:
8+
* - **create** → `DISPOSABLE` (drop on fail — entity never existed server-side)
9+
* - **update** → `DEFAULT` (flag on fail — consumer decides)
10+
* - **remove** → `REVERTIBLE` (stash on fail — undo via trailing reversion)
11+
*
12+
* Single-key:
13+
* ```ts
14+
* const todo = createCrudTransitions<Todo>('todos', 'id');
15+
* todo.create.stage(item);
16+
* todo.update.stage({ id, value: 'new' });
17+
* todo.remove.stage({ id });
18+
* ```
19+
*
20+
* Multi-key (curried for Keys inference):
21+
* ```ts
22+
* const item = createCrudTransitions<Item>()('items', ['groupId', 'itemId']);
23+
* ```
24+
*/
25+
export function createCrudTransitions<T extends Record<string, any>>(): <
26+
const Keys extends readonly [keyof T & string, ...(keyof T & string)[]],
27+
>(
28+
namespace: string,
29+
keys: Keys,
30+
) => {
31+
create: ReturnType<typeof createTransitions>;
32+
update: ReturnType<typeof createTransitions>;
33+
remove: ReturnType<typeof createTransitions>;
34+
};
35+
36+
export function createCrudTransitions<T extends Record<string, any>>(
37+
namespace: string,
38+
key: keyof T & string,
39+
): {
40+
create: ReturnType<typeof createTransitions>;
41+
update: ReturnType<typeof createTransitions>;
42+
remove: ReturnType<typeof createTransitions>;
43+
};
44+
45+
export function createCrudTransitions<T extends Record<string, any>>(namespace?: string, key?: keyof T & string): any {
46+
const build = (ns: string, crud: ReturnType<typeof crudPrepare<T>>) => ({
47+
create: createTransitions(`${ns}::create`, TransitionMode.DISPOSABLE)(crud.create),
48+
update: createTransitions(`${ns}::update`, TransitionMode.DEFAULT)(crud.update),
49+
remove: createTransitions(`${ns}::remove`, TransitionMode.REVERTIBLE)(crud.remove),
50+
});
51+
52+
if (namespace !== undefined && key !== undefined) {
53+
return build(namespace, crudPrepare<T>(key));
54+
}
55+
56+
return <const Keys extends readonly [keyof T & string, ...(keyof T & string)[]]>(ns: string, keys: Keys) =>
57+
build(ns, crudPrepare<T>()(keys) as any);
58+
}

src/actions/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
export { createCommitMatcher, createTransition, createTransitions, resolveTransition } from './transitions';
22
export { crudPrepare } from './crud';
3+
export { createCrudTransitions } from './crud-transitions';
34
export type {
45
ActionMeta,
56
EmptyPayload,

src/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
export { createTransition, createTransitions, crudPrepare } from './actions';
1+
export { createCrudTransitions, createTransition, createTransitions, crudPrepare } from './actions';
22
export { optimistron } from './optimistron';
33
export { listState } from './state/list';
44
export { nestedRecordState, recordState } from './state/record';

src/state/list.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { OptimisticMergeResult } from '~/transitions';
22
import type { StringKeys } from '~/utils/types';
3-
import type { CrudActionMap, VersioningOptions, WiredStateHandler } from '~state/types';
3+
import { resolveCompare, type CrudActionMap, type VersioningOptions, type WiredStateHandler } from '~state/types';
44

55
export type ListStateOptions<T> = VersioningOptions<T> & { key: StringKeys<T> };
66

@@ -16,7 +16,8 @@ export type ListStateOptions<T> = VersioningOptions<T> & { key: StringKeys<T> };
1616
export const listState = <T extends Record<string, any>>(
1717
options: ListStateOptions<T>,
1818
): WiredStateHandler<T[], T, Partial<T>, Partial<T>, CrudActionMap<T, Partial<T>, Partial<T>>> => {
19-
const { key, compare, eq } = options;
19+
const { key, eq } = options;
20+
const compare = resolveCompare(options);
2021

2122
return {
2223
create: (state: T[], item: T) => {

src/state/record.ts

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { OptimisticMergeResult } from '~/transitions';
33
import type { Obj } from '~/utils/path';
44
import { getAt, removeAt, setAt } from '~/utils/path';
55
import type { Maybe, StringKeys } from '~/utils/types';
6-
import type { CrudActionMap, VersioningOptions, WiredStateHandler } from '~state/types';
6+
import { resolveCompare, type CrudActionMap, type VersioningOptions, type WiredStateHandler } from '~state/types';
77

88
export type RecordStateOptions<T> = VersioningOptions<T> & { key: StringKeys<T> };
99
export type NestedRecordStateOptions<T, Keys extends readonly StringKeys<T>[]> = VersioningOptions<T> & { keys: Keys };
@@ -45,7 +45,8 @@ export const nestedRecordState =
4545
> => {
4646
type State = RecursiveRecordState<Keys, T>;
4747

48-
const { keys, compare, eq } = options;
48+
const { keys, eq } = options;
49+
const compare = resolveCompare(options);
4950

5051
/** Extracts path IDs from a DTO using the keys tuple */
5152
const extractPath = (dto: Record<string, any>): string[] => keys.map((k) => String(dto[k]));
@@ -135,11 +136,11 @@ export const nestedRecordState =
135136
* This is a depth-1 specialization of `nestedRecordState`.
136137
* Handler types use `Partial<T>` for update/remove DTOs — narrower types
137138
* are enforced at dispatch time via `crudPrepare`. */
138-
export const recordState = <T extends Record<string, any>>({
139-
key,
140-
compare,
141-
eq,
142-
}: RecordStateOptions<T>): WiredStateHandler<RecordState<T>, T, Partial<T>, Partial<T>, CrudActionMap<T, Partial<T>, Partial<T>>> => {
139+
export const recordState = <T extends Record<string, any>>(
140+
options: RecordStateOptions<T>,
141+
): WiredStateHandler<RecordState<T>, T, Partial<T>, Partial<T>, CrudActionMap<T, Partial<T>, Partial<T>>> => {
142+
const { key, eq } = options;
143+
const compare = resolveCompare(options);
143144
const nested = nestedRecordState<T>()({ keys: [key], compare, eq });
144145

145146
return {

src/state/singular.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { OptimisticMergeResult } from '~/transitions';
22
import type { MaybeNull } from '~/utils/types';
3-
import type { CrudActionMap, VersioningOptions, WiredStateHandler } from './types';
3+
import { resolveCompare, type CrudActionMap, type VersioningOptions, type WiredStateHandler } from './types';
44

55
export type SingularStateOptions<T> = VersioningOptions<T>;
66

@@ -12,7 +12,8 @@ export type SingularStateOptions<T> = VersioningOptions<T>;
1212
export const singularState = <T extends object>(
1313
options: SingularStateOptions<T>,
1414
): WiredStateHandler<MaybeNull<T>, T, Partial<T>, void, CrudActionMap<T, Partial<T>, void>> => {
15-
const { compare, eq } = options;
15+
const { eq } = options;
16+
const compare = resolveCompare(options);
1617

1718
return {
1819
create: (_: MaybeNull<T>, item: T) => item,

src/state/types.ts

Lines changed: 26 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,15 +7,35 @@ export type TransitionState<T> = {
77
};
88

99
export type VersioningOptions<T> = {
10-
/** Given two items returns a sorting result.
11-
* This allows checking for valid updates or conflicts.
12-
* Return -1 if `a` is "smaller" than `b`
13-
* Return 0 if `a` equals `b`
14-
* Return 1 if `b` is "greater" than `a` */
15-
compare: (a: T, b: T) => 0 | 1 | -1;
1610
/** Equality checker - it can potentially be different
1711
* than comparing. */
1812
eq: (a: T, b: T) => boolean;
13+
} & (
14+
| {
15+
/** Given two items returns a sorting result.
16+
* Return -1 if `a` is "smaller" than `b`
17+
* Return 0 if `a` equals `b`
18+
* Return 1 if `b` is "greater" than `a` */
19+
compare: (a: T, b: T) => 0 | 1 | -1;
20+
}
21+
| {
22+
/** Shorthand — extracts a comparable version from an item.
23+
* Generates `compare` automatically via `>` / `===`. */
24+
version: (item: T) => number;
25+
}
26+
);
27+
28+
/** Resolves a `VersioningOptions` into a concrete `compare` function.
29+
* If `version` shorthand is provided, generates `compare` from it. */
30+
export const resolveCompare = <T>(options: VersioningOptions<T>): ((a: T, b: T) => 0 | 1 | -1) => {
31+
if ('compare' in options) return options.compare;
32+
const { version } = options;
33+
return (a, b) => {
34+
const va = version(a);
35+
const vb = version(b);
36+
if (va === vb) return 0;
37+
return va > vb ? 1 : -1;
38+
};
1939
};
2040

2141
/** Type-narrowing action matcher — `.match()` narrows the action's payload.

test/unit/actions.spec.ts

Lines changed: 66 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { describe, expect, test } from 'bun:test';
22

3-
import { createTransitions, crudPrepare, resolveTransition } from '~actions';
3+
import { createCrudTransitions, createTransitions, crudPrepare, resolveTransition } from '~actions';
44
import { META_KEY } from '~constants';
55
import { TransitionMode, Operation } from '~transitions';
66

@@ -251,3 +251,68 @@ describe('crudPrepare', () => {
251251
});
252252
});
253253
});
254+
255+
describe('createCrudTransitions', () => {
256+
type Item = { id: string; name: string; revision: number };
257+
258+
describe('single-key', () => {
259+
const crud = createCrudTransitions<Item>('items', 'id');
260+
261+
test('create uses DISPOSABLE mode', () => {
262+
const item: Item = { id: 'i1', name: 'test', revision: 0 };
263+
const result = crud.create.stage(item);
264+
265+
expect(result.payload).toEqual(item);
266+
expect(result.meta[META_KEY].id).toBe('i1');
267+
expect(result.meta[META_KEY].mode).toBe(TransitionMode.DISPOSABLE);
268+
});
269+
270+
test('update uses DEFAULT mode', () => {
271+
const result = crud.update.stage({ id: 'i1', name: 'updated' });
272+
273+
expect(result.meta[META_KEY].id).toBe('i1');
274+
expect(result.meta[META_KEY].mode).toBe(TransitionMode.DEFAULT);
275+
});
276+
277+
test('remove uses REVERTIBLE mode', () => {
278+
const result = crud.remove.stage({ id: 'i1' });
279+
280+
expect(result.meta[META_KEY].id).toBe('i1');
281+
expect(result.meta[META_KEY].mode).toBe(TransitionMode.REVERTIBLE);
282+
});
283+
284+
test('each operation has commit/fail/stash', () => {
285+
expect(crud.create.commit('i1').meta[META_KEY].operation).toBe(Operation.COMMIT);
286+
expect(crud.update.fail('i1', new Error()).meta[META_KEY].operation).toBe(Operation.FAIL);
287+
expect(crud.remove.stash('i1').meta[META_KEY].operation).toBe(Operation.STASH);
288+
});
289+
});
290+
291+
describe('multi-key', () => {
292+
type Nested = { groupId: string; itemId: string; value: string };
293+
const crud = createCrudTransitions<Nested>()('nested', ['groupId', 'itemId']);
294+
295+
test('create derives transitionId from keys', () => {
296+
const item: Nested = { groupId: 'g1', itemId: 'i1', value: 'test' };
297+
const result = crud.create.stage(item);
298+
299+
expect(result.payload).toEqual(item);
300+
expect(result.meta[META_KEY].id).toBe('g1/i1');
301+
expect(result.meta[META_KEY].mode).toBe(TransitionMode.DISPOSABLE);
302+
});
303+
304+
test('update uses DEFAULT mode with joined key', () => {
305+
const result = crud.update.stage({ groupId: 'g1', itemId: 'i1', value: 'updated' });
306+
307+
expect(result.meta[META_KEY].id).toBe('g1/i1');
308+
expect(result.meta[META_KEY].mode).toBe(TransitionMode.DEFAULT);
309+
});
310+
311+
test('remove uses REVERTIBLE mode with joined key', () => {
312+
const result = crud.remove.stage({ groupId: 'g1', itemId: 'i1' });
313+
314+
expect(result.meta[META_KEY].id).toBe('g1/i1');
315+
expect(result.meta[META_KEY].mode).toBe(TransitionMode.REVERTIBLE);
316+
});
317+
});
318+
});

0 commit comments

Comments
 (0)