Skip to content

Commit 83f3425

Browse files
authored
Merge pull request evoluhq#644 from evoluhq/refactoring
Refactoring
2 parents a3294cb + 5f5a867 commit 83f3425

33 files changed

Lines changed: 2212 additions & 954 deletions

.changeset/beige-sloths-lead.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
"@evolu/common": minor
3+
---
4+
5+
Added `getProperty` helper function
6+
7+
Safely gets a property from a record, returning `undefined` if the key doesn't exist. TypeScript's `Record<K, V>` type assumes all keys exist, but at runtime accessing a non-existent key returns `undefined`. This helper provides proper typing for that case without needing a type assertion.
8+
9+
```ts
10+
const users: Record<string, User> = { alice: { name: "Alice" } };
11+
const user = getProperty(users, "bob"); // User | undefined
12+
```

.changeset/honest-cars-draw.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
---
2+
"@evolu/common": minor
3+
---
4+
5+
Added `set` Type factory
6+
7+
The `set` factory creates a Type for validating `Set` instances with typed elements. It validates that the input is a `Set` and that all elements conform to the specified element type.
8+
9+
```ts
10+
const NumberSet = set(Number);
11+
12+
const result1 = NumberSet.from(new Set([1, 2, 3])); // ok(Set { 1, 2, 3 })
13+
const result2 = NumberSet.from(new Set(["a", "b"])); // err(...)
14+
```

.changeset/humble-meals-wish.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
"@evolu/common": minor
3+
---
4+
5+
Added `readonly` helper function
6+
7+
The `readonly` function casts arrays, sets, records, and maps to their readonly counterparts with zero runtime cost. It preserves `NonEmptyArray` as `NonEmptyReadonlyArray` and provides proper type inference for all supported collection types.

.changeset/silver-jars-smell.md

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
---
2+
"@evolu/common": patch
3+
---
4+
5+
Update Result documentation with block scope pattern for multiple void operations
6+
7+
```ts
8+
// Before - inventing names to avoid name clash
9+
const baseTables = createBaseSqliteStorageTables(deps);
10+
if (!baseTables.ok) return baseTables;
11+
12+
const relayTables = createRelayStorageTables(deps);
13+
if (!relayTables.ok) return relayTables;
14+
15+
// After - block scopes avoid name clash
16+
{
17+
const result = createBaseSqliteStorageTables(deps);
18+
if (!result.ok) return result;
19+
}
20+
{
21+
const result = createRelayStorageTables(deps);
22+
if (!result.ok) return result;
23+
}
24+
```

.changeset/three-buses-play.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
"@evolu/common": patch
3+
---
4+
5+
Fix forward compatibility by quarantining messages with unknown schema
6+
7+
Messages with unknown tables or columns are now stored in `evolu_message_quarantine` table instead of being discarded. This fixes an issue where apps had to be updated to receive messages from newer versions. The quarantine table is queryable via `createQuery` and quarantined messages are automatically applied when the schema is updated.

.github/copilot-instructions.md

Lines changed: 21 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,24 @@ export const createUser = (data: UserData): User => {
7272
};
7373
````
7474

75+
## API stability & experimental APIs
76+
77+
- **Use `@experimental` tag** for new APIs that may change or be removed
78+
- **Experimental APIs can change** in minor/patch versions without breaking semver
79+
- **Promote to stable** once confident in the design after real-world usage
80+
81+
```ts
82+
// ✅ Good - Mark new/uncertain APIs as experimental
83+
/**
84+
* Casts a value to its readonly counterpart.
85+
*
86+
* @experimental
87+
*/
88+
export const readonly = <T>(value: T): Readonly<T> => value;
89+
```
90+
91+
This pattern allows iterating on API design without committing to stability too early.
92+
7593
## Error handling with Result
7694

7795
- Use `Result<T, E>` for business/domain errors in public APIs
@@ -94,13 +112,10 @@ const parseJson = (value: string): Result<unknown, ParseJsonError> =>
94112

95113
// ✅ Good - Sequential operations with short-circuiting
96114
const processData = (deps: DataDeps) => {
97-
const step1Result = doStep1(deps);
98-
if (!step1Result.ok) return step1Result;
99-
100-
const step2Result = doStep2(deps)(step1Result.value);
101-
if (!step2Result.ok) return step2Result;
115+
const foo = doFoo(deps);
116+
if (!foo.ok) return foo;
102117

103-
return ok(step2Result.value);
118+
return doStep2(deps)(foo.value);
104119
};
105120

