epos-unit 1.13.0 → 1.15.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.
@@ -1,30 +1,67 @@
1
1
  import * as mobx from 'mobx';
2
- import { Cls } from 'dropcap/types';
3
- import { Log } from 'dropcap/utils';
2
+ import { Cls, Obj, Arr } from 'dropcap/types';
3
+ import { createLog } from 'dropcap/utils';
4
4
 
5
5
  declare const _root_: unique symbol;
6
6
  declare const _parent_: unique symbol;
7
+ declare const _attached_: unique symbol;
7
8
  declare const _disposers_: unique symbol;
8
- type Descriptors = Record<string | symbol, PropertyDescriptor>;
9
+ declare const _ancestors_: unique symbol;
10
+ declare const _pendingAttachFns_: unique symbol;
11
+ type Node<T> = Unit<T> | Obj | Arr;
9
12
  declare class Unit<TRoot = unknown> {
10
- static [epos.symbols.stateModelStrict]: boolean;
11
- static [epos.symbols.stateModelVersioner]: unknown;
12
- [epos.symbols.stateModelInit]: () => void;
13
- [epos.symbols.stateModelDispose]: () => void;
13
+ /**
14
+ * Lifecycle method called when the unit is attached to the state tree.
15
+ */
16
+ [epos.state.ATTACH]: () => void;
17
+ /**
18
+ * Lifecycle method called when the unit is detached from the state tree.
19
+ */
20
+ [epos.state.DETACH]: () => void;
14
21
  '@': string;
15
- log: Log;
22
+ log: ReturnType<typeof createLog>;
16
23
  [':version']?: number;
17
- private [_root_];
18
- private [_parent_];
19
- private [_disposers_];
24
+ [_root_]?: TRoot;
25
+ [_parent_]?: Unit<TRoot> | null;
26
+ [_attached_]?: boolean;
27
+ [_disposers_]?: Set<() => void>;
28
+ [_ancestors_]?: Map<Cls, unknown>;
29
+ [_pendingAttachFns_]?: (() => void)[];
20
30
  constructor(parent: Unit<TRoot> | null);
21
- get $(): TRoot;
31
+ /**
32
+ * Gets the root unit of the current unit's tree.
33
+ * The result is cached for subsequent calls.
34
+ */
35
+ get $(): TRoot | (undefined & TRoot);
36
+ /**
37
+ * Finds the closest ancestor unit of a given type.
38
+ * The result is cached for subsequent calls.
39
+ */
22
40
  closest<T extends Unit>(Ancestor: Cls<T>): T | null;
41
+ /**
42
+ * A wrapper around MobX's `autorun` that automatically disposes
43
+ * the reaction when the unit is detached.
44
+ */
23
45
  autorun(...args: Parameters<typeof epos.libs.mobx.autorun>): mobx.IReactionDisposer;
46
+ /**
47
+ * A wrapper around MobX's `reaction` that automatically disposes
48
+ * the reaction when the unit is detached.
49
+ */
24
50
  reaction(...args: Parameters<typeof epos.libs.mobx.reaction>): mobx.IReactionDisposer;
51
+ /**
52
+ * A wrapper around `setTimeout` that automatically clears the timeout
53
+ * when the unit is detached.
54
+ */
25
55
  setTimeout(...args: Parameters<typeof self.setTimeout>): number;
56
+ /**
57
+ * A wrapper around `setInterval` that automatically clears the interval
58
+ * when the unit is detached.
59
+ */
26
60
  setInterval(...args: Parameters<typeof self.setInterval>): number;
27
- never(message?: string): void;
61
+ /**
62
+ * Creates an error for an unreachable code path.
63
+ */
64
+ never(message?: string): Error;
28
65
  }
29
66
 
30
- export { type Descriptors, Unit, _disposers_, _parent_, _root_ };
67
+ export { type Node, Unit, _ancestors_, _attached_, _disposers_, _parent_, _pendingAttachFns_, _root_ };
package/dist/epos-unit.js CHANGED
@@ -1,113 +1,178 @@
1
1
  // src/epos-unit.ts
2
+ import { createLog, is } from "dropcap/utils";
2
3
  import "epos";
3
- import { createLog } from "dropcap/utils";
4
4
  var _root_ = /* @__PURE__ */ Symbol("root");
5
5
  var _parent_ = /* @__PURE__ */ Symbol("parent");
6
+ var _attached_ = /* @__PURE__ */ Symbol("attached");
6
7
  var _disposers_ = /* @__PURE__ */ Symbol("disposers");
