@contember/bindx-react 0.1.5 → 0.1.11

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.
@@ -34,106 +34,66 @@ export interface UseEntityListOptions {
34
34
  // Result types
35
35
  // ============================================================================
36
36
 
37
- /**
38
- * Loading state for entity list accessor
39
- */
40
- export interface LoadingEntityListAccessor {
41
- readonly status: 'loading'
42
- readonly isLoading: true
43
- readonly isError: false
44
- readonly isDirty: false
45
- readonly items: never
46
- readonly length: 0
47
- add(data?: unknown): string
48
- remove(key: string): void
49
- move(fromIndex: number, toIndex: number): void
37
+ interface EntityListResultBase {
38
+ $add(data?: unknown): string
39
+ $remove(key: string): void
40
+ $move(fromIndex: number, toIndex: number): void
50
41
  }
51
42
 
52
- /**
53
- * Error state for entity list accessor
54
- */
55
- export interface ErrorEntityListAccessor {
56
- readonly status: 'error'
57
- readonly isLoading: false
58
- readonly isError: true
59
- readonly error: FieldError
60
- readonly isDirty: false
61
- readonly items: never
62
- readonly length: 0
63
- add(data?: unknown): string
64
- remove(key: string): void
65
- move(fromIndex: number, toIndex: number): void
43
+ export type LoadingEntityListResult = EntityListResultBase & {
44
+ readonly $status: 'loading'
45
+ readonly $isLoading: true
46
+ readonly $isError: false
47
+ readonly $error: null
66
48
  }
67
49
 
68
- /**
69
- * Ready state for entity list accessor
70
- */
71
- export interface ReadyEntityListAccessor<T extends object> {
72
- readonly status: 'ready'
73
- readonly isLoading: false
74
- readonly isError: false
75
- readonly isDirty: boolean
50
+ export type ErrorEntityListResult = EntityListResultBase & {
51
+ readonly $status: 'error'
52
+ readonly $isLoading: false
53
+ readonly $isError: true
54
+ readonly $error: FieldError
55
+ }
56
+
57
+ export type ReadyEntityListResult<T extends object> = EntityListResultBase & {
58
+ readonly $status: 'ready'
59
+ readonly $isLoading: false
60
+ readonly $isError: false
61
+ readonly $error: null
62
+ readonly $isDirty: boolean
76
63
  readonly items: Array<EntityAccessor<T>>
77
64
  readonly length: number
78
- add(data?: Partial<T>): string
79
- remove(key: string): void
80
- move(fromIndex: number, toIndex: number): void
81
65
  }
82
66
 
83
- /**
84
- * Union of all entity list accessor states
85
- */
86
- export type EntityListAccessorResult<T extends object> =
87
- | LoadingEntityListAccessor
88
- | ErrorEntityListAccessor
89
- | ReadyEntityListAccessor<T>
67
+ export type UseEntityListResult<T extends object> =
68
+ | LoadingEntityListResult
69
+ | ErrorEntityListResult
70
+ | ReadyEntityListResult<T>
90
71
 
91
72
  // ============================================================================
92
73
  // Internal helpers
93
74
  // ============================================================================
94
75
 
95
- function createLoadingListAccessor(): LoadingEntityListAccessor {
76
+ function createLoadingListResult(): LoadingEntityListResult {
96
77
  return {
97
- status: 'loading',
98
- isLoading: true,
99
- isError: false,
100
- isDirty: false,
101
- get items(): never {
102
- throw new Error('Cannot access items while loading')
103
- },
104
- length: 0,
105
- add() {
106
- throw new Error('Cannot add items while loading')
107
- },
108
- remove() {
109
- throw new Error('Cannot remove items while loading')
110
- },
111
- move() {
112
- throw new Error('Cannot move items while loading')
113
- },
78
+ $status: 'loading',
79
+ $isLoading: true,
80
+ $isError: false,
81
+ $error: null,
82
+ $add() { throw new Error('Cannot add items while loading') },
83
+ $remove() { throw new Error('Cannot remove items while loading') },
84
+ $move() { throw new Error('Cannot move items while loading') },
114
85
  }
115
86
  }
116
87
 
117
- function createErrorListAccessor(error: FieldError): ErrorEntityListAccessor {
88
+ function createErrorListResult(error: FieldError): ErrorEntityListResult {
118
89
  return {
119
- status: 'error',
120
- isLoading: false,
121
- isError: true,
122
- error,
123
- isDirty: false,
124
- get items(): never {
125
- throw new Error('Cannot access items after error')
126
- },
127
- length: 0,
128
- add() {
129
- throw new Error('Cannot add items after error')
130
- },
131
- remove() {
132
- throw new Error('Cannot remove items after error')
133
- },
134
- move() {
135
- throw new Error('Cannot move items after error')
136
- },
90
+ $status: 'error',
91
+ $isLoading: false,
92
+ $isError: true,
93
+ $error: error,
94
+ $add() { throw new Error('Cannot add items after error') },
95
+ $remove() { throw new Error('Cannot remove items after error') },
96
+ $move() { throw new Error('Cannot move items after error') },
137
97
  }
138
98
  }
139
99
 
@@ -157,7 +117,7 @@ export function useEntityList<
157
117
  entity: EntityDef<TRoleMap>,
158
118
  options: UseEntityListOptions & { roles: readonly TRoles[] },
159
119
  definer: SelectionInput<EntityForRoles<TRoleMap, TRoles>, TResult>,
160
- ): EntityListAccessorResult<TResult>
120
+ ): UseEntityListResult<TResult>
161
121
 
162
122
  /**
163
123
  * Hook to fetch and manage a list of entities with full type inference.
@@ -174,7 +134,7 @@ export function useEntityList<TRoleMap extends Record<string, object>, TResult e
174
134
  entity: EntityDef<TRoleMap>,
175
135
  options: UseEntityListOptions,
176
136
  definer: SelectionInput<CommonEntity<TRoleMap>, TResult>,
177
- ): EntityListAccessorResult<TResult>
137
+ ): UseEntityListResult<TResult>
178
138
 
179
139
  /**
180
140
  * Hook to fetch and manage a list of entities with pre-resolved selection.
@@ -185,7 +145,7 @@ export function useEntityList<TRoleMap extends Record<string, object>, TResult e
185
145
  export function useEntityList(
186
146
  entity: EntityDef,
187
147
  options: UseEntityListOptions & { selection: SelectionMeta },
188
- ): EntityListAccessorResult<object>
148
+ ): UseEntityListResult<object>
189
149
 
190
150
  // ============================================================================
191
151
  // Implementation
@@ -197,7 +157,7 @@ export function useEntityList(
197
157
  options: UseEntityListOptions,
198
158
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
199
159
  definer?: SelectionInput<any, any>,
200
- ): EntityListAccessorResult<any> {
160
+ ): UseEntityListResult<any> {
201
161
  const schemaRegistry = useSchemaRegistry()
202
162
  const entityType = entity.$name
203
163
  const { store, dispatcher, batcher } = useBindxContext()
@@ -250,7 +210,7 @@ export function useEntityList(
250
210
  version: number
251
211
  storeVersion: number
252
212
  status: string
253
- result: EntityListAccessorResult<object>
213
+ result: UseEntityListResult<any>
254
214
  } | null>(null)
255
215
 
256
216
  // --- Store subscription ---
@@ -310,7 +270,7 @@ export function useEntityList(
310
270
  )
311
271
 
312
272
  // --- Snapshot ---
313
- const getSnapshot = useCallback((): EntityListAccessorResult<object> => {
273
+ const getSnapshot = useCallback((): UseEntityListResult<any> => {
314
274
  const state = listStateRef.current
315
275
  const version = versionRef.current
316
276
  const storeVersion = store.getVersion()
@@ -320,12 +280,12 @@ export function useEntityList(
320
280
  return cache.result
321
281
  }
322
282
 
323
- let result: EntityListAccessorResult<object>
283
+ let result: UseEntityListResult<any>
324
284
 
325
285
  if (state.status === 'loading') {
326
- result = createLoadingListAccessor()
286
+ result = createLoadingListResult()
327
287
  } else if (state.status === 'error') {
328
- result = createErrorListAccessor(state.error!)
288
+ result = createErrorListResult(state.error!)
329
289
  } else {
330
290
  const items = state.items.map((item) => {
331
291
  return EntityHandle.create<object>(
@@ -334,19 +294,20 @@ export function useEntityList(
334
294
  store,
335
295
  dispatcher,
336
296
  schemaRegistry as SchemaRegistry<Record<string, object>>,
337
- ) as unknown as EntityAccessor<object>
297
+ ) as unknown as EntityAccessor<any>
338
298
  })
339
299
 
340
300
  result = {
341
- status: 'ready',
342
- isLoading: false,
343
- isError: false,
344
- isDirty: false,
301
+ $status: 'ready',
302
+ $isLoading: false,
303
+ $isError: false,
304
+ $error: null,
305
+ $isDirty: false,
345
306
  items,
346
307
  length: items.length,
347
- add: addItem,
348
- remove: removeItem,
349
- move: moveItem,
308
+ $add: addItem,
309
+ $remove: removeItem,
310
+ $move: moveItem,
350
311
  }
351
312
  }
352
313
 
@@ -361,7 +322,7 @@ export function useEntityList(
361
322
  }, [entityType, store, dispatcher, schemaRegistry, addItem, removeItem, moveItem])
362
323
 
363
324
  const isEqual = useCallback(
364
- (a: EntityListAccessorResult<object>, b: EntityListAccessorResult<object>): boolean => {
325
+ (a: UseEntityListResult<any>, b: UseEntityListResult<any>): boolean => {
365
326
  return a === b
366
327
  },
367
328
  [],
package/src/index.ts CHANGED
@@ -184,17 +184,18 @@ export {
184
184
  export type {
185
185
  BindxProviderProps,
186
186
  BindxContextValue,
187
+ BindxGraphQlClient,
187
188
  UseEntityOptions,
188
189
  UseEntityListOptions,
189
- LoadingEntityAccessor,
190
- ErrorEntityAccessor,
191
- NotFoundEntityAccessor,
192
- ReadyEntityAccessor,
193
- EntityAccessorResult,
194
- LoadingEntityListAccessor,
195
- ErrorEntityListAccessor,
196
- ReadyEntityListAccessor,
197
- EntityListAccessorResult,
190
+ LoadingEntityResult,
191
+ ErrorEntityResult,
192
+ NotFoundEntityResult,
193
+ ReadyEntityResult,
194
+ UseEntityResult,
195
+ LoadingEntityListResult,
196
+ ErrorEntityListResult,
197
+ ReadyEntityListResult,
198
+ UseEntityListResult,
198
199
  // Persistence hook types
199
200
  PersistApi,
200
201
  EntityPersistApi,
@@ -316,6 +317,7 @@ export {
316
317
  HasRole,
317
318
  type HasRoleProps,
318
319
  BINDX_COMPONENT,
320
+ SCOPE_REF,
319
321
  // Condition DSL for <If> component
320
322
  cond,
321
323
  type Condition,
@@ -47,6 +47,7 @@ export function createCollectorProxy<T>(
47
47
  $fields: fieldsProxy,
48
48
  $data: null,
49
49
  $isDirty: false,
50
+ $isPersisting: false,
50
51
  $persistedId: null,
51
52
  $isNew: true,
52
53
  __entityType: undefined as unknown as T,
@@ -189,7 +190,7 @@ function createCollectorFieldRef(
189
190
  items: [],
190
191
  map: mapFn,
191
192
  add: () => '',
192
- getById: () => ({} as any),
193
+ getById: () => createCollectorProxy<unknown>(getChildScope(), targetEntityName, schemaRegistry),
193
194
  remove: () => {},
194
195
  move: () => {},
195
196
  connect: () => {},
@@ -226,6 +227,7 @@ function createCollectorFieldRef(
226
227
  // EntityRef-compatible properties (for HasOneAccessor = EntityAccessor compatibility)
227
228
  id: placeholderId,
228
229
  $data: null,
230
+ $isPersisting: false,
229
231
  $isNew: false,
230
232
  $persistedId: null,
231
233
  __entityName: targetEntityName ?? '',
@@ -248,20 +250,27 @@ function createCollectorFieldRef(
248
250
  // This works for all field types:
249
251
  // - Scalar fields: Accessing ref.value works (exists on target, passes through)
250
252
  // - Has-many fields: Accessing ref.items/length works (exists on target, passes through)
251
- // - Has-one with schema: Direct field access proxies to $fields
253
+ // - Has-one with schema: Direct field access proxies to $fields (isHasOneRelation=true)
252
254
  // - Has-one without schema: Same - direct field access proxies to $fields
253
255
  // The hasOneFieldsProxy creates nested collector refs which properly track selection.
254
- return wrapCollectorRefWithFieldAccessProxy(refObject, hasOneFieldsProxy)
256
+ return wrapCollectorRefWithFieldAccessProxy(refObject, hasOneFieldsProxy, isHasOneRelation)
255
257
  }
256
258
 
257
259
  /**
258
260
  * Wraps a collector ref in a Proxy that supports direct field access for hasOne relations.
259
261
  * - `ref.fieldName` is equivalent to `ref.$fields.fieldName`
260
262
  * - Known ref properties pass through to the target
263
+ *
264
+ * When `isHasOneRelation` is true, matches runtime EntityHandle proxy behavior:
265
+ * only `id`, `$`-prefixed, and `__`-prefixed properties pass through;
266
+ * everything else is delegated to `fieldsProxy` as field access on the related entity.
267
+ * This prevents built-in ref properties (like `items`, `value`, `map`) from
268
+ * shadowing entity field names on the has-one target.
261
269
  */
262
270
  function wrapCollectorRefWithFieldAccessProxy(
263
271
  ref: CollectorRef,
264
272
  fieldsProxy: EntityFields<unknown>,
273
+ isHasOneRelation: boolean,
265
274
  ): CollectorRef {
266
275
  return new Proxy(ref, {
267
276
  get(target, prop) {
@@ -270,7 +279,17 @@ function wrapCollectorRefWithFieldAccessProxy(
270
279
  return Reflect.get(target, prop)
271
280
  }
272
281
 
273
- // Check if property exists on target - if so, pass through
282
+ if (isHasOneRelation) {
283
+ // For has-one relations, match runtime EntityHandle proxy behavior:
284
+ // Only pass through id, $-prefixed, and __-prefixed properties.
285
+ // Everything else is field access on the related entity.
286
+ if (prop === 'id' || prop.startsWith('$') || prop.startsWith('__')) {
287
+ return Reflect.get(target, prop)
288
+ }
289
+ return fieldsProxy[prop as keyof EntityFields<unknown>]
290
+ }
291
+
292
+ // For has-many/scalar/unknown: existing behavior
274
293
  if (prop in target) {
275
294
  return Reflect.get(target, prop)
276
295
  }
@@ -106,18 +106,19 @@ function EntityByMode({
106
106
  })
107
107
 
108
108
  // Render based on status
109
- if (result.status === 'loading') {
109
+ if (result.$status === 'loading') {
110
110
  return <>{loading ?? <DefaultLoading />}</>
111
111
  }
112
112
 
113
- if (result.status === 'error') {
113
+ if (result.$status === 'error') {
114
+ const error: FieldError = result.$error ?? { source: 'load', message: 'Unknown error', category: 'unknown', retryable: false }
114
115
  if (errorFallback) {
115
- return <>{errorFallback(result.error)}</>
116
+ return <>{errorFallback(error)}</>
116
117
  }
117
- return <DefaultError error={result.error} />
118
+ return <DefaultError error={error} />
118
119
  }
119
120
 
120
- if (result.status === 'not_found') {
121
+ if (result.$status === 'not_found') {
121
122
  return <>{notFound ?? <DefaultNotFound entityType={entityType} by={by} />}</>
122
123
  }
123
124
 
@@ -271,14 +272,19 @@ function EntityHandleRenderer({
271
272
 
272
273
  const version = useSyncExternalStore(subscribe, getSnapshot, getSnapshot)
273
274
 
274
- const handle = useMemo(
275
- () => EntityHandle.create(entityId, entityType, store, dispatcher, schemaRegistry, undefined, selection),
275
+ const rawHandle = useMemo(
276
+ () => EntityHandle.createRaw(entityId, entityType, store, dispatcher, schemaRegistry, undefined, selection),
276
277
  [entityId, entityType, store, dispatcher, schemaRegistry, selection, version],
277
278
  )
278
279
 
279
- useEffect(() => () => { handle.dispose() }, [handle])
280
+ const handle = useMemo(
281
+ () => EntityHandle.wrapProxy(rawHandle),
282
+ [rawHandle],
283
+ )
284
+
285
+ useEffect(() => () => { rawHandle.dispose() }, [rawHandle])
280
286
 
281
- return <>{children(handle as unknown as EntityAccessor<unknown>)}</>
287
+ return <>{children(handle as EntityAccessor<unknown>)}</>
282
288
  }
283
289
 
284
290
  // ==================== Main Entity Component ====================
@@ -86,15 +86,15 @@ function EntityListComponent<TRoleMap extends Record<string, object>>({
86
86
  })
87
87
 
88
88
  // Render based on status
89
- if (result.status === 'loading') {
89
+ if (result.$status === 'loading') {
90
90
  return <>{loading ?? <DefaultLoading />}</>
91
91
  }
92
92
 
93
- if (result.status === 'error') {
93
+ if (result.$status === 'error') {
94
94
  if (errorFallback) {
95
- return <>{errorFallback(result.error)}</>
95
+ return <>{errorFallback(result.$error)}</>
96
96
  }
97
- return <DefaultError error={result.error} />
97
+ return <DefaultError error={result.$error} />
98
98
  }
99
99
 
100
100
  // Empty state
@@ -103,7 +103,7 @@ function EntityListComponent<TRoleMap extends Record<string, object>>({
103
103
  }
104
104
 
105
105
  // Phase 3: Runtime render — items are already EntityAccessors from the hook
106
- const items = result.items.map((item, index) => {
106
+ const items = result.items.map((item: (typeof result.items)[number], index: number) => {
107
107
  return (
108
108
  <React.Fragment key={item.id}>
109
109
  {children(item as unknown as EntityAccessor<CommonEntity<TRoleMap>>, index)}
package/src/jsx/index.ts CHANGED
@@ -2,6 +2,7 @@
2
2
  export {
3
3
  BINDX_COMPONENT,
4
4
  FIELD_REF_META,
5
+ SCOPE_REF,
5
6
  type FieldRefMeta,
6
7
  // Full types
7
8
  type FieldRef,