@contember/bindx 0.1.51 → 0.1.53

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.
Files changed (54) hide show
  1. package/dist/core/ActionDispatcher.d.ts.map +1 -1
  2. package/dist/core/EntityLoader.d.ts +7 -0
  3. package/dist/core/EntityLoader.d.ts.map +1 -1
  4. package/dist/core/actions.d.ts +1 -2
  5. package/dist/core/actions.d.ts.map +1 -1
  6. package/dist/dataview/types.d.ts +6 -6
  7. package/dist/dataview/types.d.ts.map +1 -1
  8. package/dist/handles/EntityHandle.d.ts.map +1 -1
  9. package/dist/handles/HasManyListHandle.d.ts +8 -0
  10. package/dist/handles/HasManyListHandle.d.ts.map +1 -1
  11. package/dist/handles/HasOneHandle.d.ts.map +1 -1
  12. package/dist/index.d.ts +1 -1
  13. package/dist/index.d.ts.map +1 -1
  14. package/dist/persistence/BatchPersister.d.ts.map +1 -1
  15. package/dist/store/HasManyStore.d.ts +94 -78
  16. package/dist/store/HasManyStore.d.ts.map +1 -1
  17. package/dist/store/RelationStore.d.ts +21 -12
  18. package/dist/store/RelationStore.d.ts.map +1 -1
  19. package/dist/store/ResponseOccurrenceIndex.d.ts +65 -0
  20. package/dist/store/ResponseOccurrenceIndex.d.ts.map +1 -0
  21. package/dist/store/SnapshotStore.d.ts +53 -15
  22. package/dist/store/SnapshotStore.d.ts.map +1 -1
  23. package/dist/store/SubscriptionManager.d.ts +19 -4
  24. package/dist/store/SubscriptionManager.d.ts.map +1 -1
  25. package/dist/store/hasManyState.d.ts +164 -0
  26. package/dist/store/hasManyState.d.ts.map +1 -0
  27. package/dist/store/relationKey.d.ts +11 -0
  28. package/dist/store/relationKey.d.ts.map +1 -1
  29. package/dist/undo/UndoJournal.d.ts.map +1 -1
  30. package/dist/undo/rekeyJournalEntry.d.ts +4 -4
  31. package/dist/undo/rekeyJournalEntry.d.ts.map +1 -1
  32. package/dist/utils/aliasGenerator.d.ts +1 -1
  33. package/dist/utils/aliasGenerator.d.ts.map +1 -1
  34. package/package.json +2 -2
  35. package/src/core/ActionDispatcher.ts +0 -1
  36. package/src/core/EntityLoader.ts +13 -6
  37. package/src/core/actions.ts +1 -3
  38. package/src/dataview/types.ts +6 -6
  39. package/src/handles/EntityHandle.ts +11 -0
  40. package/src/handles/HasManyListHandle.ts +15 -17
  41. package/src/handles/HasOneHandle.ts +4 -2
  42. package/src/index.ts +6 -0
  43. package/src/persistence/BatchPersister.ts +15 -11
  44. package/src/store/HasManyStore.ts +312 -488
  45. package/src/store/RelationStore.ts +60 -24
  46. package/src/store/ResponseOccurrenceIndex.ts +224 -0
  47. package/src/store/SnapshotStore.ts +183 -109
  48. package/src/store/SubscriptionManager.ts +116 -58
  49. package/src/store/hasManyState.ts +408 -0
  50. package/src/store/relationKey.ts +17 -0
  51. package/src/undo/UndoJournal.ts +3 -1
  52. package/src/undo/UndoManager.ts +1 -1
  53. package/src/undo/rekeyJournalEntry.ts +31 -43
  54. package/src/utils/aliasGenerator.ts +1 -1
@@ -360,7 +360,6 @@ export class ActionDispatcher {
360
360
  action.fieldName,
361
361
  action.itemKey,
362
362
  action.removalType,
363
- action.alias,
364
363
  )
365
364
  break
366
365
 