106121
// ❌ Avoid - Implementation error in public API

apps/web/src/app/(docs)/docs/conventions/page.mdx

Lines changed: 47 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -66,9 +66,16 @@ const bar = () => {
6666

6767
## Immutability
6868

69-
Mutable state can be tricky because it increases the risk of unintended side effects, makes code harder to predict, and complicates debugging—especially in complex applications where data might be shared or modified unexpectedly.
69+
Mutable state is tricky because it increases the risk of unintended side effects, makes code harder to predict, and complicates debugging—especially in complex applications where data might be shared or modified unexpectedly. Favor immutable values using readonly types to reduce these risks and improve clarity.
7070

71-
Favor immutable values using TypeScript's type system to reduce these risks and improve clarity. Use `ReadonlyArray` and `NonEmptyReadonlyArray` for arrays and prefix interface properties with readonly to enforce immutability at the type level.
71+
### Readonly types
72+
73+
Use readonly types for collections and prefix interface properties with `readonly`:
74+
75+
- `ReadonlyArray<T>` and `NonEmptyReadonlyArray<T>` for arrays
76+
- `ReadonlySet<T>` for sets
77+
- `ReadonlyRecord<K, V>` for records
78+
- `ReadonlyMap<K, V>` for maps
7279

7380
```ts
7481
// Use ReadonlyArray for immutable arrays.
@@ -78,9 +85,47 @@ const values: ReadonlyArray<string> = ["a", "b", "c"];
7885
interface Example {
7986
readonly id: number;
8087
readonly items: ReadonlyArray<string>;
88+
readonly tags: ReadonlySet<string>;
8189
}
8290
```
8391

92+
### The `readonly` helper
93+
94+
Use the [readonly](/docs/api-reference/common/Function/functions/readonly) helper to cast arrays, sets, records, and maps to their readonly counterparts with zero runtime cost.
95+
96+
```ts
97+
import { readonly, NonEmptyArray } from "@evolu/common";
98+
99+
// Array literals become NonEmptyReadonlyArray
100+
const items = readonly([1, 2, 3]);
101+
// Type: NonEmptyReadonlyArray<number>
102+
103+
// NonEmptyArray is preserved as NonEmptyReadonlyArray
104+
const nonEmpty: NonEmptyArray<number> = [1, 2, 3];
105+
const readonlyNonEmpty = readonly(nonEmpty);
106+
// Type: NonEmptyReadonlyArray<number>
107+
108+
// Regular arrays become ReadonlyArray
109+
const arr: Array<number> = getNumbers();
110+
const readonlyArr = readonly(arr);
111+
// Type: ReadonlyArray<number>
112+
113+
// Sets, Records, and Maps
114+
const ids = readonly(new Set(["a", "b"]));
115+
// Type: ReadonlySet<string>
116+
117+
const users: Record<UserId, string> = { ... };
118+
const readonlyUsers = readonly(users);
119+
// Type: ReadonlyRecord<UserId, string>
120+
121+
const lookup = readonly(new Map([["key", "value"]]));
122+
// Type: ReadonlyMap<string, string>
123+
```
124+
125+
### Immutable helpers
126+
127+
Evolu provides helpers in the [Array](/docs/api-reference/common/Array) and [Object](/docs/api-reference/common/Object) modules that do not mutate and preserve readonly types.
128+
84129
## Interface over type
85130

86131
Prefer `interface` over `type` because interfaces always appear by name in error messages and tooltips.

eslint.config.mjs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,10 @@ export default defineConfig(
4646
"@typescript-eslint/explicit-module-boundary-types": "error",
4747
// https://github.com/typescript-eslint/typescript-eslint/issues/8113#issuecomment-2334943836
4848
"@typescript-eslint/no-invalid-void-type": "off",
49+
50+
// It seems its buggy, disable it for now.
51+
"@typescript-eslint/no-redundant-type-constituents": "off",
52+
4953
"@typescript-eslint/no-empty-object-type": "off",
5054
"@typescript-eslint/restrict-template-expressions": "off",
5155
"@typescript-eslint/no-unused-vars": [

packages/common/src/Function.ts

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
1+
import { NonEmptyArray, NonEmptyReadonlyArray } from "./Array.js";
2+
import { ReadonlyRecord } from "./Object.js";
3+
14
/**
25
* Helper function to ensure exhaustive matching in a switch statement. Throws
36
* an error if an unhandled case is encountered.
@@ -31,8 +34,79 @@ export const exhaustiveCheck = (value: never): never => {
3134
throw new Error(`exhaustiveCheck unhandled case: ${JSON.stringify(value)}`);
3235
};
3336

37+
/**
38+
* Returns the input value unchanged.
39+
*
40+
* Useful as a default transformation, placeholder callback, or when a function
41+
* is required but no transformation is needed.
42+
*
43+
* ### Example
44+
*
45+
* ```ts
46+
* const values = [1, 2, 3];
47+
* const same = values.map(identity); // [1, 2, 3]
48+
*
49+
* const getTransform = (shouldDouble: boolean) =>
50+
* shouldDouble ? (x: number) => x * 2 : identity;
51+
* ```
52+
*/
3453
export const identity = <A>(a: A): A => a;
3554

55+
/**
56+
* Casts an array, set, record, or map to its readonly counterpart.
57+
*
58+
* Zero runtime cost — returns the same value with a readonly type. Use this to
59+
* enforce immutability at the type level. Preserves {@link NonEmptyArray} as
60+
* {@link NonEmptyReadonlyArray}.
61+
*
62+
* ### Example
63+
*
64+
* ```ts
65+
* // Array literals become NonEmptyReadonlyArray
66+
* const items = readonly([1, 2, 3]);
67+
* // Type: NonEmptyReadonlyArray<number>
68+
*
69+
* // NonEmptyArray is preserved as NonEmptyReadonlyArray
70+
* const nonEmpty: NonEmptyArray<number> = [1, 2, 3];
71+
* const readonlyNonEmpty = readonly(nonEmpty);
72+
* // Type: NonEmptyReadonlyArray<number>
73+
*
74+
* // Regular arrays become ReadonlyArray
75+
* const arr: Array<number> = getNumbers();
76+
* const readonlyArr = readonly(arr);
77+
* // Type: ReadonlyArray<number>
78+
*
79+
* // Sets, Records, and Maps
80+
* const ids = readonly(new Set(["a", "b"]));
81+
* // Type: ReadonlySet<string>
82+
*
83+
* const users: Record<UserId, string> = { ... };
84+
* const readonlyUsers = readonly(users);
85+
* // Type: ReadonlyRecord<UserId, string>
86+
*
87+
* const lookup = readonly(new Map([["key", "value"]]));
88+
* // Type: ReadonlyMap<string, string>
89+
* ```
90+
*
91+
* @experimental
92+
*/
93+
export function readonly<T>(array: NonEmptyArray<T>): NonEmptyReadonlyArray<T>;
94+
export function readonly<T>(array: Array<T>): ReadonlyArray<T>;
95+
export function readonly<T>(set: Set<T>): ReadonlySet<T>;
96+
export function readonly<K, V>(map: Map<K, V>): ReadonlyMap<K, V>;
97+
export function readonly<K extends keyof any, V>(
98+
record: Record<K, V>,
99+
): ReadonlyRecord<K, V>;
100+
export function readonly<T, K extends keyof any, V>(
101+
value: Array<T> | Set<T> | Map<K, V> | Record<K, V>,
102+
):
103+
| ReadonlyArray<T>
104+
| ReadonlySet<T>
105+
| ReadonlyMap<K, V>
106+
| ReadonlyRecord<K, V> {
107+
return value;
108+
}
109+
36110
/**
37111
* A function that delays computation and returns a value of type T.
38112
*

packages/common/src/Object.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,3 +89,23 @@ export const createRecord = <K extends string = string, V = unknown>(): Record<
8989
K,
9090
V
9191
> => Object.create(null) as Record<K, V>;
92+
93+
/**
94+
* Safely gets a property from a record, returning `undefined` if the key
95+
* doesn't exist.
96+
*
97+
* TypeScript's `Record<K, V>` type assumes all keys exist, but at runtime
98+
* accessing a non-existent key returns `undefined`. This helper provides proper
99+
* typing for that case without needing a type assertion.
100+
*
101+
* ### Example
102+
*
103+
* ```ts
104+
* const users: Record<string, User> = { alice: { name: "Alice" } };
105+
* const user = getProperty(users, "bob"); // User | undefined
106+
* ```
107+
*/
108+
export const getProperty = <K extends string, V>(
109+
record: ReadonlyRecord<K, V>,
110+
key: string,
111+
): V | undefined => (key in record ? record[key as K] : undefined);

0 commit comments

Comments
 (0)