@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.
- package/dist/core/ActionDispatcher.d.ts.map +1 -1
- package/dist/core/EntityLoader.d.ts +7 -0
- package/dist/core/EntityLoader.d.ts.map +1 -1
- package/dist/core/actions.d.ts +1 -2
- package/dist/core/actions.d.ts.map +1 -1
- package/dist/dataview/types.d.ts +6 -6
- package/dist/dataview/types.d.ts.map +1 -1
- package/dist/handles/EntityHandle.d.ts.map +1 -1
- package/dist/handles/HasManyListHandle.d.ts +8 -0
- package/dist/handles/HasManyListHandle.d.ts.map +1 -1
- package/dist/handles/HasOneHandle.d.ts.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/persistence/BatchPersister.d.ts.map +1 -1
- package/dist/store/HasManyStore.d.ts +94 -78
- package/dist/store/HasManyStore.d.ts.map +1 -1
- package/dist/store/RelationStore.d.ts +21 -12
- package/dist/store/RelationStore.d.ts.map +1 -1
- package/dist/store/ResponseOccurrenceIndex.d.ts +65 -0
- package/dist/store/ResponseOccurrenceIndex.d.ts.map +1 -0
- package/dist/store/SnapshotStore.d.ts +53 -15
- package/dist/store/SnapshotStore.d.ts.map +1 -1
- package/dist/store/SubscriptionManager.d.ts +19 -4
- package/dist/store/SubscriptionManager.d.ts.map +1 -1
- package/dist/store/hasManyState.d.ts +164 -0
- package/dist/store/hasManyState.d.ts.map +1 -0
- package/dist/store/relationKey.d.ts +11 -0
- package/dist/store/relationKey.d.ts.map +1 -1
- package/dist/undo/UndoJournal.d.ts.map +1 -1
- package/dist/undo/rekeyJournalEntry.d.ts +4 -4
- package/dist/undo/rekeyJournalEntry.d.ts.map +1 -1
- package/dist/utils/aliasGenerator.d.ts +1 -1
- package/dist/utils/aliasGenerator.d.ts.map +1 -1
- package/package.json +2 -2
- package/src/core/ActionDispatcher.ts +0 -1
- package/src/core/EntityLoader.ts +13 -6
- package/src/core/actions.ts +1 -3
- package/src/dataview/types.ts +6 -6
- package/src/handles/EntityHandle.ts +11 -0
- package/src/handles/HasManyListHandle.ts +15 -17
- package/src/handles/HasOneHandle.ts +4 -2
- package/src/index.ts +6 -0
- package/src/persistence/BatchPersister.ts +15 -11
- package/src/store/HasManyStore.ts +312 -488
- package/src/store/RelationStore.ts +60 -24
- package/src/store/ResponseOccurrenceIndex.ts +224 -0
- package/src/store/SnapshotStore.ts +183 -109
- package/src/store/SubscriptionManager.ts +116 -58
- package/src/store/hasManyState.ts +408 -0
- package/src/store/relationKey.ts +17 -0
- package/src/undo/UndoJournal.ts +3 -1
- package/src/undo/UndoManager.ts +1 -1
- package/src/undo/rekeyJournalEntry.ts +31 -43
- package/src/utils/aliasGenerator.ts +1 -1
package/src/core/EntityLoader.ts
CHANGED
|
@@ -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
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
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) {
|
package/src/core/actions.ts
CHANGED
|
@@ -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
|
|
584
|
+
return { type: 'REMOVE_FROM_LIST', entityType, entityId, fieldName, itemKey, removalType }
|
|
587
585
|
}
|
|
588
586
|
|
|
589
587
|
/**
|
package/src/dataview/types.ts
CHANGED
|
@@ -60,9 +60,9 @@ export interface BooleanFilterArtifact {
|
|
|
60
60
|
/**
|
|
61
61
|
* Enum filter artifact
|
|
62
62
|
*/
|
|
63
|
-
export interface EnumFilterArtifact {
|
|
64
|
-
readonly values?: readonly
|
|
65
|
-
readonly notValues?: readonly
|
|
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
|
|
83
|
-
readonly notValues?: readonly
|
|
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
|
|
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
|
-
|
|
424
|
-
|
|
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'
|
|
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'
|
|
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()
|
|
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(
|
|
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
|
-
|
|
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.
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
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
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
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 {
|