8
+ var _ancestors_ = /* @__PURE__ */ Symbol("ancestors");
9
+ var _pendingAttachFns_ = /* @__PURE__ */ Symbol("pendingAttachFns");
7
10
  var Unit = class {
8
- static get [epos.symbols.stateModelStrict]() {
9
- return true;
10
- }
11
11
  constructor(parent) {
12
- Reflect.defineProperty(this, _parent_, { get: () => parent });
12
+ this[_parent_] = parent;
13
+ const versions = getVersions(this);
14
+ if (versions.length > 0) this[":version"] = versions.at(-1);
13
15
  }
14
- // ---------------------------------------------------------------------------
15
- // INIT
16
- // ---------------------------------------------------------------------------
17
- [epos.symbols.stateModelInit]() {
18
- const _this = this;
19
- const Unit2 = this.constructor;
20
- const descriptors = Object.getOwnPropertyDescriptors(Unit2.prototype);
21
- const keys = Reflect.ownKeys(descriptors);
22
- const disposers = /* @__PURE__ */ new Set();
23
- Reflect.defineProperty(this, _disposers_, { get: () => disposers });
24
- for (const key of keys) {
25
- if (key === "constructor") continue;
26
- const descriptor = descriptors[key];
27
- if (!descriptor) continue;
28
- if (descriptor.get || descriptor.set) continue;
29
- if (typeof descriptor.value !== "function") continue;
30
- _this[key] = descriptor.value.bind(this);
16
+ /**
17
+ * Lifecycle method called when the unit is attached to the state tree.
18
+ */
19
+ [epos.state.ATTACH]() {
20
+ setProperty(this, "log", createLog(this["@"]));
21
+ epos.state.transaction(() => {
22
+ const versioner = getVersioner(this);
23
+ const versions = getVersions(this);
24
+ for (const version of versions) {
25
+ if (is.number(this[":version"]) && this[":version"] >= version) continue;
26
+ const versionFn = versioner[version];
27
+ if (!is.function(versionFn)) continue;
28
+ versionFn.call(this, this);
29
+ this[":version"] = version;
30
+ }
31
+ });
32
+ for (const prototype of getPrototypes(this)) {
33
+ const descriptors = Object.getOwnPropertyDescriptors(prototype);
34
+ for (const [key, descriptor] of Object.entries(descriptors)) {
35
+ if (key === "constructor") continue;
36
+ if (this.hasOwnProperty(key)) continue;
37
+ if (descriptor.get || descriptor.set) continue;
38
+ if (!is.function(descriptor.value)) continue;
39
+ const fn = descriptor.value.bind(this);
40
+ if (key.endsWith("View")) {
41
+ let Component = epos.component(fn);
42
+ Component.displayName = `${this.constructor.name}.${key}`;
43
+ setProperty(this, key, Component);
44
+ } else {
45
+ setProperty(this, key, fn);
46
+ }
47
+ }
31
48
  }
32
- for (const key of keys) {
33
- if (typeof key === "symbol") continue;
34
- if (!key.endsWith("View")) continue;
35
- const descriptor = descriptors[key];
36
- if (!descriptor) continue;
37
- if (descriptor.get || descriptor.set) continue;
38
- if (typeof _this[key] !== "function") continue;
39
- _this[key] = epos.component(_this[key]);
40
- _this[key].displayName = `${this["@"]}.${key}`;
49
+ const stateDescriptor = Reflect.getOwnPropertyDescriptor(this.constructor.prototype, "state");
50
+ if (stateDescriptor && stateDescriptor.get) {
51
+ const state = stateDescriptor.get.call(this);
52
+ setProperty(this, "state", epos.libs.mobx.observable.object(state, {}, { deep: false }));
41
53
  }
42
- const log = createLog(this["@"]);
43
- Reflect.defineProperty(this, "log", { get: () => log });
44
- if (typeof _this.init === "function") _this.init();
45
- }
46
- // ---------------------------------------------------------------------------
47
- // DISPOSE
48
- // ---------------------------------------------------------------------------
49
- [epos.symbols.stateModelDispose]() {
50
- const _this = this;
51
- this[_disposers_].forEach((disposer) => disposer());
52
- this[_disposers_].clear();
53
- if (typeof _this.dispose === "function") _this.dispose();
54
+ const attach = Reflect.get(this, "attach");
55
+ if (is.function(attach)) {
56
+ const unattachedRoot = findUnattachedRoot(this);
57
+ if (!unattachedRoot) throw this.never();
58
+ ensureProperty(unattachedRoot, _pendingAttachFns_, () => []);
59
+ unattachedRoot[_pendingAttachFns_].push(() => attach());
60
+ if (this[_pendingAttachFns_]) {
61
+ this[_pendingAttachFns_].forEach((attach2) => attach2());
62
+ delete this[_pendingAttachFns_];
63
+ }
64
+ }
65
+ setProperty(this, _attached_, true);
54
66
  }
55
- // ---------------------------------------------------------------------------
56
- // VERSIONER
57
- // ---------------------------------------------------------------------------
58
- static get [epos.symbols.stateModelVersioner]() {
59
- if (!("versioner" in this)) return null;
60
- return this.versioner;
67
+ /**
68
+ * Lifecycle method called when the unit is detached from the state tree.
69
+ */
70
+ [epos.state.DETACH]() {
71
+ if (this[_disposers_]) {
72
+ this[_disposers_].forEach((disposer) => disposer());
73
+ this[_disposers_].clear();
74
+ }
75
+ const detach = Reflect.get(this, "detach");
76
+ if (is.function(detach)) detach();
77
+ delete this[_root_];
78
+ delete this[_attached_];
79
+ delete this[_ancestors_];
80
+ delete this[_disposers_];
61
81
  }
62
- // ---------------------------------------------------------------------------
63
- // ROOT
64
- // ---------------------------------------------------------------------------
82
+ /**
83
+ * Gets the root unit of the current unit's tree.
84
+ * The result is cached for subsequent calls.
85
+ */
65
86
  get $() {
66
- this[_root_] ??= findRoot(this);
87
+ ensureProperty(this, _root_, () => findRoot(this));
67
88
  return this[_root_];
68
89
  }
69
- // ---------------------------------------------------------------------------
70
- // METHODS
71
- // ---------------------------------------------------------------------------
90
+ /**
91
+ * Finds the closest ancestor unit of a given type.
92
+ * The result is cached for subsequent calls.
93
+ */
72
94
  closest(Ancestor) {
73
- let cursor = getParent(this);
95
+ ensureProperty(this, _ancestors_, () => /* @__PURE__ */ new Map());
96
+ if (this[_ancestors_].has(Ancestor)) return this[_ancestors_].get(Ancestor);
97
+ let cursor = this;
74
98
  while (cursor) {
75
- if (cursor instanceof Ancestor) return cursor;
99
+ if (cursor instanceof Ancestor) {
100
+ this[_ancestors_].set(Ancestor, cursor);
101
+ return cursor;
102
+ }
76
103
  cursor = getParent(cursor);
77
104
  }
78
105
  return null;
79
106
  }
107
+ /**
108
+ * A wrapper around MobX's `autorun` that automatically disposes
109
+ * the reaction when the unit is detached.
110
+ */
80
111
  autorun(...args) {
81
112
  const disposer = epos.libs.mobx.autorun(...args);
113
+ ensureProperty(this, _disposers_, () => /* @__PURE__ */ new Set());
82
114
  this[_disposers_].add(disposer);
83
115
  return disposer;
84
116
  }
117
+ /**
118
+ * A wrapper around MobX's `reaction` that automatically disposes
119
+ * the reaction when the unit is detached.
120
+ */
85
121
  reaction(...args) {
86
122
  const disposer = epos.libs.mobx.reaction(...args);
123
+ ensureProperty(this, _disposers_, () => /* @__PURE__ */ new Set());
87
124
  this[_disposers_].add(disposer);
88
125
  return disposer;
89
126
  }
127
+ /**
128
+ * A wrapper around `setTimeout` that automatically clears the timeout
129
+ * when the unit is detached.
130
+ */
90
131
  setTimeout(...args) {
91
132
  const id = self.setTimeout(...args);
133
+ ensureProperty(this, _disposers_, () => /* @__PURE__ */ new Set());
92
134
  this[_disposers_].add(() => self.clearTimeout(id));
93
135
  return id;
94
136
  }
137
+ /**
138
+ * A wrapper around `setInterval` that automatically clears the interval
139
+ * when the unit is detached.
140
+ */
95
141
  setInterval(...args) {
96
142
  const id = self.setInterval(...args);
143
+ ensureProperty(this, _disposers_, () => /* @__PURE__ */ new Set());
97
144
  this[_disposers_].add(() => self.clearInterval(id));
98
145
  return id;
99
146
  }
147
+ /**
148
+ * Creates an error for an unreachable code path.
149
+ */
100
150
  never(message = "This should never happen") {
101
- const error = new Error(`[${this["@"]}] ${message}`);
151
+ const details = message ? `: ${message}` : "";
152
+ const error = new Error(`[${this.constructor.name}] This should never happen${details}`);
102
153
  Error.captureStackTrace(error, this.never);
103
- throw error;
154
+ return error;
104
155
  }
105
156
  };
106
- function getParent(child) {
107
- return child[_parent_] ?? child[epos.symbols.stateParent];
157
+ function setProperty(object, key, value) {
158
+ Reflect.defineProperty(object, key, {
159
+ configurable: true,
160
+ get: () => value,
161
+ set: (v) => value = v
162
+ });
163
+ }
164
+ function ensureProperty(object, key, getInitialValue) {
165
+ if (key in object) return;
166
+ const value = getInitialValue();
167
+ Reflect.defineProperty(object, key, { configurable: true, get: () => value });
168
+ }
169
+ function getPrototypes(object) {
170
+ const prototype = Reflect.getPrototypeOf(object);
171
+ if (!prototype || prototype === Object.prototype) return [];
172
+ return [prototype, ...getPrototypes(prototype)];
108
173
  }
109
174
  function findRoot(unit) {
110
- let root = unit;
175
+ let root = null;
111
176
  let cursor = unit;
112
177
  while (cursor) {
113
178
  if (cursor instanceof Unit) root = cursor;
@@ -115,9 +180,36 @@ function findRoot(unit) {
115
180
  }
116
181
  return root;
117
182
  }
183
+ function findUnattachedRoot(unit) {
184
+ let unattachedRoot = null;
185
+ let cursor = unit;
186
+ while (cursor) {
187
+ if (cursor instanceof Unit && !cursor[_attached_]) unattachedRoot = cursor;
188
+ cursor = getParent(cursor);
189
+ }
190
+ return unattachedRoot;
191
+ }
192
+ function getParent(node) {
193
+ const parent = Reflect.get(node, _parent_) ?? Reflect.get(node, epos.state.PARENT) ?? null;
194
+ return parent;
195
+ }
196
+ function getVersioner(unit) {
197
+ const versioner = Reflect.get(unit.constructor, "versioner");
198
+ if (!is.object(versioner)) return {};
199
+ return versioner;
200
+ }
201
+ function getVersions(unit) {
202
+ const versioner = getVersioner(unit);
203
+ const numericKeys = Object.keys(versioner).filter((key) => is.numeric(key));
204
+ return numericKeys.map(Number).sort((v1, v2) => v1 - v2);
205
+ }
118
206
  export {
119
207
  Unit,
208
+ _ancestors_,
209
+ _attached_,
120
210
  _disposers_,
121
211
  _parent_,
212
+ _pendingAttachFns_,
122
213
  _root_
123
214
  };
215
+ //# sourceMappingURL=epos-unit.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/epos-unit.ts"],"sourcesContent":["import type { Arr, Cls, Obj } from 'dropcap/types'\nimport { createLog, is } from 'dropcap/utils'\nimport 'epos'\n\nexport const _root_ = Symbol('root')\nexport const _parent_ = Symbol('parent')\nexport const _attached_ = Symbol('attached')\nexport const _disposers_ = Symbol('disposers')\nexport const _ancestors_ = Symbol('ancestors')\nexport const _pendingAttachFns_ = Symbol('pendingAttachFns')\n\nexport type Node<T> = Unit<T> | Obj | Arr\n\nexport class Unit<TRoot = unknown> {\n declare '@': string\n declare log: ReturnType<typeof createLog>;\n declare [':version']?: number;\n declare [_root_]?: TRoot;\n declare [_parent_]?: Unit<TRoot> | null; // Parent reference for a not-yet-attached units\n declare [_attached_]?: boolean;\n declare [_disposers_]?: Set<() => void>;\n declare [_ancestors_]?: Map<Cls, unknown>;\n declare [_pendingAttachFns_]?: (() => void)[]\n\n constructor(parent: Unit<TRoot> | null) {\n this[_parent_] = parent\n const versions = getVersions(this)\n if (versions.length > 0) this[':version'] = versions.at(-1)!\n }\n\n /**\n * Lifecycle method called when the unit is attached to the state tree.\n */\n [epos.state.ATTACH]() {\n // Setup logger\n setProperty(this, 'log', createLog(this['@']))\n\n // Apply versioner\n epos.state.transaction(() => {\n const versioner = getVersioner(this)\n const versions = getVersions(this)\n for (const version of versions) {\n if (is.number(this[':version']) && this[':version'] >= version) continue\n const versionFn = versioner[version]\n if (!is.function(versionFn)) continue\n versionFn.call(this, this)\n this[':version'] = version\n }\n })\n\n // Prepare methods:\n // - Create components for methods ending with `View`\n // - Bind all other methods to the unit instance\n for (const prototype of getPrototypes(this)) {\n const descriptors = Object.getOwnPropertyDescriptors(prototype)\n\n for (const [key, descriptor] of Object.entries(descriptors)) {\n if (key === 'constructor') continue\n if (this.hasOwnProperty(key)) continue\n\n if (descriptor.get || descriptor.set) continue\n if (!is.function(descriptor.value)) continue\n const fn = descriptor.value.bind(this)\n\n if (key.endsWith('View')) {\n let Component = epos.component(fn)\n Component.displayName = `${this.constructor.name}.${key}`\n setProperty(this, key, Component)\n } else {\n setProperty(this, key, fn)\n }\n }\n }\n\n // Setup state\n const stateDescriptor = Reflect.getOwnPropertyDescriptor(this.constructor.prototype, 'state')\n if (stateDescriptor && stateDescriptor.get) {\n const state = stateDescriptor.get.call(this)\n setProperty(this, 'state', epos.libs.mobx.observable.object(state, {}, { deep: false }))\n }\n\n // Process attach queue.\n // We do not execute `attach` methods immediately, but rather queue them\n // on the highest unattached ancestor. This way we ensure that `attach`\n // methods are called after all versioners have been applied in the entire subtree.\n const attach = Reflect.get(this, 'attach')\n if (is.function(attach)) {\n const unattachedRoot = findUnattachedRoot(this)\n if (!unattachedRoot) throw this.never()\n\n ensureProperty(unattachedRoot, _pendingAttachFns_, () => [])\n unattachedRoot[_pendingAttachFns_].push(() => attach())\n\n if (this[_pendingAttachFns_]) {\n this[_pendingAttachFns_].forEach(attach => attach())\n delete this[_pendingAttachFns_]\n }\n }\n\n // Mark as attached\n setProperty(this, _attached_, true)\n }\n\n /**\n * Lifecycle method called when the unit is detached from the state tree.\n */\n [epos.state.DETACH]() {\n // 1. Run disposers\n if (this[_disposers_]) {\n this[_disposers_].forEach(disposer => disposer())\n this[_disposers_].clear()\n }\n\n // 2. Call detach method\n const detach = Reflect.get(this, 'detach')\n if (is.function(detach)) detach()\n\n // 3. Clean up internal properties\n delete this[_root_]\n delete this[_attached_]\n delete this[_ancestors_]\n delete this[_disposers_]\n }\n\n /**\n * Gets the root unit of the current unit's tree.\n * The result is cached for subsequent calls.\n */\n get $() {\n ensureProperty(this, _root_, () => findRoot(this))\n return this[_root_]\n }\n\n /**\n * Finds the closest ancestor unit of a given type.\n * The result is cached for subsequent calls.\n */\n closest<T extends Unit>(Ancestor: Cls<T>) {\n // Has cached value? -> Return it\n ensureProperty(this, _ancestors_, () => new Map())\n if (this[_ancestors_].has(Ancestor)) return this[_ancestors_].get(Ancestor) as T\n\n // Find the closest ancestor and cache it\n let cursor: Node<TRoot> | null = this\n while (cursor) {\n if (cursor instanceof Ancestor) {\n this[_ancestors_].set(Ancestor, cursor)\n return cursor\n }\n cursor = getParent(cursor)\n }\n\n return null\n }\n\n /**\n * A wrapper around MobX's `autorun` that automatically disposes\n * the reaction when the unit is detached.\n */\n autorun(...args: Parameters<typeof epos.libs.mobx.autorun>) {\n const disposer = epos.libs.mobx.autorun(...args)\n ensureProperty(this, _disposers_, () => new Set())\n this[_disposers_].add(disposer)\n return disposer\n }\n\n /**\n * A wrapper around MobX's `reaction` that automatically disposes\n * the reaction when the unit is detached.\n */\n reaction(...args: Parameters<typeof epos.libs.mobx.reaction>) {\n const disposer = epos.libs.mobx.reaction(...args)\n ensureProperty(this, _disposers_, () => new Set())\n this[_disposers_].add(disposer)\n return disposer\n }\n\n /**\n * A wrapper around `setTimeout` that automatically clears the timeout\n * when the unit is detached.\n */\n setTimeout(...args: Parameters<typeof self.setTimeout>) {\n const id = self.setTimeout(...args)\n ensureProperty(this, _disposers_, () => new Set())\n this[_disposers_].add(() => self.clearTimeout(id))\n return id\n }\n\n /**\n * A wrapper around `setInterval` that automatically clears the interval\n * when the unit is detached.\n */\n setInterval(...args: Parameters<typeof self.setInterval>) {\n const id = self.setInterval(...args)\n ensureProperty(this, _disposers_, () => new Set())\n this[_disposers_].add(() => self.clearInterval(id))\n return id\n }\n\n /**\n * Creates an error for an unreachable code path.\n */\n never(message = 'This should never happen') {\n const details = message ? `: ${message}` : ''\n const error = new Error(`[${this.constructor.name}] This should never happen${details}`)\n Error.captureStackTrace(error, this.never)\n return error\n }\n}\n\n// ---------------------------------------------------------------------------\n// HELPERS\n// ---------------------------------------------------------------------------\n\n/**\n * Defines a configurable property on an object.\n */\nfunction setProperty(object: object, key: PropertyKey, value: unknown) {\n Reflect.defineProperty(object, key, {\n configurable: true,\n get: () => value,\n set: v => (value = v),\n })\n}\n\n/**\n * Ensures a property exists on an object, initializing it if it doesn't.\n */\nfunction ensureProperty<T extends object, K extends PropertyKey, V>(\n object: T,\n key: K,\n getInitialValue: () => V,\n): asserts object is T & { [key in K]: V } {\n if (key in object) return\n const value = getInitialValue()\n Reflect.defineProperty(object, key, { configurable: true, get: () => value })\n}\n\n/**\n * Gets all prototypes of an object up to `Object.prototype`.\n */\nfunction getPrototypes(object: object): object[] {\n const prototype = Reflect.getPrototypeOf(object)\n if (!prototype || prototype === Object.prototype) return []\n return [prototype, ...getPrototypes(prototype)]\n}\n\n/**\n * Finds the root `Unit` in the hierarchy for a given unit.\n */\nfunction findRoot<T>(unit: Unit<T>) {\n let root: Unit<T> | null = null\n let cursor: Node<T> | null = unit\n\n while (cursor) {\n if (cursor instanceof Unit) root = cursor\n cursor = getParent(cursor)\n }\n\n return root as T\n}\n\n/**\n * Finds the highest unattached `Unit` in the hierarchy for a given unit.\n */\nfunction findUnattachedRoot<T>(unit: Unit<T>) {\n let unattachedRoot: Unit<T> | null = null\n let cursor: Node<T> | null = unit\n\n while (cursor) {\n if (cursor instanceof Unit && !cursor[_attached_]) unattachedRoot = cursor\n cursor = getParent(cursor)\n }\n\n return unattachedRoot\n}\n\n/**\n * Gets the parent of a node, which can be a `Unit`, an object, or an array.\n */\nfunction getParent<T>(node: Node<T>) {\n const parent: Node<T> | null = Reflect.get(node, _parent_) ?? Reflect.get(node, epos.state.PARENT) ?? null\n return parent\n}\n\n/**\n * Gets the versioner object from a unit's constructor.\n */\nfunction getVersioner<T>(unit: Unit<T>) {\n const versioner: unknown = Reflect.get(unit.constructor, 'versioner')\n if (!is.object(versioner)) return {}\n return versioner\n}\n\n/**\n * Gets a sorted list of numeric version keys from a unit's versioner.\n */\nfunction getVersions<T>(unit: Unit<T>) {\n const versioner = getVersioner(unit)\n const numericKeys = Object.keys(versioner).filter(key => is.numeric(key))\n return numericKeys.map(Number).sort((v1, v2) => v1 - v2)\n}\n"],"mappings":";AACA,SAAS,WAAW,UAAU;AAC9B,OAAO;AAEA,IAAM,SAAS,uBAAO,MAAM;AAC5B,IAAM,WAAW,uBAAO,QAAQ;AAChC,IAAM,aAAa,uBAAO,UAAU;AACpC,IAAM,cAAc,uBAAO,WAAW;AACtC,IAAM,cAAc,uBAAO,WAAW;AACtC,IAAM,qBAAqB,uBAAO,kBAAkB;AAIpD,IAAM,OAAN,MAA4B;AAAA,EAWjC,YAAY,QAA4B;AACtC,SAAK,QAAQ,IAAI;AACjB,UAAM,WAAW,YAAY,IAAI;AACjC,QAAI,SAAS,SAAS,EAAG,MAAK,UAAU,IAAI,SAAS,GAAG,EAAE;AAAA,EAC5D;AAAA;AAAA;AAAA;AAAA,EAKA,CAAC,KAAK,MAAM,MAAM,IAAI;AAEpB,gBAAY,MAAM,OAAO,UAAU,KAAK,GAAG,CAAC,CAAC;AAG7C,SAAK,MAAM,YAAY,MAAM;AAC3B,YAAM,YAAY,aAAa,IAAI;AACnC,YAAM,WAAW,YAAY,IAAI;AACjC,iBAAW,WAAW,UAAU;AAC9B,YAAI,GAAG,OAAO,KAAK,UAAU,CAAC,KAAK,KAAK,UAAU,KAAK,QAAS;AAChE,cAAM,YAAY,UAAU,OAAO;AACnC,YAAI,CAAC,GAAG,SAAS,SAAS,EAAG;AAC7B,kBAAU,KAAK,MAAM,IAAI;AACzB,aAAK,UAAU,IAAI;AAAA,MACrB;AAAA,IACF,CAAC;AAKD,eAAW,aAAa,cAAc,IAAI,GAAG;AAC3C,YAAM,cAAc,OAAO,0BAA0B,SAAS;AAE9D,iBAAW,CAAC,KAAK,UAAU,KAAK,OAAO,QAAQ,WAAW,GAAG;AAC3D,YAAI,QAAQ,cAAe;AAC3B,YAAI,KAAK,eAAe,GAAG,EAAG;AAE9B,YAAI,WAAW,OAAO,WAAW,IAAK;AACtC,YAAI,CAAC,GAAG,SAAS,WAAW,KAAK,EAAG;AACpC,cAAM,KAAK,WAAW,MAAM,KAAK,IAAI;AAErC,YAAI,IAAI,SAAS,MAAM,GAAG;AACxB,cAAI,YAAY,KAAK,UAAU,EAAE;AACjC,oBAAU,cAAc,GAAG,KAAK,YAAY,IAAI,IAAI,GAAG;AACvD,sBAAY,MAAM,KAAK,SAAS;AAAA,QAClC,OAAO;AACL,sBAAY,MAAM,KAAK,EAAE;AAAA,QAC3B;AAAA,MACF;AAAA,IACF;AAGA,UAAM,kBAAkB,QAAQ,yBAAyB,KAAK,YAAY,WAAW,OAAO;AAC5F,QAAI,mBAAmB,gBAAgB,KAAK;AAC1C,YAAM,QAAQ,gBAAgB,IAAI,KAAK,IAAI;AAC3C,kBAAY,MAAM,SAAS,KAAK,KAAK,KAAK,WAAW,OAAO,OAAO,CAAC,GAAG,EAAE,MAAM,MAAM,CAAC,CAAC;AAAA,IACzF;AAMA,UAAM,SAAS,QAAQ,IAAI,MAAM,QAAQ;AACzC,QAAI,GAAG,SAAS,MAAM,GAAG;AACvB,YAAM,iBAAiB,mBAAmB,IAAI;AAC9C,UAAI,CAAC,eAAgB,OAAM,KAAK,MAAM;AAEtC,qBAAe,gBAAgB,oBAAoB,MAAM,CAAC,CAAC;AAC3D,qBAAe,kBAAkB,EAAE,KAAK,MAAM,OAAO,CAAC;AAEtD,UAAI,KAAK,kBAAkB,GAAG;AAC5B,aAAK,kBAAkB,EAAE,QAAQ,CAAAA,YAAUA,QAAO,CAAC;AACnD,eAAO,KAAK,kBAAkB;AAAA,MAChC;AAAA,IACF;AAGA,gBAAY,MAAM,YAAY,IAAI;AAAA,EACpC;AAAA;AAAA;AAAA;AAAA,EAKA,CAAC,KAAK,MAAM,MAAM,IAAI;AAEpB,QAAI,KAAK,WAAW,GAAG;AACrB,WAAK,WAAW,EAAE,QAAQ,cAAY,SAAS,CAAC;AAChD,WAAK,WAAW,EAAE,MAAM;AAAA,IAC1B;AAGA,UAAM,SAAS,QAAQ,IAAI,MAAM,QAAQ;AACzC,QAAI,GAAG,SAAS,MAAM,EAAG,QAAO;AAGhC,WAAO,KAAK,MAAM;AAClB,WAAO,KAAK,UAAU;AACtB,WAAO,KAAK,WAAW;AACvB,WAAO,KAAK,WAAW;AAAA,EACzB;AAAA;AAAA;AAAA;AAAA;AAAA,EAMA,IAAI,IAAI;AACN,mBAAe,MAAM,QAAQ,MAAM,SAAS,IAAI,CAAC;AACjD,WAAO,KAAK,MAAM;AAAA,EACpB;AAAA;AAAA;AAAA;AAAA;AAAA,EAMA,QAAwB,UAAkB;AAExC,mBAAe,MAAM,aAAa,MAAM,oBAAI,IAAI,CAAC;AACjD,QAAI,KAAK,WAAW,EAAE,IAAI,QAAQ,EAAG,QAAO,KAAK,WAAW,EAAE,IAAI,QAAQ;AAG1E,QAAI,SAA6B;AACjC,WAAO,QAAQ;AACb,UAAI,kBAAkB,UAAU;AAC9B,aAAK,WAAW,EAAE,IAAI,UAAU,MAAM;AACtC,eAAO;AAAA,MACT;AACA,eAAS,UAAU,MAAM;AAAA,IAC3B;AAEA,WAAO;AAAA,EACT;AAAA;AAAA;AAAA;AAAA;AAAA,EAMA,WAAW,MAAiD;AAC1D,UAAM,WAAW,KAAK,KAAK,KAAK,QAAQ,GAAG,IAAI;AAC/C,mBAAe,MAAM,aAAa,MAAM,oBAAI,IAAI,CAAC;AACjD,SAAK,WAAW,EAAE,IAAI,QAAQ;AAC9B,WAAO;AAAA,EACT;AAAA;AAAA;AAAA;AAAA;AAAA,EAMA,YAAY,MAAkD;AAC5D,UAAM,WAAW,KAAK,KAAK,KAAK,SAAS,GAAG,IAAI;AAChD,mBAAe,MAAM,aAAa,MAAM,oBAAI,IAAI,CAAC;AACjD,SAAK,WAAW,EAAE,IAAI,QAAQ;AAC9B,WAAO;AAAA,EACT;AAAA;AAAA;AAAA;AAAA;AAAA,EAMA,cAAc,MAA0C;AACtD,UAAM,KAAK,KAAK,WAAW,GAAG,IAAI;AAClC,mBAAe,MAAM,aAAa,MAAM,oBAAI,IAAI,CAAC;AACjD,SAAK,WAAW,EAAE,IAAI,MAAM,KAAK,aAAa,EAAE,CAAC;AACjD,WAAO;AAAA,EACT;AAAA;AAAA;AAAA;AAAA;AAAA,EAMA,eAAe,MAA2C;AACxD,UAAM,KAAK,KAAK,YAAY,GAAG,IAAI;AACnC,mBAAe,MAAM,aAAa,MAAM,oBAAI,IAAI,CAAC;AACjD,SAAK,WAAW,EAAE,IAAI,MAAM,KAAK,cAAc,EAAE,CAAC;AAClD,WAAO;AAAA,EACT;AAAA;AAAA;AAAA;AAAA,EAKA,MAAM,UAAU,4BAA4B;AAC1C,UAAM,UAAU,UAAU,KAAK,OAAO,KAAK;AAC3C,UAAM,QAAQ,IAAI,MAAM,IAAI,KAAK,YAAY,IAAI,6BAA6B,OAAO,EAAE;AACvF,UAAM,kBAAkB,OAAO,KAAK,KAAK;AACzC,WAAO;AAAA,EACT;AACF;AASA,SAAS,YAAY,QAAgB,KAAkB,OAAgB;AACrE,UAAQ,eAAe,QAAQ,KAAK;AAAA,IAClC,cAAc;AAAA,IACd,KAAK,MAAM;AAAA,IACX,KAAK,OAAM,QAAQ;AAAA,EACrB,CAAC;AACH;AAKA,SAAS,eACP,QACA,KACA,iBACyC;AACzC,MAAI,OAAO,OAAQ;AACnB,QAAM,QAAQ,gBAAgB;AAC9B,UAAQ,eAAe,QAAQ,KAAK,EAAE,cAAc,MAAM,KAAK,MAAM,MAAM,CAAC;AAC9E;AAKA,SAAS,cAAc,QAA0B;AAC/C,QAAM,YAAY,QAAQ,eAAe,MAAM;AAC/C,MAAI,CAAC,aAAa,cAAc,OAAO,UAAW,QAAO,CAAC;AAC1D,SAAO,CAAC,WAAW,GAAG,cAAc,SAAS,CAAC;AAChD;AAKA,SAAS,SAAY,MAAe;AAClC,MAAI,OAAuB;AAC3B,MAAI,SAAyB;AAE7B,SAAO,QAAQ;AACb,QAAI,kBAAkB,KAAM,QAAO;AACnC,aAAS,UAAU,MAAM;AAAA,EAC3B;AAEA,SAAO;AACT;AAKA,SAAS,mBAAsB,MAAe;AAC5C,MAAI,iBAAiC;AACrC,MAAI,SAAyB;AAE7B,SAAO,QAAQ;AACb,QAAI,kBAAkB,QAAQ,CAAC,OAAO,UAAU,EAAG,kBAAiB;AACpE,aAAS,UAAU,MAAM;AAAA,EAC3B;AAEA,SAAO;AACT;AAKA,SAAS,UAAa,MAAe;AACnC,QAAM,SAAyB,QAAQ,IAAI,MAAM,QAAQ,KAAK,QAAQ,IAAI,MAAM,KAAK,MAAM,MAAM,KAAK;AACtG,SAAO;AACT;AAKA,SAAS,aAAgB,MAAe;AACtC,QAAM,YAAqB,QAAQ,IAAI,KAAK,aAAa,WAAW;AACpE,MAAI,CAAC,GAAG,OAAO,SAAS,EAAG,QAAO,CAAC;AACnC,SAAO;AACT;AAKA,SAAS,YAAe,MAAe;AACrC,QAAM,YAAY,aAAa,IAAI;AACnC,QAAM,cAAc,OAAO,KAAK,SAAS,EAAE,OAAO,SAAO,GAAG,QAAQ,GAAG,CAAC;AACxE,SAAO,YAAY,IAAI,MAAM,EAAE,KAAK,CAAC,IAAI,OAAO,KAAK,EAAE;AACzD;","names":["attach"]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "epos-unit",
3
- "version": "1.13.0",
3
+ "version": "1.15.0",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
6
  "author": "imkost",
@@ -20,7 +20,7 @@
20
20
  "src"
21
21
  ],
22
22
  "dependencies": {
23
- "dropcap": "^1.0.0",
24
- "epos": "^1.30.0"
23
+ "dropcap": "^1.4.0",
24
+ "epos": "^1.32.0"
25
25
  }
26
26
  }
package/src/epos-unit.ts CHANGED
@@ -1,148 +1,210 @@
1
+ import type { Arr, Cls, Obj } from 'dropcap/types'
2
+ import { createLog, is } from 'dropcap/utils'
1
3
  import 'epos'
2
- import type { Cls } from 'dropcap/types'
3
- import { createLog, Log } from 'dropcap/utils'
4
- import type { FC } from 'react'
5
4
 
6
5
  export const _root_ = Symbol('root')
7
6
  export const _parent_ = Symbol('parent')
7
+ export const _attached_ = Symbol('attached')
8
8
  export const _disposers_ = Symbol('disposers')
9
- export type Descriptors = Record<string | symbol, PropertyDescriptor>
9
+ export const _ancestors_ = Symbol('ancestors')
10
+ export const _pendingAttachFns_ = Symbol('pendingAttachFns')
11
+
12
+ export type Node<T> = Unit<T> | Obj | Arr
10
13
 
11
14
  export class Unit<TRoot = unknown> {
12
15
  declare '@': string
13
- declare log: Log;
14
- declare [':version']?: number
15
- declare private [_root_]: TRoot
16
- declare private [_parent_]: Unit<TRoot> | null // Parent reference for not-yet-attached units
17
- declare private [_disposers_]: Set<() => void>
18
-
19
- static get [epos.symbols.stateModelStrict]() {
20
- return true
21
- }
16
+ declare log: ReturnType<typeof createLog>;
17
+ declare [':version']?: number;
18
+ declare [_root_]?: TRoot;
19
+ declare [_parent_]?: Unit<TRoot> | null; // Parent reference for a not-yet-attached units
20
+ declare [_attached_]?: boolean;
21
+ declare [_disposers_]?: Set<() => void>;
22
+ declare [_ancestors_]?: Map<Cls, unknown>;
23
+ declare [_pendingAttachFns_]?: (() => void)[]
22
24
 
23
25
  constructor(parent: Unit<TRoot> | null) {
24
- // Define parent for not-yet-attached units
25
- Reflect.defineProperty(this, _parent_, { get: () => parent })
26
+ this[_parent_] = parent
27
+ const versions = getVersions(this)
28
+ if (versions.length > 0) this[':version'] = versions.at(-1)!
26
29
  }
27
30
 
28
- // ---------------------------------------------------------------------------
29
- // INIT
30
- // ---------------------------------------------------------------------------
31
-
32
- [epos.symbols.stateModelInit]() {
33
- const _this = this as any
34
- const Unit = this.constructor
35
- const descriptors: Descriptors = Object.getOwnPropertyDescriptors(Unit.prototype)
36
- const keys = Reflect.ownKeys(descriptors)
37
-
38
- // Setup disposers container
39
- const disposers = new Set<() => void>()
40
- Reflect.defineProperty(this, _disposers_, { get: () => disposers })
41
-
42
- // Bind all methods
43
- for (const key of keys) {
44
- if (key === 'constructor') continue
45
- const descriptor = descriptors[key]
46
- if (!descriptor) continue
47
- if (descriptor.get || descriptor.set) continue
48
- if (typeof descriptor.value !== 'function') continue
49
- _this[key] = descriptor.value.bind(this)
50
- }
31
+ /**
32
+ * Lifecycle method called when the unit is attached to the state tree.
33
+ */
34
+ [epos.state.ATTACH]() {
35
+ // Setup logger
36
+ setProperty(this, 'log', createLog(this['@']))
51
37
 
52
- // Create components out of `View` methods
53
- for (const key of keys) {
54
- if (typeof key === 'symbol') continue
55
- if (!key.endsWith('View')) continue
56
- const descriptor = descriptors[key]
57
- if (!descriptor) continue
58
- if (descriptor.get || descriptor.set) continue
59
- if (typeof _this[key] !== 'function') continue
60
- _this[key] = epos.component(_this[key] as FC)
61
- _this[key].displayName = `${this['@']}.${key}`
62
- }
38
+ // Apply versioner
39
+ epos.state.transaction(() => {
40
+ const versioner = getVersioner(this)
41
+ const versions = getVersions(this)
42
+ for (const version of versions) {
43
+ if (is.number(this[':version']) && this[':version'] >= version) continue
44
+ const versionFn = versioner[version]
45
+ if (!is.function(versionFn)) continue
46
+ versionFn.call(this, this)
47
+ this[':version'] = version
48
+ }
49
+ })
63
50
 
64
- // Define log method
65
- const log = createLog(this['@'])
66
- Reflect.defineProperty(this, 'log', { get: () => log })
51
+ // Prepare methods:
52
+ // - Create components for methods ending with `View`
53
+ // - Bind all other methods to the unit instance
54
+ for (const prototype of getPrototypes(this)) {
55
+ const descriptors = Object.getOwnPropertyDescriptors(prototype)
67
56
 
68
- // Call init method
69
- if (typeof _this.init === 'function') _this.init()
70
- }
57
+ for (const [key, descriptor] of Object.entries(descriptors)) {
58
+ if (key === 'constructor') continue
59
+ if (this.hasOwnProperty(key)) continue
71
60
 
72
- // ---------------------------------------------------------------------------
73
- // DISPOSE
74
- // ---------------------------------------------------------------------------
61
+ if (descriptor.get || descriptor.set) continue
62
+ if (!is.function(descriptor.value)) continue
63
+ const fn = descriptor.value.bind(this)
75
64
 
76
- [epos.symbols.stateModelDispose]() {
77
- const _this = this as any
65
+ if (key.endsWith('View')) {
66
+ let Component = epos.component(fn)
67
+ Component.displayName = `${this.constructor.name}.${key}`
68
+ setProperty(this, key, Component)
69
+ } else {
70
+ setProperty(this, key, fn)
71
+ }
72
+ }
73
+ }
78
74
 
79
- // Call disposers
80
- this[_disposers_].forEach(disposer => disposer())
81
- this[_disposers_].clear()
75
+ // Setup state
76
+ const stateDescriptor = Reflect.getOwnPropertyDescriptor(this.constructor.prototype, 'state')
77
+ if (stateDescriptor && stateDescriptor.get) {
78
+ const state = stateDescriptor.get.call(this)
79
+ setProperty(this, 'state', epos.libs.mobx.observable.object(state, {}, { deep: false }))
80
+ }
82
81
 
83
- // Call dispose method
84
- if (typeof _this.dispose === 'function') _this.dispose()
85
- }
82
+ // Process attach queue.
83
+ // We do not execute `attach` methods immediately, but rather queue them
84
+ // on the highest unattached ancestor. This way we ensure that `attach`
85
+ // methods are called after all versioners have been applied in the entire subtree.
86
+ const attach = Reflect.get(this, 'attach')
87
+ if (is.function(attach)) {
88
+ const unattachedRoot = findUnattachedRoot(this)
89
+ if (!unattachedRoot) throw this.never()
86
90
 
87
- // ---------------------------------------------------------------------------
88
- // VERSIONER
89
- // ---------------------------------------------------------------------------
91
+ ensureProperty(unattachedRoot, _pendingAttachFns_, () => [])
92
+ unattachedRoot[_pendingAttachFns_].push(() => attach())
90
93
 
91
- static get [epos.symbols.stateModelVersioner]() {
92
- if (!('versioner' in this)) return null
93
- return this.versioner
94
+ if (this[_pendingAttachFns_]) {
95
+ this[_pendingAttachFns_].forEach(attach => attach())
96
+ delete this[_pendingAttachFns_]
97
+ }
98
+ }
99
+
100
+ // Mark as attached
101
+ setProperty(this, _attached_, true)
94
102
  }
95
103
 
96
- // ---------------------------------------------------------------------------
97
- // ROOT
98
- // ---------------------------------------------------------------------------
104
+ /**
105
+ * Lifecycle method called when the unit is detached from the state tree.
106
+ */
107
+ [epos.state.DETACH]() {
108
+ // 1. Run disposers
109
+ if (this[_disposers_]) {
110
+ this[_disposers_].forEach(disposer => disposer())
111
+ this[_disposers_].clear()
112
+ }
113
+
114
+ // 2. Call detach method
115
+ const detach = Reflect.get(this, 'detach')
116
+ if (is.function(detach)) detach()
99
117
 
118
+ // 3. Clean up internal properties
119
+ delete this[_root_]
120
+ delete this[_attached_]
121
+ delete this[_ancestors_]
122
+ delete this[_disposers_]
123
+ }
124
+
125
+ /**
126
+ * Gets the root unit of the current unit's tree.
127
+ * The result is cached for subsequent calls.
128
+ */
100
129
  get $() {
101
- this[_root_] ??= findRoot(this) as TRoot
130
+ ensureProperty(this, _root_, () => findRoot(this))
102
131
  return this[_root_]
103
132
  }
104
133
 
105
- // ---------------------------------------------------------------------------
106
- // METHODS
107
- // ---------------------------------------------------------------------------
134
+ /**
135
+ * Finds the closest ancestor unit of a given type.
136
+ * The result is cached for subsequent calls.
137
+ */
138
+ closest<T extends Unit>(Ancestor: Cls<T>) {
139
+ // Has cached value? -> Return it
140
+ ensureProperty(this, _ancestors_, () => new Map())
141
+ if (this[_ancestors_].has(Ancestor)) return this[_ancestors_].get(Ancestor) as T
108
142
 
109
- closest<T extends Unit>(Ancestor: Cls<T>): T | null {
110
- let cursor: unknown = getParent(this)
143
+ // Find the closest ancestor and cache it
144
+ let cursor: Node<TRoot> | null = this
111
145
  while (cursor) {
112
- if (cursor instanceof Ancestor) return cursor
146
+ if (cursor instanceof Ancestor) {
147
+ this[_ancestors_].set(Ancestor, cursor)
148
+ return cursor
149
+ }
113
150
  cursor = getParent(cursor)
114
151
  }
152
+
115
153
  return null
116
154
  }
117
155
 
156
+ /**
157
+ * A wrapper around MobX's `autorun` that automatically disposes
158
+ * the reaction when the unit is detached.
159
+ */
118
160
  autorun(...args: Parameters<typeof epos.libs.mobx.autorun>) {
119
161
  const disposer = epos.libs.mobx.autorun(...args)
162
+ ensureProperty(this, _disposers_, () => new Set())
120
163
  this[_disposers_].add(disposer)
121
164
  return disposer
122
165
  }
123
166
 
167
+ /**
168
+ * A wrapper around MobX's `reaction` that automatically disposes
169
+ * the reaction when the unit is detached.
170
+ */
124
171
  reaction(...args: Parameters<typeof epos.libs.mobx.reaction>) {
125
172
  const disposer = epos.libs.mobx.reaction(...args)
173
+ ensureProperty(this, _disposers_, () => new Set())
126
174
  this[_disposers_].add(disposer)
127
175
  return disposer
128
176
  }
129
177
 
178
+ /**
179
+ * A wrapper around `setTimeout` that automatically clears the timeout
180
+ * when the unit is detached.
181
+ */
130
182
  setTimeout(...args: Parameters<typeof self.setTimeout>) {
131
183
  const id = self.setTimeout(...args)
184
+ ensureProperty(this, _disposers_, () => new Set())
132
185
  this[_disposers_].add(() => self.clearTimeout(id))
133
186
  return id
134
187
  }
135
188
 
189
+ /**
190
+ * A wrapper around `setInterval` that automatically clears the interval
191
+ * when the unit is detached.
192
+ */
136
193
  setInterval(...args: Parameters<typeof self.setInterval>) {
137
194
  const id = self.setInterval(...args)
195
+ ensureProperty(this, _disposers_, () => new Set())
138
196
  this[_disposers_].add(() => self.clearInterval(id))
139
197
  return id
140
198
  }
141
199
 
200
+ /**
201
+ * Creates an error for an unreachable code path.
202
+ */
142
203
  never(message = 'This should never happen') {
143
- const error = new Error(`[${this['@']}] ${message}`)
204
+ const details = message ? `: ${message}` : ''
205
+ const error = new Error(`[${this.constructor.name}] This should never happen${details}`)
144
206
  Error.captureStackTrace(error, this.never)
145
- throw error
207
+ return error
146
208
  }
147
209
  }
148
210
 
@@ -150,18 +212,91 @@ export class Unit<TRoot = unknown> {
150
212
  // HELPERS
151
213
  // ---------------------------------------------------------------------------
152
214
 
153
- function getParent(child: any) {
154
- return child[_parent_] ?? child[epos.symbols.stateParent]
215
+ /**
216
+ * Defines a configurable property on an object.
217
+ */
218
+ function setProperty(object: object, key: PropertyKey, value: unknown) {
219
+ Reflect.defineProperty(object, key, {
220
+ configurable: true,
221
+ get: () => value,
222
+ set: v => (value = v),
223
+ })
224
+ }
225
+
226
+ /**
227
+ * Ensures a property exists on an object, initializing it if it doesn't.
228
+ */
229
+ function ensureProperty<T extends object, K extends PropertyKey, V>(
230
+ object: T,
231
+ key: K,
232
+ getInitialValue: () => V,
233
+ ): asserts object is T & { [key in K]: V } {
234
+ if (key in object) return
235
+ const value = getInitialValue()
236
+ Reflect.defineProperty(object, key, { configurable: true, get: () => value })
237
+ }
238
+
239
+ /**
240
+ * Gets all prototypes of an object up to `Object.prototype`.
241
+ */
242
+ function getPrototypes(object: object): object[] {
243
+ const prototype = Reflect.getPrototypeOf(object)
244
+ if (!prototype || prototype === Object.prototype) return []
245
+ return [prototype, ...getPrototypes(prototype)]
155
246
  }
156
247
 
157
- function findRoot(unit: Unit) {
158
- let root = unit
159
- let cursor: any = unit
248
+ /**
249
+ * Finds the root `Unit` in the hierarchy for a given unit.
250
+ */
251
+ function findRoot<T>(unit: Unit<T>) {
252
+ let root: Unit<T> | null = null
253
+ let cursor: Node<T> | null = unit
160
254
 
161
255
  while (cursor) {
162
256
  if (cursor instanceof Unit) root = cursor
163
257
  cursor = getParent(cursor)
164
258
  }
165
259
 
166
- return root
260
+ return root as T
261
+ }
262
+
263
+ /**
264
+ * Finds the highest unattached `Unit` in the hierarchy for a given unit.
265
+ */
266
+ function findUnattachedRoot<T>(unit: Unit<T>) {
267
+ let unattachedRoot: Unit<T> | null = null
268
+ let cursor: Node<T> | null = unit
269
+
270
+ while (cursor) {
271
+ if (cursor instanceof Unit && !cursor[_attached_]) unattachedRoot = cursor
272
+ cursor = getParent(cursor)
273
+ }
274
+
275
+ return unattachedRoot
276
+ }
277
+
278
+ /**
279
+ * Gets the parent of a node, which can be a `Unit`, an object, or an array.
280
+ */
281
+ function getParent<T>(node: Node<T>) {
282
+ const parent: Node<T> | null = Reflect.get(node, _parent_) ?? Reflect.get(node, epos.state.PARENT) ?? null
283
+ return parent
284
+ }
285
+
286
+ /**
287
+ * Gets the versioner object from a unit's constructor.
288
+ */
289
+ function getVersioner<T>(unit: Unit<T>) {
290
+ const versioner: unknown = Reflect.get(unit.constructor, 'versioner')
291
+ if (!is.object(versioner)) return {}
292
+ return versioner
293
+ }
294
+
295
+ /**
296
+ * Gets a sorted list of numeric version keys from a unit's versioner.
297
+ */
298
+ function getVersions<T>(unit: Unit<T>) {
299
+ const versioner = getVersioner(unit)
300
+ const numericKeys = Object.keys(versioner).filter(key => is.numeric(key))
301
+ return numericKeys.map(Number).sort((v1, v2) => v1 - v2)
167
302
  }