@iamjulianacosta/mobx-data 1.0.1
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 +366 -0
- package/dist/CacheHandler-BTU_rYkv.js +208 -0
- package/dist/CacheHandler-BTU_rYkv.js.map +1 -0
- package/dist/CacheHandler-CXgY9IJo.cjs +2 -0
- package/dist/CacheHandler-CXgY9IJo.cjs.map +1 -0
- package/dist/EmbeddedRecordsMixin-CBvqNdgC.cjs +2 -0
- package/dist/EmbeddedRecordsMixin-CBvqNdgC.cjs.map +1 -0
- package/dist/EmbeddedRecordsMixin-VoHluHCT.js +261 -0
- package/dist/EmbeddedRecordsMixin-VoHluHCT.js.map +1 -0
- package/dist/JsonApiSerializer-CC5HXp4b.js +194 -0
- package/dist/JsonApiSerializer-CC5HXp4b.js.map +1 -0
- package/dist/JsonApiSerializer-CKB02AgP.cjs +2 -0
- package/dist/JsonApiSerializer-CKB02AgP.cjs.map +1 -0
- package/dist/MemoryAdapter-Bx1e7ndV.js +123 -0
- package/dist/MemoryAdapter-Bx1e7ndV.js.map +1 -0
- package/dist/MemoryAdapter-D1cTyydm.cjs +2 -0
- package/dist/MemoryAdapter-D1cTyydm.cjs.map +1 -0
- package/dist/ODataAdapter-C4IHK4BK.js +157 -0
- package/dist/ODataAdapter-C4IHK4BK.js.map +1 -0
- package/dist/ODataAdapter-DyyF1sdA.cjs +2 -0
- package/dist/ODataAdapter-DyyF1sdA.cjs.map +1 -0
- package/dist/RestAdapter-B4aRvs4m.js +355 -0
- package/dist/RestAdapter-B4aRvs4m.js.map +1 -0
- package/dist/RestAdapter-CJOwTsKK.cjs +2 -0
- package/dist/RestAdapter-CJOwTsKK.cjs.map +1 -0
- package/dist/SchemaService-DZwkFgZu.js +102 -0
- package/dist/SchemaService-DZwkFgZu.js.map +1 -0
- package/dist/SchemaService-Di_yjVzU.cjs +2 -0
- package/dist/SchemaService-Di_yjVzU.cjs.map +1 -0
- package/dist/Serializer-95gi5edy.cjs +2 -0
- package/dist/Serializer-95gi5edy.cjs.map +1 -0
- package/dist/Serializer-FxJbsZ50.js +139 -0
- package/dist/Serializer-FxJbsZ50.js.map +1 -0
- package/dist/Store-BdwMrbDi.cjs +2 -0
- package/dist/Store-BdwMrbDi.cjs.map +1 -0
- package/dist/Store-CZ7Z-Nme.js +912 -0
- package/dist/Store-CZ7Z-Nme.js.map +1 -0
- package/dist/adapter/Adapter.d.ts +146 -0
- package/dist/adapter/Adapter.d.ts.map +1 -0
- package/dist/adapter/MemoryAdapter.d.ts +44 -0
- package/dist/adapter/MemoryAdapter.d.ts.map +1 -0
- package/dist/adapter/RestAdapter.d.ts +57 -0
- package/dist/adapter/RestAdapter.d.ts.map +1 -0
- package/dist/adapter/index.cjs +2 -0
- package/dist/adapter/index.cjs.map +1 -0
- package/dist/adapter/index.d.ts +4 -0
- package/dist/adapter/index.d.ts.map +1 -0
- package/dist/adapter/index.js +8 -0
- package/dist/adapter/index.js.map +1 -0
- package/dist/date-Bj4O2W1F.js +107 -0
- package/dist/date-Bj4O2W1F.js.map +1 -0
- package/dist/date-CRCe-9gf.cjs +2 -0
- package/dist/date-CRCe-9gf.cjs.map +1 -0
- package/dist/decorators-HQ1KnRdh.cjs +2 -0
- package/dist/decorators-HQ1KnRdh.cjs.map +1 -0
- package/dist/decorators-Zr35qr6A.js +50 -0
- package/dist/decorators-Zr35qr6A.js.map +1 -0
- package/dist/index.cjs +2 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +52 -0
- package/dist/index.js.map +1 -0
- package/dist/json-api/JsonApiAdapter.d.ts +38 -0
- package/dist/json-api/JsonApiAdapter.d.ts.map +1 -0
- package/dist/json-api/JsonApiSerializer.d.ts +73 -0
- package/dist/json-api/JsonApiSerializer.d.ts.map +1 -0
- package/dist/json-api/index.cjs +2 -0
- package/dist/json-api/index.cjs.map +1 -0
- package/dist/json-api/index.d.ts +3 -0
- package/dist/json-api/index.d.ts.map +1 -0
- package/dist/json-api/index.js +6 -0
- package/dist/json-api/index.js.map +1 -0
- package/dist/model/Errors.d.ts +46 -0
- package/dist/model/Errors.d.ts.map +1 -0
- package/dist/model/Model.d.ts +226 -0
- package/dist/model/Model.d.ts.map +1 -0
- package/dist/model/Snapshot.d.ts +72 -0
- package/dist/model/Snapshot.d.ts.map +1 -0
- package/dist/model/StateMachine.d.ts +45 -0
- package/dist/model/StateMachine.d.ts.map +1 -0
- package/dist/model/index.cjs +2 -0
- package/dist/model/index.cjs.map +1 -0
- package/dist/model/index.d.ts +6 -0
- package/dist/model/index.d.ts.map +1 -0
- package/dist/model/index.js +11 -0
- package/dist/model/index.js.map +1 -0
- package/dist/model/relationships.d.ts +182 -0
- package/dist/model/relationships.d.ts.map +1 -0
- package/dist/odata/ODataAdapter.d.ts +67 -0
- package/dist/odata/ODataAdapter.d.ts.map +1 -0
- package/dist/odata/index.cjs +2 -0
- package/dist/odata/index.cjs.map +1 -0
- package/dist/odata/index.d.ts +2 -0
- package/dist/odata/index.d.ts.map +1 -0
- package/dist/odata/index.js +5 -0
- package/dist/odata/index.js.map +1 -0
- package/dist/relationships-B55LBaCW.cjs +2 -0
- package/dist/relationships-B55LBaCW.cjs.map +1 -0
- package/dist/relationships-BEXANmWg.js +821 -0
- package/dist/relationships-BEXANmWg.js.map +1 -0
- package/dist/request/CacheHandler.d.ts +50 -0
- package/dist/request/CacheHandler.d.ts.map +1 -0
- package/dist/request/FetchHandler.d.ts +41 -0
- package/dist/request/FetchHandler.d.ts.map +1 -0
- package/dist/request/RequestManager.d.ts +52 -0
- package/dist/request/RequestManager.d.ts.map +1 -0
- package/dist/request/index.cjs +2 -0
- package/dist/request/index.cjs.map +1 -0
- package/dist/request/index.d.ts +5 -0
- package/dist/request/index.d.ts.map +1 -0
- package/dist/request/index.js +7 -0
- package/dist/request/index.js.map +1 -0
- package/dist/request/types.d.ts +111 -0
- package/dist/request/types.d.ts.map +1 -0
- package/dist/schema/SchemaService.d.ts +58 -0
- package/dist/schema/SchemaService.d.ts.map +1 -0
- package/dist/schema/decorators.d.ts +50 -0
- package/dist/schema/decorators.d.ts.map +1 -0
- package/dist/schema/index.cjs +2 -0
- package/dist/schema/index.cjs.map +1 -0
- package/dist/schema/index.d.ts +4 -0
- package/dist/schema/index.d.ts.map +1 -0
- package/dist/schema/index.js +13 -0
- package/dist/schema/index.js.map +1 -0
- package/dist/schema/types.d.ts +61 -0
- package/dist/schema/types.d.ts.map +1 -0
- package/dist/serializer/EmbeddedRecordsMixin.d.ts +80 -0
- package/dist/serializer/EmbeddedRecordsMixin.d.ts.map +1 -0
- package/dist/serializer/JsonSerializer.d.ts +52 -0
- package/dist/serializer/JsonSerializer.d.ts.map +1 -0
- package/dist/serializer/RestSerializer.d.ts +43 -0
- package/dist/serializer/RestSerializer.d.ts.map +1 -0
- package/dist/serializer/Serializer.d.ts +202 -0
- package/dist/serializer/Serializer.d.ts.map +1 -0
- package/dist/serializer/index.cjs +2 -0
- package/dist/serializer/index.cjs.map +1 -0
- package/dist/serializer/index.d.ts +5 -0
- package/dist/serializer/index.d.ts.map +1 -0
- package/dist/serializer/index.js +9 -0
- package/dist/serializer/index.js.map +1 -0
- package/dist/store/IdentityMap.d.ts +53 -0
- package/dist/store/IdentityMap.d.ts.map +1 -0
- package/dist/store/RecordArray.d.ts +114 -0
- package/dist/store/RecordArray.d.ts.map +1 -0
- package/dist/store/Store.d.ts +395 -0
- package/dist/store/Store.d.ts.map +1 -0
- package/dist/store/index.cjs +2 -0
- package/dist/store/index.cjs.map +1 -0
- package/dist/store/index.d.ts +5 -0
- package/dist/store/index.d.ts.map +1 -0
- package/dist/store/index.js +8 -0
- package/dist/store/index.js.map +1 -0
- package/dist/transforms/Transform.d.ts +49 -0
- package/dist/transforms/Transform.d.ts.map +1 -0
- package/dist/transforms/boolean.d.ts +26 -0
- package/dist/transforms/boolean.d.ts.map +1 -0
- package/dist/transforms/date.d.ts +22 -0
- package/dist/transforms/date.d.ts.map +1 -0
- package/dist/transforms/index.cjs +2 -0
- package/dist/transforms/index.cjs.map +1 -0
- package/dist/transforms/index.d.ts +6 -0
- package/dist/transforms/index.d.ts.map +1 -0
- package/dist/transforms/index.js +9 -0
- package/dist/transforms/index.js.map +1 -0
- package/dist/transforms/number.d.ts +17 -0
- package/dist/transforms/number.d.ts.map +1 -0
- package/dist/transforms/string.d.ts +18 -0
- package/dist/transforms/string.d.ts.map +1 -0
- package/dist/types-C9NB2gRj.js +7 -0
- package/dist/types-C9NB2gRj.js.map +1 -0
- package/dist/types-uWOXMPWW.cjs +2 -0
- package/dist/types-uWOXMPWW.cjs.map +1 -0
- package/package.json +140 -0
- package/src/adapter/Adapter.ts +320 -0
- package/src/adapter/MemoryAdapter.ts +216 -0
- package/src/adapter/RestAdapter.ts +248 -0
- package/src/adapter/index.ts +7 -0
- package/src/index.ts +17 -0
- package/src/json-api/JsonApiAdapter.ts +93 -0
- package/src/json-api/JsonApiSerializer.ts +245 -0
- package/src/json-api/index.ts +2 -0
- package/src/model/Errors.ts +100 -0
- package/src/model/Model.ts +683 -0
- package/src/model/Snapshot.ts +162 -0
- package/src/model/StateMachine.ts +149 -0
- package/src/model/index.ts +20 -0
- package/src/model/relationships.ts +484 -0
- package/src/odata/ODataAdapter.ts +245 -0
- package/src/odata/index.ts +1 -0
- package/src/request/CacheHandler.ts +125 -0
- package/src/request/FetchHandler.ts +119 -0
- package/src/request/RequestManager.ts +112 -0
- package/src/request/index.ts +4 -0
- package/src/request/types.ts +139 -0
- package/src/schema/SchemaService.ts +161 -0
- package/src/schema/decorators.ts +162 -0
- package/src/schema/index.ts +3 -0
- package/src/schema/types.ts +66 -0
- package/src/serializer/EmbeddedRecordsMixin.ts +257 -0
- package/src/serializer/JsonSerializer.ts +173 -0
- package/src/serializer/RestSerializer.ts +138 -0
- package/src/serializer/Serializer.ts +397 -0
- package/src/serializer/index.ts +15 -0
- package/src/store/IdentityMap.ts +110 -0
- package/src/store/RecordArray.ts +210 -0
- package/src/store/Store.ts +1391 -0
- package/src/store/index.ts +11 -0
- package/src/transforms/Transform.ts +52 -0
- package/src/transforms/boolean.ts +57 -0
- package/src/transforms/date.ts +48 -0
- package/src/transforms/index.ts +5 -0
- package/src/transforms/number.ts +42 -0
- package/src/transforms/string.ts +35 -0
|
@@ -0,0 +1,683 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Base class for all mobx-data model records.
|
|
3
|
+
*
|
|
4
|
+
* `Model` is an abstract MobX-observable class that manages the complete
|
|
5
|
+
* lifecycle of a server-side resource. Subclasses declare their schema using
|
|
6
|
+
* `@attr`, `@belongsTo`, and `@hasMany` decorators; `Model` automatically
|
|
7
|
+
* installs observable getters/setters for each declaration the first time the
|
|
8
|
+
* class is instantiated.
|
|
9
|
+
*
|
|
10
|
+
* ## State machine
|
|
11
|
+
* Every record progresses through the states tracked by `StateMachine`:
|
|
12
|
+
* - **empty** → initial placeholder state
|
|
13
|
+
* - **loading** → adapter request in flight
|
|
14
|
+
* - **loaded.saved** → clean, persisted record
|
|
15
|
+
* - **loaded.created.uncommitted** → new, unsaved record
|
|
16
|
+
* - **loaded.updated.uncommitted** → dirty, unsaved changes
|
|
17
|
+
* - **deleted.*** → record marked for / undergoing deletion
|
|
18
|
+
* - **error** → adapter threw a non-validation error
|
|
19
|
+
*
|
|
20
|
+
* ## Lifecycle hooks
|
|
21
|
+
* Override `didLoad`, `didCreate`, `didUpdate`, `didDelete`, `willSave`,
|
|
22
|
+
* `didSave`, `becameInvalid`, and `becameError` to react to state transitions.
|
|
23
|
+
*
|
|
24
|
+
* ## Store integration
|
|
25
|
+
* Records are normally created and loaded through a `Store` instance. The
|
|
26
|
+
* `store` property is set by the store after instantiation so that `save()`,
|
|
27
|
+
* `reload()`, and `destroyRecord()` can delegate back to it.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import 'reflect-metadata';
|
|
31
|
+
import {
|
|
32
|
+
makeObservable,
|
|
33
|
+
observable,
|
|
34
|
+
computed,
|
|
35
|
+
action,
|
|
36
|
+
runInAction,
|
|
37
|
+
} from 'mobx';
|
|
38
|
+
import {
|
|
39
|
+
ATTRIBUTES_META_KEY,
|
|
40
|
+
RELATIONSHIPS_META_KEY,
|
|
41
|
+
type AttributeDef,
|
|
42
|
+
type RelationshipDef,
|
|
43
|
+
} from '@mobx-data/schema';
|
|
44
|
+
import { Errors } from './Errors.js';
|
|
45
|
+
import { StateMachine, type RecordState, type RecordEvent } from './StateMachine.js';
|
|
46
|
+
import { Snapshot } from './Snapshot.js';
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Raw relationship reference stored on the record.
|
|
50
|
+
* Contains either a single `{ type, id }` object (belongsTo),
|
|
51
|
+
* an array of them (hasMany), or `null`.
|
|
52
|
+
*/
|
|
53
|
+
export interface RelationshipRef {
|
|
54
|
+
data:
|
|
55
|
+
| { type: string; id: string }
|
|
56
|
+
| Array<{ type: string; id: string }>
|
|
57
|
+
| null;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Minimal store interface that `Model` uses to delegate persistence operations.
|
|
62
|
+
* The full `Store` class satisfies this interface.
|
|
63
|
+
*/
|
|
64
|
+
export interface SaveOptions {
|
|
65
|
+
patch?: boolean;
|
|
66
|
+
adapterOptions?: Record<string, unknown>;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export interface ModelStoreLike {
|
|
70
|
+
saveRecord?<T extends Model>(record: T, options?: SaveOptions): Promise<T>;
|
|
71
|
+
deleteRecord?<T extends Model>(record: T): Promise<T>;
|
|
72
|
+
reloadRecord?<T extends Model>(record: T): Promise<T>;
|
|
73
|
+
unloadRecord?(record: Model): void;
|
|
74
|
+
peekRecord?<T extends Model = Model>(type: string, id: string): T | null;
|
|
75
|
+
findRecord?<T extends Model = Model>(
|
|
76
|
+
type: string,
|
|
77
|
+
id: string,
|
|
78
|
+
options?: unknown,
|
|
79
|
+
): Promise<T>;
|
|
80
|
+
onRelationshipSet?(
|
|
81
|
+
record: Model,
|
|
82
|
+
relName: string,
|
|
83
|
+
ref: RelationshipRef,
|
|
84
|
+
): void;
|
|
85
|
+
resolveRelationship?(
|
|
86
|
+
record: Model,
|
|
87
|
+
name: string,
|
|
88
|
+
meta: RelationshipDef,
|
|
89
|
+
): unknown;
|
|
90
|
+
setRelationshipValue?(
|
|
91
|
+
record: Model,
|
|
92
|
+
name: string,
|
|
93
|
+
meta: RelationshipDef,
|
|
94
|
+
value: unknown,
|
|
95
|
+
): void;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Options accepted by the `Model` constructor. */
|
|
99
|
+
export interface ModelConstructorOptions {
|
|
100
|
+
/** Server-assigned id, or `null` / `undefined` for new records. */
|
|
101
|
+
id?: string | null;
|
|
102
|
+
/** Initial attribute data. */
|
|
103
|
+
data?: Record<string, unknown>;
|
|
104
|
+
/** Initial relationship references. */
|
|
105
|
+
relationships?: Record<string, RelationshipRef>;
|
|
106
|
+
/** Store instance injected so records can delegate persistence. */
|
|
107
|
+
store?: ModelStoreLike;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Options accepted by the static `Model.push` factory method. */
|
|
111
|
+
export interface PushOptions {
|
|
112
|
+
id: string;
|
|
113
|
+
data: Record<string, unknown>;
|
|
114
|
+
relationships?: Record<string, RelationshipRef>;
|
|
115
|
+
store?: ModelStoreLike;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Internal constructor options that include the initial state override. */
|
|
119
|
+
interface InternalOptions extends ModelConstructorOptions {
|
|
120
|
+
__initialState?: RecordState;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Symbol used as a per-class flag to avoid reinstalling accessors. */
|
|
124
|
+
const ACCESSORS_INSTALLED = Symbol('mobx-data:accessors-installed');
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Walks the prototype chain from root to leaf and merges own-metadata
|
|
128
|
+
* entries, with subclass definitions overriding ancestors.
|
|
129
|
+
*/
|
|
130
|
+
function walkProto<V>(
|
|
131
|
+
proto: object | null,
|
|
132
|
+
metadataKey: symbol,
|
|
133
|
+
): Map<string, V> {
|
|
134
|
+
const chain: object[] = [];
|
|
135
|
+
let current: object | null = proto;
|
|
136
|
+
while (current && current !== Object.prototype) {
|
|
137
|
+
chain.push(current);
|
|
138
|
+
current = Object.getPrototypeOf(current);
|
|
139
|
+
}
|
|
140
|
+
const merged = new Map<string, V>();
|
|
141
|
+
for (const entry of chain.reverse()) {
|
|
142
|
+
const local = Reflect.getOwnMetadata(metadataKey, entry) as
|
|
143
|
+
| Map<string, V>
|
|
144
|
+
| undefined;
|
|
145
|
+
if (local) {
|
|
146
|
+
for (const [name, value] of local) {
|
|
147
|
+
merged.set(name, value);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
return merged;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Installs observable getters/setters on the class prototype for every
|
|
156
|
+
* `@attr` and `@belongsTo` / `@hasMany` declaration.
|
|
157
|
+
*
|
|
158
|
+
* Run once per class (guarded by `ACCESSORS_INSTALLED`); subsequent
|
|
159
|
+
* instantiations are a no-op.
|
|
160
|
+
*/
|
|
161
|
+
function ensureAccessorsInstalled(klass: Function): void {
|
|
162
|
+
const proto = klass.prototype as Record<PropertyKey, unknown> & {
|
|
163
|
+
[ACCESSORS_INSTALLED]?: boolean;
|
|
164
|
+
};
|
|
165
|
+
if ((klass as unknown as Record<symbol, unknown>)[ACCESSORS_INSTALLED]) {
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
(klass as unknown as Record<symbol, unknown>)[ACCESSORS_INSTALLED] = true;
|
|
169
|
+
|
|
170
|
+
const attrs = walkProto<AttributeDef>(proto as object, ATTRIBUTES_META_KEY);
|
|
171
|
+
for (const [name] of attrs) {
|
|
172
|
+
Object.defineProperty(proto, name, {
|
|
173
|
+
get(this: Model) {
|
|
174
|
+
return (this as unknown as { _data: Record<string, unknown> })._data[name];
|
|
175
|
+
},
|
|
176
|
+
set(this: Model, value: unknown) {
|
|
177
|
+
(this as unknown as { _setAttribute(k: string, v: unknown): void })._setAttribute(
|
|
178
|
+
name,
|
|
179
|
+
value,
|
|
180
|
+
);
|
|
181
|
+
},
|
|
182
|
+
configurable: true,
|
|
183
|
+
enumerable: true,
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const relationships = walkProto<RelationshipDef>(proto as object, RELATIONSHIPS_META_KEY);
|
|
188
|
+
for (const [name, meta] of relationships) {
|
|
189
|
+
Object.defineProperty(proto, name, {
|
|
190
|
+
get(this: Model) {
|
|
191
|
+
return (this as unknown as {
|
|
192
|
+
_resolveRelationship(name: string, meta: RelationshipDef): unknown;
|
|
193
|
+
})._resolveRelationship(name, meta);
|
|
194
|
+
},
|
|
195
|
+
set(this: Model, value: unknown) {
|
|
196
|
+
(this as unknown as {
|
|
197
|
+
_setRelationship(name: string, meta: RelationshipDef, value: unknown): void;
|
|
198
|
+
})._setRelationship(name, meta, value);
|
|
199
|
+
},
|
|
200
|
+
configurable: true,
|
|
201
|
+
enumerable: true,
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
export abstract class Model {
|
|
207
|
+
/** Registered model name — must be set as a static property on subclasses. */
|
|
208
|
+
static modelName: string;
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Factory method that creates an instance in the `root.loaded.saved` state
|
|
212
|
+
* (i.e. as if freshly loaded from the server) and calls `didLoad()`.
|
|
213
|
+
*
|
|
214
|
+
* Used internally by `Store.pushResource` to avoid exposing the internal
|
|
215
|
+
* `__initialState` option.
|
|
216
|
+
*/
|
|
217
|
+
static push<T extends typeof Model>(
|
|
218
|
+
this: T,
|
|
219
|
+
opts: PushOptions,
|
|
220
|
+
): InstanceType<T> {
|
|
221
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
222
|
+
const Klass = this as unknown as new (internalOpts: InternalOptions) => Model;
|
|
223
|
+
const instance = new Klass({
|
|
224
|
+
...opts,
|
|
225
|
+
__initialState: 'root.loaded.saved',
|
|
226
|
+
});
|
|
227
|
+
(instance as unknown as { didLoad(): void }).didLoad();
|
|
228
|
+
return instance as InstanceType<T>;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// --- internal reactive state (prefixed with _) ---
|
|
232
|
+
|
|
233
|
+
/** Current attribute values. Deep-observable so nested mutations are tracked. */
|
|
234
|
+
protected _data: Record<string, unknown> = {};
|
|
235
|
+
/** Snapshot of attribute values as last received from the server (dirty tracking baseline). */
|
|
236
|
+
protected _originalData: Record<string, unknown> = {};
|
|
237
|
+
/** Raw relationship references keyed by relationship name. */
|
|
238
|
+
protected _relationships: Map<string, RelationshipRef> = new Map();
|
|
239
|
+
/** Server-assigned id, or `null` for new records. */
|
|
240
|
+
protected _id: string | null = null;
|
|
241
|
+
/** Internal lifecycle state machine. */
|
|
242
|
+
protected _stateMachine: StateMachine;
|
|
243
|
+
|
|
244
|
+
/** Observable validation error collection. */
|
|
245
|
+
readonly errors: Errors = new Errors();
|
|
246
|
+
/** Reference to the owning store, injected at construction. */
|
|
247
|
+
store?: ModelStoreLike;
|
|
248
|
+
|
|
249
|
+
constructor(options: ModelConstructorOptions = {}) {
|
|
250
|
+
const opts = options as InternalOptions;
|
|
251
|
+
ensureAccessorsInstalled(this.constructor);
|
|
252
|
+
|
|
253
|
+
this._id = opts.id ?? null;
|
|
254
|
+
this.store = opts.store;
|
|
255
|
+
this._stateMachine = new StateMachine(
|
|
256
|
+
opts.__initialState ?? 'root.loaded.created.uncommitted',
|
|
257
|
+
);
|
|
258
|
+
|
|
259
|
+
const initialData = opts.data ? { ...opts.data } : {};
|
|
260
|
+
this._data = initialData;
|
|
261
|
+
// When pushed (clean), snapshot original for dirty tracking.
|
|
262
|
+
if (opts.__initialState === 'root.loaded.saved') {
|
|
263
|
+
this._originalData = { ...initialData };
|
|
264
|
+
} else {
|
|
265
|
+
this._originalData = {};
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
if (opts.relationships) {
|
|
269
|
+
for (const [name, reference] of Object.entries(opts.relationships)) {
|
|
270
|
+
this._relationships.set(name, reference);
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
makeObservable<
|
|
275
|
+
this,
|
|
276
|
+
| '_data'
|
|
277
|
+
| '_originalData'
|
|
278
|
+
| '_relationships'
|
|
279
|
+
| '_id'
|
|
280
|
+
| '_setAttribute'
|
|
281
|
+
| '_transitionIfClean'
|
|
282
|
+
| '_applyServerData'
|
|
283
|
+
| '_setState'
|
|
284
|
+
>(this, {
|
|
285
|
+
_data: observable.shallow,
|
|
286
|
+
_originalData: observable.ref,
|
|
287
|
+
_relationships: observable.shallow,
|
|
288
|
+
_id: observable,
|
|
289
|
+
id: computed,
|
|
290
|
+
currentState: computed,
|
|
291
|
+
isLoading: computed,
|
|
292
|
+
isLoaded: computed,
|
|
293
|
+
isSaving: computed,
|
|
294
|
+
isDirty: computed,
|
|
295
|
+
hasDirtyAttributes: computed,
|
|
296
|
+
isNew: computed,
|
|
297
|
+
isDeleted: computed,
|
|
298
|
+
isValid: computed,
|
|
299
|
+
isError: computed,
|
|
300
|
+
isEmpty: computed,
|
|
301
|
+
_setAttribute: action,
|
|
302
|
+
_transitionIfClean: action,
|
|
303
|
+
_applyServerData: action,
|
|
304
|
+
_setState: action,
|
|
305
|
+
rollbackAttributes: action,
|
|
306
|
+
deleteRecord: action,
|
|
307
|
+
});
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/** Server-assigned id, or `null` for new records. */
|
|
311
|
+
get id(): string | null {
|
|
312
|
+
return this._id;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
set id(v: string | null) {
|
|
316
|
+
runInAction(() => {
|
|
317
|
+
this._id = v;
|
|
318
|
+
});
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/** Returns the static `modelName` from the concrete subclass constructor. */
|
|
322
|
+
get modelName(): string {
|
|
323
|
+
return (this.constructor as typeof Model).modelName;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/** Current state-machine state string. */
|
|
327
|
+
get currentState(): RecordState {
|
|
328
|
+
return this._stateMachine.current;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/** `true` while an adapter request to fetch this record is in flight. */
|
|
332
|
+
get isLoading(): boolean {
|
|
333
|
+
return this.currentState === 'root.loading';
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/** `true` when the record has been loaded (any `root.loaded.*` state). */
|
|
337
|
+
get isLoaded(): boolean {
|
|
338
|
+
return this.currentState.startsWith('root.loaded');
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/** `true` while a create or update request is in flight. */
|
|
342
|
+
get isSaving(): boolean {
|
|
343
|
+
return this.currentState.endsWith('.inFlight');
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/** `true` when the record was created locally and has never been saved. */
|
|
347
|
+
get isNew(): boolean {
|
|
348
|
+
return this.currentState.startsWith('root.loaded.created');
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/** `true` when `deleteRecord()` has been called (regardless of server state). */
|
|
352
|
+
get isDeleted(): boolean {
|
|
353
|
+
return this.currentState.startsWith('root.deleted');
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/** `true` when the record is in the `root.error` state. */
|
|
357
|
+
get isError(): boolean {
|
|
358
|
+
return this.currentState === 'root.error';
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/** `true` when the record is in the `root.empty` placeholder state. */
|
|
362
|
+
get isEmpty(): boolean {
|
|
363
|
+
return this.currentState === 'root.empty';
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/** `true` when any attribute differs from its last-saved value. */
|
|
367
|
+
get hasDirtyAttributes(): boolean {
|
|
368
|
+
const data = this._data;
|
|
369
|
+
const original = this._originalData;
|
|
370
|
+
for (const key of Object.keys(data)) {
|
|
371
|
+
if (!Object.is(data[key], original[key])) {
|
|
372
|
+
return true;
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
for (const key of Object.keys(original)) {
|
|
376
|
+
if (!(key in data)) {
|
|
377
|
+
return true;
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
return false;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/**
|
|
384
|
+
* `true` when the record needs to be saved — new, deleted (not yet
|
|
385
|
+
* confirmed), or has dirty attributes.
|
|
386
|
+
*/
|
|
387
|
+
get isDirty(): boolean {
|
|
388
|
+
if (this.isNew) {
|
|
389
|
+
return true;
|
|
390
|
+
}
|
|
391
|
+
if (this.isDeleted && this.currentState !== 'root.deleted.saved') {
|
|
392
|
+
return true;
|
|
393
|
+
}
|
|
394
|
+
return this.hasDirtyAttributes;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
/** `true` when `errors.isEmpty` — i.e. no validation errors are present. */
|
|
398
|
+
get isValid(): boolean {
|
|
399
|
+
return this.errors.isEmpty;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/**
|
|
403
|
+
* Returns a `{ [key]: [original, current] }` map of attributes that differ
|
|
404
|
+
* from the last server-received snapshot.
|
|
405
|
+
*/
|
|
406
|
+
changedAttributes(): Record<string, [unknown, unknown]> {
|
|
407
|
+
const changed: Record<string, [unknown, unknown]> = {};
|
|
408
|
+
const keys = new Set([
|
|
409
|
+
...Object.keys(this._data),
|
|
410
|
+
...Object.keys(this._originalData),
|
|
411
|
+
]);
|
|
412
|
+
for (const key of keys) {
|
|
413
|
+
const current = this._data[key];
|
|
414
|
+
const original = this._originalData[key];
|
|
415
|
+
if (!Object.is(current, original)) {
|
|
416
|
+
changed[key] = [original, current];
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
return changed;
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
/**
|
|
423
|
+
* Resets all attributes to their original server values and clears
|
|
424
|
+
* validation errors. For new records the record is transitioned to
|
|
425
|
+
* `root.empty` and unloaded from the store.
|
|
426
|
+
*/
|
|
427
|
+
rollbackAttributes(): void {
|
|
428
|
+
this._data = { ...this._originalData };
|
|
429
|
+
this.errors.clear();
|
|
430
|
+
if (this.isNew) {
|
|
431
|
+
this._setState('root.empty');
|
|
432
|
+
if (this.store?.unloadRecord) {
|
|
433
|
+
this.store.unloadRecord(this);
|
|
434
|
+
}
|
|
435
|
+
return;
|
|
436
|
+
}
|
|
437
|
+
if (this.currentState === 'root.loaded.updated.uncommitted') {
|
|
438
|
+
this._stateMachine.transition('rolledBack');
|
|
439
|
+
} else if (this.currentState === 'root.deleted.uncommitted') {
|
|
440
|
+
this._stateMachine.transition('rolledBack');
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
/**
|
|
445
|
+
* Persists the record to the server. No-ops if the record is not dirty.
|
|
446
|
+
* Delegates to `store.saveRecord`.
|
|
447
|
+
*
|
|
448
|
+
* @throws when no store is attached.
|
|
449
|
+
*/
|
|
450
|
+
async save(
|
|
451
|
+
options: SaveOptions = {},
|
|
452
|
+
): Promise<this> {
|
|
453
|
+
if (!this.isDirty) {
|
|
454
|
+
return this;
|
|
455
|
+
}
|
|
456
|
+
if (!this.store?.saveRecord) {
|
|
457
|
+
throw new Error('Cannot save: no store attached');
|
|
458
|
+
}
|
|
459
|
+
const wasNew = this.isNew;
|
|
460
|
+
this.willSave();
|
|
461
|
+
this._stateMachine.transition('willCommit');
|
|
462
|
+
try {
|
|
463
|
+
await this.store.saveRecord(this, options);
|
|
464
|
+
// If the store didn't already finalize the record, do it here.
|
|
465
|
+
if (
|
|
466
|
+
this.currentState === 'root.loaded.created.inFlight'
|
|
467
|
+
|| this.currentState === 'root.loaded.updated.inFlight'
|
|
468
|
+
) {
|
|
469
|
+
runInAction(() => {
|
|
470
|
+
this._originalData = { ...this._data };
|
|
471
|
+
this._stateMachine.transition('didCommit');
|
|
472
|
+
});
|
|
473
|
+
}
|
|
474
|
+
if (wasNew) {
|
|
475
|
+
this.didCreate();
|
|
476
|
+
} else {
|
|
477
|
+
this.didUpdate();
|
|
478
|
+
}
|
|
479
|
+
this.didSave();
|
|
480
|
+
return this;
|
|
481
|
+
} catch (error) {
|
|
482
|
+
if (!this.errors.isEmpty) {
|
|
483
|
+
this._stateMachine.transition('becameInvalid');
|
|
484
|
+
this.becameInvalid();
|
|
485
|
+
} else {
|
|
486
|
+
this._stateMachine.transition('becameError');
|
|
487
|
+
this.becameError();
|
|
488
|
+
}
|
|
489
|
+
throw error;
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
/**
|
|
494
|
+
* Reloads the record from the server.
|
|
495
|
+
* @throws when no store is attached.
|
|
496
|
+
*/
|
|
497
|
+
async reload(): Promise<this> {
|
|
498
|
+
if (!this.store?.reloadRecord) {
|
|
499
|
+
throw new Error('Cannot reload: no store attached');
|
|
500
|
+
}
|
|
501
|
+
return (await this.store.reloadRecord(this)) as this;
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
/**
|
|
505
|
+
* Marks the record for deletion. The record moves to
|
|
506
|
+
* `root.deleted.uncommitted` but is not yet removed from the server.
|
|
507
|
+
* Call `destroyRecord()` to also issue the DELETE request.
|
|
508
|
+
*/
|
|
509
|
+
deleteRecord(): void {
|
|
510
|
+
if (this.isNew) {
|
|
511
|
+
this._setState('root.deleted.uncommitted');
|
|
512
|
+
} else {
|
|
513
|
+
this._stateMachine.transition('deleteRecord');
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
/**
|
|
518
|
+
* Marks the record for deletion and immediately sends a DELETE request.
|
|
519
|
+
* @throws when no store is attached.
|
|
520
|
+
*/
|
|
521
|
+
async destroyRecord(): Promise<this> {
|
|
522
|
+
this.deleteRecord();
|
|
523
|
+
if (!this.store?.deleteRecord) {
|
|
524
|
+
throw new Error('Cannot destroy: no store attached');
|
|
525
|
+
}
|
|
526
|
+
this._stateMachine.transition('willCommit');
|
|
527
|
+
const result = (await this.store.deleteRecord(this)) as this;
|
|
528
|
+
if (this.currentState === 'root.deleted.inFlight') {
|
|
529
|
+
this._stateMachine.transition('didCommit');
|
|
530
|
+
}
|
|
531
|
+
this.didDelete();
|
|
532
|
+
return result;
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
/** Removes the record from the store's identity map without a server call. */
|
|
536
|
+
unloadRecord(): void {
|
|
537
|
+
if (this.store?.unloadRecord) {
|
|
538
|
+
this.store.unloadRecord(this);
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
/** Creates a frozen `Snapshot` of the current record state. */
|
|
543
|
+
createSnapshot(): Snapshot<this> {
|
|
544
|
+
return new Snapshot(this);
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
/** Returns a plain-object representation of the current attribute data. */
|
|
548
|
+
serialize(_options: { includeId?: boolean } = {}): Record<string, unknown> {
|
|
549
|
+
return { ...this._data };
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
/** Returns `{ id, ...attributes }` — used by `JSON.stringify`. */
|
|
553
|
+
toJSON(): Record<string, unknown> {
|
|
554
|
+
return { id: this._id, ...this._data };
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
// Lifecycle hooks — default no-ops, overridable.
|
|
558
|
+
|
|
559
|
+
/** Called after the record is loaded from the server. */
|
|
560
|
+
didLoad(): void {}
|
|
561
|
+
/** Called after a new record is successfully persisted. */
|
|
562
|
+
didCreate(): void {}
|
|
563
|
+
/** Called after an existing record is successfully updated. */
|
|
564
|
+
didUpdate(): void {}
|
|
565
|
+
/** Called after a record is successfully deleted. */
|
|
566
|
+
didDelete(): void {}
|
|
567
|
+
/** Called immediately before a save request is issued. */
|
|
568
|
+
willSave(): void {}
|
|
569
|
+
/** Called after any successful save (create or update). */
|
|
570
|
+
didSave(): void {}
|
|
571
|
+
/** Called when the server returns a 422-style validation error. */
|
|
572
|
+
becameInvalid(): void {}
|
|
573
|
+
/** Called when the server returns a non-validation error. */
|
|
574
|
+
becameError(): void {}
|
|
575
|
+
|
|
576
|
+
// --- internals ---
|
|
577
|
+
|
|
578
|
+
/** Called by generated attribute setters. */
|
|
579
|
+
protected _setAttribute(key: string, value: unknown): void {
|
|
580
|
+
if (Object.is(this._data[key], value)) {
|
|
581
|
+
return;
|
|
582
|
+
}
|
|
583
|
+
this._data[key] = value;
|
|
584
|
+
this._transitionIfClean();
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
/**
|
|
588
|
+
* Transitions to `updated.uncommitted` when the record becomes dirty, or
|
|
589
|
+
* back to `saved` when all changes are rolled back.
|
|
590
|
+
*/
|
|
591
|
+
protected _transitionIfClean(): void {
|
|
592
|
+
const dirty = this.hasDirtyAttributes;
|
|
593
|
+
if (dirty && this.currentState === 'root.loaded.saved') {
|
|
594
|
+
this._stateMachine.transition('didSetProperty');
|
|
595
|
+
} else if (!dirty && this.currentState === 'root.loaded.updated.uncommitted') {
|
|
596
|
+
this._stateMachine.transition('rolledBack');
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
/** Directly sets the state machine's current state (bypasses transition validation). */
|
|
601
|
+
protected _setState(state: RecordState): void {
|
|
602
|
+
this._stateMachine.current = state;
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
/** Fires a state-machine transition event. */
|
|
606
|
+
protected _transition(event: RecordEvent): void {
|
|
607
|
+
this._stateMachine.transition(event);
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
/** Used by Store to apply server data after save, making record clean again. */
|
|
611
|
+
protected _applyServerData(
|
|
612
|
+
id: string | null,
|
|
613
|
+
data: Record<string, unknown>,
|
|
614
|
+
relationships?: Record<string, RelationshipRef>,
|
|
615
|
+
): void {
|
|
616
|
+
if (id !== null) {
|
|
617
|
+
this._id = id;
|
|
618
|
+
}
|
|
619
|
+
this._data = { ...this._data, ...data };
|
|
620
|
+
this._originalData = { ...this._data };
|
|
621
|
+
this.errors.clear();
|
|
622
|
+
if (relationships) {
|
|
623
|
+
for (const [name, reference] of Object.entries(relationships)) {
|
|
624
|
+
this._relationships.set(name, reference);
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
if (this.currentState === 'root.loaded.created.inFlight') {
|
|
628
|
+
this._stateMachine.transition('didCommit');
|
|
629
|
+
this.didCreate();
|
|
630
|
+
} else if (this.currentState === 'root.loaded.updated.inFlight') {
|
|
631
|
+
this._stateMachine.transition('didCommit');
|
|
632
|
+
this.didUpdate();
|
|
633
|
+
} else if (this.currentState === 'root.deleted.inFlight') {
|
|
634
|
+
this._stateMachine.transition('didCommit');
|
|
635
|
+
} else if (this.currentState === 'root.loading') {
|
|
636
|
+
this._stateMachine.transition('pushedData');
|
|
637
|
+
}
|
|
638
|
+
// If pushed externally to a created/updated record, reset to saved.
|
|
639
|
+
if (
|
|
640
|
+
!this.isNew
|
|
641
|
+
&& !this.isDeleted
|
|
642
|
+
&& (this.currentState === 'root.loaded.updated.uncommitted'
|
|
643
|
+
|| this.currentState === 'root.loaded.created.uncommitted')
|
|
644
|
+
) {
|
|
645
|
+
this._setState('root.loaded.saved');
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
/** Relationship data (reference only). Resolution to records lives in Store. */
|
|
650
|
+
protected _getRelationshipRef(name: string): RelationshipRef | null {
|
|
651
|
+
return this._relationships.get(name) ?? null;
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
/** Stores a raw relationship reference without triggering store logic. */
|
|
655
|
+
protected _setRelationshipRef(name: string, ref: RelationshipRef): void {
|
|
656
|
+
this._relationships.set(name, ref);
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
/**
|
|
660
|
+
* Delegates relationship resolution to the store.
|
|
661
|
+
* Returns `null` when no store is attached (e.g. in unit tests).
|
|
662
|
+
*/
|
|
663
|
+
protected _resolveRelationship(
|
|
664
|
+
name: string,
|
|
665
|
+
meta: RelationshipDef,
|
|
666
|
+
): unknown {
|
|
667
|
+
if (this.store?.resolveRelationship) {
|
|
668
|
+
return this.store.resolveRelationship(this, name, meta);
|
|
669
|
+
}
|
|
670
|
+
return null;
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
/** Delegates relationship mutation to the store (which also handles inverse sync). */
|
|
674
|
+
protected _setRelationship(
|
|
675
|
+
name: string,
|
|
676
|
+
meta: RelationshipDef,
|
|
677
|
+
value: unknown,
|
|
678
|
+
): void {
|
|
679
|
+
if (this.store?.setRelationshipValue) {
|
|
680
|
+
this.store.setRelationshipValue(this, name, meta, value);
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
}
|