Skip to content

Commit 61a1d5f

Browse files
authored
fix: implement RelationError, improve unique relations validation (#323)
1 parent aa07bd5 commit 61a1d5f

8 files changed

Lines changed: 471 additions & 110 deletions

File tree

README.md

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -426,6 +426,34 @@ videos.defineRelations(({ one }) => ({
426426

427427
> In this example, `post.attachments` is an array of either `images` or `videos`, where records from both collections are allowed.
428428
429+
## Error handling
430+
431+
Data provides multiple different error classes to help you differentiate and handle different errors.
432+
433+
### `OperationError`
434+
435+
- `code` `<OperationErrorCode>`, the error code describing the failed operation;
436+
- `cause` `<Error>` (_optional_), a reference to the original thrown error.
437+
438+
Thrown whenever performing a record operation fails. For example:
439+
440+
- When creating a new record whose initial values do not match the collection's schema;
441+
- When there are no records found for a strict query.
442+
443+
### `RelationError`
444+
445+
- `code` `<RelationErrorCode>`, the error code describing the relation operation;
446+
- `info` `<object>`, additional error information;
447+
- `path` `<PropertyPath>`, path of the relational property;
448+
- `ownerCollection` `<Collection>`, a reference to the owner collection;
449+
- `foreignCollection` `<Array<Collection>>`, an array of foreign collections referenced by this relation;
450+
- `options` `RelationDefinitionOptions`, the options object passed upon decaring this relation.
451+
452+
Thrown whenever performing a relation operation fails. For example:
453+
454+
- When attempting to reference a foreign record that's already associated with another record in a unique relation;
455+
- When directly assigning value to a relational property.
456+
429457
---
430458

431459
## API

src/collection.ts

Lines changed: 14 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ import {
1919
} from '#/src/utils.js'
2020
import { type SortOptions, sortResults } from '#/src/sort.js'
2121
import type { Extension } from '#/src/extensions/index.js'
22-
import { OperationError, StrictOperationError } from '#/src/errors.js'
22+
import { OperationError, OperationErrorCodes } from '#/src/errors.js'
2323
import { TypedEvent, type Emitter } from 'rettime'
2424

2525
let collectionsCreated = 0
@@ -131,16 +131,17 @@ export class Collection<Schema extends StandardSchemaV1> {
131131

132132
if (validationResult.issues) {
133133
console.error(validationResult.issues)
134-
throw new InvariantError(
135-
'Failed to create a new record with initial values (%j): does not match the schema',
136-
initialValues,
134+
135+
throw new OperationError(
136+
'Failed to create a new record with initial values: does not match the schema. Please see the schema validation errors above.',
137+
OperationErrorCodes.INVALID_INITIAL_VALUES,
137138
)
138139
}
139140

140141
let record = validationResult.value as RecordType
141142

142143
invariant.as(
143-
OperationError.for('create', { initialValues }),
144+
OperationError.for(OperationErrorCodes.INVALID_INITIAL_VALUES),
144145
typeof record === 'object',
145146
'Failed to create a record with initial values (%j): expected the record to be an object or an array',
146147
initialValues,
@@ -207,8 +208,7 @@ export class Collection<Schema extends StandardSchemaV1> {
207208
return await Promise.all(pendingPromises).catch((error) => {
208209
throw new OperationError(
209210
'Failed to execute "createMany" on collection: unexpected error',
210-
'createMany',
211-
{ count, initialValuesFactory },
211+
OperationErrorCodes.UNEXPECTED_ERROR,
212212
error,
213213
)
214214
})
@@ -232,7 +232,7 @@ export class Collection<Schema extends StandardSchemaV1> {
232232
const firstRecord = this.#records[0]
233233

234234
invariant.as(
235-
StrictOperationError.for('findFirst', { predicate, options }),
235+
OperationError.for(OperationErrorCodes.STRICT_QUERY_WITHOUT_RESULTS),
236236
options?.strict ? firstRecord != null : true,
237237
'Failed to execute "findFirst" on collection without a query: the collection is empty',
238238
)
@@ -245,7 +245,7 @@ export class Collection<Schema extends StandardSchemaV1> {
245245
).next().value
246246

247247
invariant.as(
248-
StrictOperationError.for('findFirst', { predicate, options }),
248+
OperationError.for(OperationErrorCodes.STRICT_QUERY_WITHOUT_RESULTS),
249249
options?.strict ? result != null : true,
250250
'Failed to execute "findFirst" on collection: no record found matching the query',
251251
)
@@ -277,7 +277,7 @@ export class Collection<Schema extends StandardSchemaV1> {
277277
)
278278

279279
invariant.as(
280-
StrictOperationError.for('findMany', { predicate, options }),
280+
OperationError.for(OperationErrorCodes.STRICT_QUERY_WITHOUT_RESULTS),
281281
options?.strict ? results.length > 0 : true,
282282
'Failed to execute "findMany" on collection: no records found matching the query',
283283
)
@@ -324,7 +324,7 @@ export class Collection<Schema extends StandardSchemaV1> {
324324

325325
if (prevRecord == null) {
326326
invariant.as(
327-
StrictOperationError.for('update', { predicate, options }),
327+
OperationError.for(OperationErrorCodes.STRICT_QUERY_WITHOUT_RESULTS),
328328
!options.strict,
329329
'Failed to execute "update" on collection: no record found matching the query',
330330
)
@@ -363,7 +363,7 @@ export class Collection<Schema extends StandardSchemaV1> {
363363

364364
if (prevRecords.length === 0) {
365365
invariant.as(
366-
StrictOperationError.for('updateMany', { predicate, options }),
366+
OperationError.for(OperationErrorCodes.STRICT_QUERY_WITHOUT_RESULTS),
367367
!options.strict,
368368
'Failed to execute "updateMany" on collection: no records found matching the query',
369369
)
@@ -409,7 +409,7 @@ export class Collection<Schema extends StandardSchemaV1> {
409409

410410
if (record == null) {
411411
invariant.as(
412-
StrictOperationError.for('delete', { predicate, options }),
412+
OperationError.for(OperationErrorCodes.STRICT_QUERY_WITHOUT_RESULTS),
413413
!options?.strict,
414414
'Failed to execute "delete" on collection: no record found matching the query',
415415
)
@@ -445,7 +445,7 @@ export class Collection<Schema extends StandardSchemaV1> {
445445

446446
if (records.length === 0) {
447447
invariant.as(
448-
StrictOperationError.for('deleteMany', { predicate, options }),
448+
OperationError.for(OperationErrorCodes.STRICT_QUERY_WITHOUT_RESULTS),
449449
!options?.strict,
450450
'Failed to execute "deleteMany" on collection: no records found matching the query',
451451
)

src/errors.ts

Lines changed: 42 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -1,64 +1,56 @@
1-
import { InvariantError } from 'outvariant'
2-
import type { Collection } from './collection.js'
1+
import type { Collection } from '#/src/collection.js'
2+
import type { PropertyPath } from '#/src/utils.js'
3+
import type { RelationDeclarationOptions } from '#/src/relation.js'
34

4-
export interface OperationErrorMap {
5-
create: {
6-
initialValues: Parameters<InstanceType<typeof Collection>['create']>[0]
7-
}
8-
createMany: {
9-
count: number
10-
initialValuesFactory: Parameters<
11-
InstanceType<typeof Collection>['createMany']
12-
>[1]
13-
}
14-
findFirst: {
15-
predicate: Parameters<InstanceType<typeof Collection>['findFirst']>[0]
16-
options: Parameters<InstanceType<typeof Collection>['findFirst']>[1]
17-
}
18-
findMany: {
19-
predicate: Parameters<InstanceType<typeof Collection>['findMany']>[0]
20-
options: Parameters<InstanceType<typeof Collection>['findMany']>[1]
21-
}
22-
update: {
23-
predicate: Parameters<InstanceType<typeof Collection>['update']>[0]
24-
options: Parameters<InstanceType<typeof Collection>['update']>[1]
25-
}
26-
updateMany: {
27-
predicate: Parameters<InstanceType<typeof Collection>['updateMany']>[0]
28-
options: Parameters<InstanceType<typeof Collection>['updateMany']>[1]
29-
}
30-
delete: {
31-
predicate: Parameters<InstanceType<typeof Collection>['delete']>[0]
32-
options: Parameters<InstanceType<typeof Collection>['delete']>[1]
33-
}
34-
deleteMany: {
35-
predicate: Parameters<InstanceType<typeof Collection>['deleteMany']>[0]
36-
options: Parameters<InstanceType<typeof Collection>['deleteMany']>[1]
37-
}
5+
export enum OperationErrorCodes {
6+
UNEXPECTED_ERROR = 'UNEXPECTED_ERROR',
7+
INVALID_INITIAL_VALUES = 'INVALID_INITIAL_VALUES',
8+
STRICT_QUERY_WITHOUT_RESULTS = 'STRICT_QUERY_WITHOUT_RESULTS',
389
}
3910

40-
export class OperationError<
41-
OperationName extends keyof OperationErrorMap,
42-
> extends InvariantError {
43-
static for<OperationName extends keyof OperationErrorMap>(
44-
operationName: OperationName,
45-
info: OperationErrorMap[OperationName],
46-
) {
47-
return (message: string) => new OperationError(message, operationName, info)
11+
export class OperationError extends Error {
12+
static for(code: OperationErrorCodes) {
13+
return (message: string) => {
14+
return new OperationError(message, code)
15+
}
4816
}
4917

5018
constructor(
5119
message: string,
52-
public readonly operationName: OperationName,
53-
public readonly info: OperationErrorMap[OperationName],
20+
public readonly code: OperationErrorCodes,
5421
public readonly cause?: unknown,
5522
) {
5623
super(message)
5724
}
5825
}
5926

60-
export class StrictOperationError<
61-
OperationName extends keyof OperationErrorMap,
62-
> extends OperationError<OperationName> {
63-
static for = OperationError.for
27+
export enum RelationErrorCodes {
28+
RELATION_NOT_READY = 'RELATION_NOT_READY',
29+
UNEXPECTED_SET_EXPRESSION = 'UNEXPECTED_SET_EXPRESSION',
30+
INVALID_FOREIGN_RECORD = 'INVALID_FOREIGN_RECORD',
31+
FORBIDDEN_UNIQUE_CREATE = 'FORBIDDEN_UNIQUE_CREATE',
32+
FORBIDDEN_UNIQUE_UPDATE = 'FORBIDDEN_UNIQUE_UPDATE',
33+
}
34+
35+
export interface RelationErrorDetails {
36+
path: PropertyPath
37+
ownerCollection: Collection<any>
38+
foreignCollections: Array<Collection<any>>
39+
options: RelationDeclarationOptions
40+
}
41+
42+
export class RelationError extends Error {
43+
static for(code: RelationErrorCodes, details: RelationErrorDetails) {
44+
return (message: string) => {
45+
return new RelationError(message, code, details)
46+
}
47+
}
48+
49+
constructor(
50+
message: string,
51+
public readonly code: RelationErrorCodes,
52+
public readonly details: RelationErrorDetails,
53+
) {
54+
super(message)
55+
}
6456
}

src/extensions/persist.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,8 @@ import {
88
kRelationMap,
99
type RecordType,
1010
} from '#/src/collection.js'
11-
import { Logger } from '../logger.js'
11+
import { Logger } from '#/src/logger.js'
12+
import type { PropertyPath } from '#/src/utils.js'
1213

1314
const STORAGE_KEY = 'msw/data/storage'
1415
const METADATA_KEY = '__metadata__'
@@ -27,7 +28,7 @@ export interface SerializedRecord {
2728
interface RecordMetadata {
2829
primaryKey: string
2930
relations: Array<{
30-
path: Array<string>
31+
path: PropertyPath
3132
foreignKeys: Array<string>
3233
}>
3334
}

src/index.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
11
export { Collection, type CollectionOptions } from './collection.js'
22
export { Query, type Condition, type PredicateFunction } from './query.js'
3-
export { Relation } from './relation.js'
3+
export { Relation, type RelationDeclarationOptions } from './relation.js'
44
export type { HookEventMap, HookEventListener } from './hooks.js'
5-
export { OperationError, StrictOperationError } from './errors.js'
5+
export {
6+
OperationError,
7+
RelationError,
8+
RelationErrorCodes,
9+
type RelationErrorDetails,
10+
} from './errors.js'

0 commit comments

Comments
 (0)