@@ -60,6 +60,13 @@ export interface LoadEntityListOptions<TEntity = unknown> {
60
60
  /**
61
61
  * Non-React service for loading entities.
62
62
  * Can be used in any JavaScript environment.
63
+ *
64
+ * It writes each response as it arrives, without uniting the occurrences of one
65
+ * entity within it (see `SnapshotStore.indexServerResponse`): that needs the
66
+ * selection and the schema, and the loader has only a `QuerySpec`. A query that
67
+ * reaches one entity through two paths with different sub-selections can
68
+ * therefore lose the wider one's fields, as described in
69
+ * https://github.com/contember/bindx/issues/123. The React hooks do not go through it.
63
70
  */
64
71
  export class EntityLoader {
65
72
  constructor(
@@ -124,13 +131,13 @@ export class EntityLoader {
124
131
  }
125
132
  }
126
133
 
127
- // Store each entity in snapshot store
128
- for (const item of result.data) {
129
- const record = item as Record<string, unknown>
130
- if (typeof record['id'] === 'string') {
131
- this.store.setEntityData(entityType, record['id'], record, true)
134
+ this.store.batchNotifications(() => {
135
+ for (const record of result.data) {
136
+ if (typeof record['id'] === 'string') {
137
+ this.store.setEntityData(entityType, record['id'], record, true)
138
+ }
132
139
  }
133
- }
140
+ })
134
141
 
135
142
  return { status: 'success', data: result.data as T[] }
136
143
  } catch (error) {
@@ -165,7 +165,6 @@ export interface RemoveFromListAction {
165
165
  readonly fieldName: string
166
166
  readonly itemKey: string
167
167
  readonly removalType: 'disconnect' | 'delete'
168
- readonly alias?: string
169
168
  }
170
169
 
171
170
  /**
@@ -581,9 +580,8 @@ export function removeFromList(
581
580
  fieldName: string,
582
581
  itemKey: string,
583
582
  removalType: 'disconnect' | 'delete',
584
- alias?: string,
585
583
  ): RemoveFromListAction {
586
- return { type: 'REMOVE_FROM_LIST', entityType, entityId, fieldName, itemKey, removalType, alias }
584
+ return { type: 'REMOVE_FROM_LIST', entityType, entityId, fieldName, itemKey, removalType }
587
585
  }
588
586
 
589
587
  /**
@@ -60,9 +60,9 @@ export interface BooleanFilterArtifact {
60
60
  /**
61
61
  * Enum filter artifact
62
62
  */
63
- export interface EnumFilterArtifact {
64
- readonly values?: readonly string[]
65
- readonly notValues?: readonly string[]
63
+ export interface EnumFilterArtifact<TValue extends string = string> {
64
+ readonly values?: readonly TValue[]
65
+ readonly notValues?: readonly TValue[]
66
66
  readonly nullCondition?: boolean
67
67
  }
68
68
 
@@ -78,9 +78,9 @@ export interface RelationFilterArtifact {
78
78
  /**
79
79
  * Enum list filter artifact - for array enum fields (uses 'includes' condition)
80
80
  */
81
- export interface EnumListFilterArtifact {
82
- readonly values?: readonly string[]
83
- readonly notValues?: readonly string[]
81
+ export interface EnumListFilterArtifact<TValue extends string = string> {
82
+ readonly values?: readonly TValue[]
83
+ readonly notValues?: readonly TValue[]
84
84
  readonly nullCondition?: boolean
85
85
  }
86
86
 
@@ -13,6 +13,7 @@ import {
13
13
  } from '../core/actions.js'
14
14
  import { FIELD_REF_META, type FieldRefMeta, type FieldAccessor, type HasOneAccessor, type HasManyAccessor, type EntityFieldsAccessor, type Unsubscribe, type EntityAccessor } from './types.js'
15
15
  import { deepEqual } from '../utils/deepEqual.js'
16
+ import { canHasManyParamsExcludeMembers } from '../utils/aliasGenerator.js'
16
17
  import { createClientError, type ErrorInput, type FieldError } from '../errors/types.js'
17
18
  import type {
18
19
  EventTypeMap,
@@ -511,6 +512,16 @@ export class EntityHandle<T extends object = object, TSelected = T> extends Enti
511
512
  // Has-many relation - return HasManyListHandle.
512
513
  // Thread the selected alias so the handle reads data stored under the
513
514
  // auto-generated alias (e.g. `tags_<hash>`) for params-bearing relations.
515
+ // The alias is a hash of the params, so the store cannot tell a filtered
516
+ // view from one that only orders — say so here, where the params are.
517
+ if (fieldMeta && fieldMeta.alias !== schemaFieldName) {
518
+ this.store.declareHasManyViewMembership(
519
+ this.entityType,
520
+ schemaFieldName,
521
+ fieldMeta.alias,
522
+ canHasManyParamsExcludeMembers(fieldMeta.hasManyParams) ? 'partial' : 'total',
523
+ )
524
+ }
514
525
  return this.hasMany(schemaFieldName, fieldMeta?.alias, nestedSelection)
515
526
  }
516
527
 
@@ -284,7 +284,8 @@ export class HasManyListHandle<TEntity extends object = object, TSelected = TEnt
284
284
  * Only called when parent's embedded data has changed (re-fetch detected).
285
285
  */
286
286
  private ensureItemSnapshots(listData: Array<Record<string, unknown>>): void {
287
- for (const itemData of listData) {
287
+ for (const embeddedItem of listData) {
288
+ const itemData = this.store.resolveServerOccurrence(embeddedItem)
288
289
  const itemId = itemData['id'] as string
289
290
  if (!itemId) continue
290
291
 
@@ -406,23 +407,18 @@ export class HasManyListHandle<TEntity extends object = object, TSelected = TEnt
406
407
 
407
408
  /**
408
409
  * Checks if the list is dirty (items added/removed/moved/connected/disconnected).
410
+ *
411
+ * Pending writes belong to the relation, not to this view, so a sibling view's
412
+ * unsaved change reports here too — persisting either view sends the whole
413
+ * relation, and a view claiming to be clean while pushing its sibling's writes
414
+ * would be incoherent. Only the manual ordering is this view's own.
409
415
  */
410
416
  get isDirty(): boolean {
411
417
  this.materializeEmbeddedItems()
412
418
 
413
- const state = this.store.getHasMany(
414
- this.entityType,
415
- this.entityId,
416
- this.fieldName,
417
- this.alias,
418
- )
419
-
420
- if (!state) return false
421
-
422
419
  return (
423
- state.plannedRemovals.size > 0 ||
424
- state.plannedAdditions.size > 0 ||
425
- state.orderedIds !== null
420
+ this.store.hasPendingHasManyWrites(this.entityType, this.entityId, this.fieldName)
421
+ || this.store.hasExplicitHasManyOrder(this.entityType, this.entityId, this.fieldName, this.alias)
426
422
  )
427
423
  }
428
424
 
@@ -452,7 +448,7 @@ export class HasManyListHandle<TEntity extends object = object, TSelected = TEnt
452
448
  disconnect(itemId: string): void {
453
449
  this.materializeEmbeddedItems()
454
450
  this.dispatcher.dispatch(
455
- removeFromList(this.entityType, this.entityId, this.fieldName, itemId, 'disconnect', this.alias),
451
+ removeFromList(this.entityType, this.entityId, this.fieldName, itemId, 'disconnect'),
456
452
  )
457
453
  }
458
454
 
@@ -464,7 +460,7 @@ export class HasManyListHandle<TEntity extends object = object, TSelected = TEnt
464
460
  delete(itemId: string): void {
465
461
  this.materializeEmbeddedItems()
466
462
  this.dispatcher.dispatch(
467
- removeFromList(this.entityType, this.entityId, this.fieldName, itemId, 'delete', this.alias),
463
+ removeFromList(this.entityType, this.entityId, this.fieldName, itemId, 'delete'),
468
464
  )
469
465
  }
470
466
 
@@ -503,7 +499,7 @@ export class HasManyListHandle<TEntity extends object = object, TSelected = TEnt
503
499
  remove(itemId: string): void {
504
500
  this.materializeEmbeddedItems()
505
501
  this.dispatcher.dispatch(
506
- removeFromList(this.entityType, this.entityId, this.fieldName, itemId, this.resolveRemovalType(), this.alias),
502
+ removeFromList(this.entityType, this.entityId, this.fieldName, itemId, this.resolveRemovalType()),
507
503
  )
508
504
  }
509
505
 
@@ -541,6 +537,9 @@ export class HasManyListHandle<TEntity extends object = object, TSelected = TEnt
541
537
  /**
542
538
  * Resets the has-many relation to server state.
543
539
  * Clears all planned connections and removals.
540
+ *
541
+ * Relation-level, like {@link isDirty}: planned removals carry no view, so a
542
+ * per-view reset could not know which of them to drop.
544
543
  */
545
544
  reset(): void {
546
545
  this.materializeEmbeddedItems()
@@ -548,7 +547,6 @@ export class HasManyListHandle<TEntity extends object = object, TSelected = TEnt
548
547
  this.entityType,
549
548
  this.entityId,
550
549
  this.fieldName,
551
- this.alias,
552
550
  )
553
551
  }
554
552
 
@@ -418,11 +418,13 @@ export class HasOneHandle<TEntity extends object = object, TSelected = TEntity>
418
418
  return
419
419
  }
420
420
 
421
+ const occurrence = this.store.resolveServerOccurrence(embeddedData as Record<string, unknown>)
422
+
421
423
  // Skip if embedded data values match existing serverData — avoids overwriting
422
424
  // unpersisted local mutations when a re-fetch returns the same server data
423
425
  // (e.g. polling). A new reference with identical values means no actual change.
424
426
  const existing = this.store.getEntitySnapshot(this.targetType, id)
425
- if (existing?.serverData && embeddedDataMatchesSnapshot(embeddedData as Record<string, unknown>, existing.serverData as Record<string, unknown>)) {
427
+ if (existing?.serverData && embeddedDataMatchesSnapshot(occurrence, existing.serverData as Record<string, unknown>)) {
426
428
  this.store.markEmbeddedDataPropagated(this.entityType, this.entityId, this.dataFieldName, embeddedData)
427
429
  return
428
430
  }
@@ -435,7 +437,7 @@ export class HasOneHandle<TEntity extends object = object, TSelected = TEntity>
435
437
  this.store.refreshServerData(
436
438
  this.targetType,
437
439
  id,
438
- embeddedData as Record<string, unknown>,
440
+ occurrence,
439
441
  true, // skipNotify - called during render, data already exists embedded in parent
440
442
  )
441
443
  this.store.markEmbeddedDataPropagated(this.entityType, this.entityId, this.dataFieldName, embeddedData)
package/src/index.ts CHANGED
@@ -250,6 +250,12 @@ export type {
250
250
  export type {
251
251
  StoredRelationState,
252
252
  StoredHasManyState,
253
+ HasManyView,
254
+ HasManyViewMembership,
255
+ PlannedHasManyAddition,
256
+ HasManyAdditionKind,
257
+ HasManyRelationProjection,
258
+ HasManyViewProjection,
253
259
  EntityMeta,
254
260
  } from './store/SnapshotStore.js'
255
261
 
@@ -327,16 +327,18 @@ export class BatchPersister {
327
327
  }
328
328
 
329
329
  const transactionResult = await this.executeTransaction(execution, options?.signal)
330
- result = this.processExecutionResult(execution, transactionResult, options)
330
+ result = this.store.batchNotifications(() => this.processExecutionResult(execution, transactionResult, options))
331
331
  result = this.mergeCancelled(result, execution.vetoed)
332
332
  return result
333
333
 
334
334
  } finally {
335
- this.releaseEntities([...claimed.values()])
336
- this.undoManager?.unblock()
337
- if (result?.success) {
338
- this.store.sweepUnreachableCreated()
339
- }
335
+ this.store.batchNotifications(() => {
336
+ this.releaseEntities([...claimed.values()])
337
+ this.undoManager?.unblock()
338
+ if (result?.success) {
339
+ this.store.sweepUnreachableCreated()
340
+ }
341
+ })
340
342
  }
341
343
 
342
344
  // Unreachable, but keeps the return type explicit when control-flow analysis changes.
@@ -991,11 +993,13 @@ export class BatchPersister {
991
993
  const fresh = entities.filter(entity => !claimed.has(entityIdentityKey(entity.entityType, entity.entityId)))
992
994
  if (fresh.length === 0) return
993
995
  this.changeRegistry.markInFlight(fresh)
994
- for (const entity of fresh) {
995
- claimed.set(entityIdentityKey(entity.entityType, entity.entityId), entity)
996
- this.dispatcher.dispatch(setPersisting(entity.entityType, entity.entityId, true, updateMode === 'pessimistic'))
997
- this.dispatcher.dispatch(clearAllServerErrors(entity.entityType, entity.entityId))
998
- }
996
+ this.store.batchNotifications(() => {
997
+ for (const entity of fresh) {
998
+ claimed.set(entityIdentityKey(entity.entityType, entity.entityId), entity)
999
+ this.dispatcher.dispatch(setPersisting(entity.entityType, entity.entityId, true, updateMode === 'pessimistic'))
1000
+ this.dispatcher.dispatch(clearAllServerErrors(entity.entityType, entity.entityId))
1001
+ }
1002
+ })
999
1003
  }
1000
1004
 
1001
1005
  private releaseEntities(entities: readonly DirtyEntity[]): void {