@contember/bindx 0.1.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.
- package/dist/adapter/ContemberAdapter.d.ts +40 -0
- package/dist/adapter/ContemberAdapter.d.ts.map +1 -0
- package/dist/adapter/MockAdapter.d.ts +81 -0
- package/dist/adapter/MockAdapter.d.ts.map +1 -0
- package/dist/adapter/MockQueryEngine.d.ts +43 -0
- package/dist/adapter/MockQueryEngine.d.ts.map +1 -0
- package/dist/adapter/index.d.ts +5 -0
- package/dist/adapter/index.d.ts.map +1 -0
- package/dist/adapter/types.d.ts +184 -0
- package/dist/adapter/types.d.ts.map +1 -0
- package/dist/brand/ComponentBrand.d.ts +25 -0
- package/dist/brand/ComponentBrand.d.ts.map +1 -0
- package/dist/contember/index.d.ts +10 -0
- package/dist/contember/index.d.ts.map +1 -0
- package/dist/core/ActionDispatcher.d.ts +64 -0
- package/dist/core/ActionDispatcher.d.ts.map +1 -0
- package/dist/core/Disposable.d.ts +57 -0
- package/dist/core/Disposable.d.ts.map +1 -0
- package/dist/core/EntityLoader.d.ts +80 -0
- package/dist/core/EntityLoader.d.ts.map +1 -0
- package/dist/core/PersistenceManager.d.ts +126 -0
- package/dist/core/PersistenceManager.d.ts.map +1 -0
- package/dist/core/SelectionResolver.d.ts +21 -0
- package/dist/core/SelectionResolver.d.ts.map +1 -0
- package/dist/core/actionClassification.d.ts +48 -0
- package/dist/core/actionClassification.d.ts.map +1 -0
- package/dist/core/actions.d.ts +301 -0
- package/dist/core/actions.d.ts.map +1 -0
- package/dist/core/index.d.ts +4 -0
- package/dist/core/index.d.ts.map +1 -0
- package/dist/dataview/filterHandlers.d.ts +18 -0
- package/dist/dataview/filterHandlers.d.ts.map +1 -0
- package/dist/dataview/index.d.ts +3 -0
- package/dist/dataview/index.d.ts.map +1 -0
- package/dist/dataview/types.d.ts +164 -0
- package/dist/dataview/types.d.ts.map +1 -0
- package/dist/errors/UnfetchedFieldError.d.ts +11 -0
- package/dist/errors/UnfetchedFieldError.d.ts.map +1 -0
- package/dist/errors/errorPathResolver.d.ts +52 -0
- package/dist/errors/errorPathResolver.d.ts.map +1 -0
- package/dist/errors/index.d.ts +5 -0
- package/dist/errors/index.d.ts.map +1 -0
- package/dist/errors/pathMapper.d.ts +42 -0
- package/dist/errors/pathMapper.d.ts.map +1 -0
- package/dist/errors/types.d.ts +131 -0
- package/dist/errors/types.d.ts.map +1 -0
- package/dist/events/EventEmitter.d.ts +97 -0
- package/dist/events/EventEmitter.d.ts.map +1 -0
- package/dist/events/eventFactory.d.ts +40 -0
- package/dist/events/eventFactory.d.ts.map +1 -0
- package/dist/events/index.d.ts +15 -0
- package/dist/events/index.d.ts.map +1 -0
- package/dist/events/types.d.ts +264 -0
- package/dist/events/types.d.ts.map +1 -0
- package/dist/handles/BaseHandle.d.ts +64 -0
- package/dist/handles/BaseHandle.d.ts.map +1 -0
- package/dist/handles/EntityHandle.d.ts +208 -0
- package/dist/handles/EntityHandle.d.ts.map +1 -0
- package/dist/handles/FieldHandle.d.ts +111 -0
- package/dist/handles/FieldHandle.d.ts.map +1 -0
- package/dist/handles/HasManyListHandle.d.ts +166 -0
- package/dist/handles/HasManyListHandle.d.ts.map +1 -0
- package/dist/handles/HasOneHandle.d.ts +168 -0
- package/dist/handles/HasOneHandle.d.ts.map +1 -0
- package/dist/handles/PlaceholderHandle.d.ts +117 -0
- package/dist/handles/PlaceholderHandle.d.ts.map +1 -0
- package/dist/handles/index.d.ts +8 -0
- package/dist/handles/index.d.ts.map +1 -0
- package/dist/handles/proxyFactory.d.ts +66 -0
- package/dist/handles/proxyFactory.d.ts.map +1 -0
- package/dist/handles/types.d.ts +430 -0
- package/dist/handles/types.d.ts.map +1 -0
- package/dist/index.d.ts +53 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/internal.d.ts +11 -0
- package/dist/internal.d.ts.map +1 -0
- package/dist/persistence/BatchPersister.d.ts +142 -0
- package/dist/persistence/BatchPersister.d.ts.map +1 -0
- package/dist/persistence/ChangeRegistry.d.ts +87 -0
- package/dist/persistence/ChangeRegistry.d.ts.map +1 -0
- package/dist/persistence/ContemberSchemaMutationAdapter.d.ts +16 -0
- package/dist/persistence/ContemberSchemaMutationAdapter.d.ts.map +1 -0
- package/dist/persistence/MutationCollector.d.ts +96 -0
- package/dist/persistence/MutationCollector.d.ts.map +1 -0
- package/dist/persistence/MutationSchemaProvider.d.ts +30 -0
- package/dist/persistence/MutationSchemaProvider.d.ts.map +1 -0
- package/dist/persistence/index.d.ts +7 -0
- package/dist/persistence/index.d.ts.map +1 -0
- package/dist/persistence/types.d.ts +189 -0
- package/dist/persistence/types.d.ts.map +1 -0
- package/dist/roles/RoleSchemaRegistry.d.ts +77 -0
- package/dist/roles/RoleSchemaRegistry.d.ts.map +1 -0
- package/dist/roles/index.d.ts +11 -0
- package/dist/roles/index.d.ts.map +1 -0
- package/dist/roles/types.d.ts +242 -0
- package/dist/roles/types.d.ts.map +1 -0
- package/dist/schema/ContemberSchema.d.ts +98 -0
- package/dist/schema/ContemberSchema.d.ts.map +1 -0
- package/dist/schema/SchemaLoader.d.ts +28 -0
- package/dist/schema/SchemaLoader.d.ts.map +1 -0
- package/dist/schema/SchemaRegistry.d.ts +95 -0
- package/dist/schema/SchemaRegistry.d.ts.map +1 -0
- package/dist/schema/index.d.ts +6 -0
- package/dist/schema/index.d.ts.map +1 -0
- package/dist/schema/roles.d.ts +62 -0
- package/dist/schema/roles.d.ts.map +1 -0
- package/dist/schema/types.d.ts +179 -0
- package/dist/schema/types.d.ts.map +1 -0
- package/dist/selection/SelectionMetaCollector.d.ts +32 -0
- package/dist/selection/SelectionMetaCollector.d.ts.map +1 -0
- package/dist/selection/SelectionScope.d.ts +77 -0
- package/dist/selection/SelectionScope.d.ts.map +1 -0
- package/dist/selection/buildQuery.d.ts +45 -0
- package/dist/selection/buildQuery.d.ts.map +1 -0
- package/dist/selection/createFragment.d.ts +29 -0
- package/dist/selection/createFragment.d.ts.map +1 -0
- package/dist/selection/createSelectionBuilder.d.ts +18 -0
- package/dist/selection/createSelectionBuilder.d.ts.map +1 -0
- package/dist/selection/index.d.ts +8 -0
- package/dist/selection/index.d.ts.map +1 -0
- package/dist/selection/queryTypes.d.ts +84 -0
- package/dist/selection/queryTypes.d.ts.map +1 -0
- package/dist/selection/types.d.ts +238 -0
- package/dist/selection/types.d.ts.map +1 -0
- package/dist/store/DirtyTracker.d.ts +26 -0
- package/dist/store/DirtyTracker.d.ts.map +1 -0
- package/dist/store/EntityMetaStore.d.ts +52 -0
- package/dist/store/EntityMetaStore.d.ts.map +1 -0
- package/dist/store/EntitySnapshotStore.d.ts +63 -0
- package/dist/store/EntitySnapshotStore.d.ts.map +1 -0
- package/dist/store/ErrorStore.d.ts +82 -0
- package/dist/store/ErrorStore.d.ts.map +1 -0
- package/dist/store/RelationStore.d.ts +175 -0
- package/dist/store/RelationStore.d.ts.map +1 -0
- package/dist/store/SnapshotStore.d.ts +143 -0
- package/dist/store/SnapshotStore.d.ts.map +1 -0
- package/dist/store/SubscriptionManager.d.ts +90 -0
- package/dist/store/SubscriptionManager.d.ts.map +1 -0
- package/dist/store/TouchedStore.d.ts +24 -0
- package/dist/store/TouchedStore.d.ts.map +1 -0
- package/dist/store/entityId.d.ts +29 -0
- package/dist/store/entityId.d.ts.map +1 -0
- package/dist/store/index.d.ts +1 -0
- package/dist/store/index.d.ts.map +1 -0
- package/dist/store/snapshots.d.ts +144 -0
- package/dist/store/snapshots.d.ts.map +1 -0
- package/dist/undo/UndoManager.d.ts +120 -0
- package/dist/undo/UndoManager.d.ts.map +1 -0
- package/dist/undo/index.d.ts +3 -0
- package/dist/undo/index.d.ts.map +1 -0
- package/dist/undo/types.d.ts +74 -0
- package/dist/undo/types.d.ts.map +1 -0
- package/dist/utils/aliasGenerator.d.ts +25 -0
- package/dist/utils/aliasGenerator.d.ts.map +1 -0
- package/dist/utils/deepEqual.d.ts +6 -0
- package/dist/utils/deepEqual.d.ts.map +1 -0
- package/dist/utils/index.d.ts +3 -0
- package/dist/utils/index.d.ts.map +1 -0
- package/package.json +29 -0
- package/src/adapter/ContemberAdapter.ts +239 -0
- package/src/adapter/MockAdapter.ts +394 -0
- package/src/adapter/MockQueryEngine.ts +236 -0
- package/src/adapter/index.ts +21 -0
- package/src/adapter/types.ts +199 -0
- package/src/brand/ComponentBrand.ts +28 -0
- package/src/contember/index.ts +17 -0
- package/src/core/ActionDispatcher.ts +450 -0
- package/src/core/Disposable.ts +126 -0
- package/src/core/EntityLoader.ts +169 -0
- package/src/core/SelectionResolver.ts +78 -0
- package/src/core/actionClassification.ts +196 -0
- package/src/core/actions.ts +491 -0
- package/src/core/index.ts +20 -0
- package/src/dataview/filterHandlers.ts +443 -0
- package/src/dataview/index.ts +36 -0
- package/src/dataview/types.ts +220 -0
- package/src/errors/UnfetchedFieldError.ts +18 -0
- package/src/errors/errorPathResolver.ts +239 -0
- package/src/errors/index.ts +40 -0
- package/src/errors/pathMapper.ts +40 -0
- package/src/errors/types.ts +223 -0
- package/src/events/EventEmitter.ts +447 -0
- package/src/events/eventFactory.ts +430 -0
- package/src/events/index.ts +89 -0
- package/src/events/types.ts +374 -0
- package/src/handles/BaseHandle.ts +100 -0
- package/src/handles/EntityHandle.ts +538 -0
- package/src/handles/FieldHandle.ts +268 -0
- package/src/handles/HasManyListHandle.ts +500 -0
- package/src/handles/HasOneHandle.ts +500 -0
- package/src/handles/PlaceholderHandle.ts +321 -0
- package/src/handles/index.ts +37 -0
- package/src/handles/proxyFactory.ts +218 -0
- package/src/handles/types.ts +743 -0
- package/src/index.ts +384 -0
- package/src/internal.ts +19 -0
- package/src/persistence/BatchPersister.ts +911 -0
- package/src/persistence/ChangeRegistry.ts +150 -0
- package/src/persistence/ContemberSchemaMutationAdapter.ts +57 -0
- package/src/persistence/MutationCollector.ts +567 -0
- package/src/persistence/MutationSchemaProvider.ts +33 -0
- package/src/persistence/index.ts +23 -0
- package/src/persistence/types.ts +220 -0
- package/src/schema/ContemberSchema.ts +132 -0
- package/src/schema/SchemaLoader.ts +93 -0
- package/src/schema/SchemaRegistry.ts +286 -0
- package/src/schema/index.ts +51 -0
- package/src/schema/roles.ts +83 -0
- package/src/schema/types.ts +238 -0
- package/src/selection/SelectionMetaCollector.ts +122 -0
- package/src/selection/SelectionScope.ts +208 -0
- package/src/selection/buildQuery.ts +134 -0
- package/src/selection/createFragment.ts +50 -0
- package/src/selection/createSelectionBuilder.ts +283 -0
- package/src/selection/index.ts +31 -0
- package/src/selection/queryTypes.ts +135 -0
- package/src/selection/types.ts +320 -0
- package/src/store/DirtyTracker.ts +117 -0
- package/src/store/EntityMetaStore.ts +143 -0
- package/src/store/EntitySnapshotStore.ts +260 -0
- package/src/store/ErrorStore.ts +251 -0
- package/src/store/RelationStore.ts +696 -0
- package/src/store/SnapshotStore.ts +765 -0
- package/src/store/SubscriptionManager.ts +241 -0
- package/src/store/TouchedStore.ts +42 -0
- package/src/store/entityId.ts +43 -0
- package/src/store/index.ts +2 -0
- package/src/store/snapshots.ts +203 -0
- package/src/undo/UndoManager.ts +418 -0
- package/src/undo/index.ts +9 -0
- package/src/undo/types.ts +84 -0
- package/src/utils/aliasGenerator.ts +56 -0
- package/src/utils/deepEqual.ts +33 -0
- package/src/utils/index.ts +2 -0
|
@@ -0,0 +1,394 @@
|
|
|
1
|
+
import type { BackendAdapter, Query, QueryResult, QueryOptions, GetQuery, ListQuery, PersistResult, CreateResult, DeleteResult } from './types.js'
|
|
2
|
+
import { MockQueryEngine } from './MockQueryEngine.js'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* In-memory data store structure
|
|
6
|
+
*/
|
|
7
|
+
export type MockDataStore = {
|
|
8
|
+
[entityType: string]: {
|
|
9
|
+
[id: string]: Record<string, unknown>
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Options for MockAdapter
|
|
15
|
+
*/
|
|
16
|
+
export interface MockAdapterOptions {
|
|
17
|
+
/** Simulated network delay in milliseconds (default: 100) */
|
|
18
|
+
delay?: number
|
|
19
|
+
/** Whether to log operations to console (default: false) */
|
|
20
|
+
debug?: boolean
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Mock backend adapter for testing and development.
|
|
25
|
+
* Stores data in memory and simulates async operations.
|
|
26
|
+
*/
|
|
27
|
+
export class MockAdapter implements BackendAdapter {
|
|
28
|
+
private readonly delay: number
|
|
29
|
+
private readonly debug: boolean
|
|
30
|
+
private readonly queryEngine: MockQueryEngine
|
|
31
|
+
|
|
32
|
+
constructor(
|
|
33
|
+
private store: MockDataStore,
|
|
34
|
+
options: MockAdapterOptions = {},
|
|
35
|
+
) {
|
|
36
|
+
this.delay = options.delay ?? 100
|
|
37
|
+
this.debug = options.debug ?? false
|
|
38
|
+
this.queryEngine = new MockQueryEngine()
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Simulates network delay with abort support
|
|
43
|
+
*/
|
|
44
|
+
private async simulateDelay(signal?: AbortSignal): Promise<void> {
|
|
45
|
+
if (this.delay > 0) {
|
|
46
|
+
await new Promise<void>((resolve, reject) => {
|
|
47
|
+
const timeoutId = setTimeout(resolve, this.delay)
|
|
48
|
+
|
|
49
|
+
if (signal) {
|
|
50
|
+
if (signal.aborted) {
|
|
51
|
+
clearTimeout(timeoutId)
|
|
52
|
+
reject(new DOMException('Aborted', 'AbortError'))
|
|
53
|
+
return
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
signal.addEventListener('abort', () => {
|
|
57
|
+
clearTimeout(timeoutId)
|
|
58
|
+
reject(new DOMException('Aborted', 'AbortError'))
|
|
59
|
+
}, { once: true })
|
|
60
|
+
}
|
|
61
|
+
})
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Check if aborted after delay
|
|
65
|
+
if (signal?.aborted) {
|
|
66
|
+
throw new DOMException('Aborted', 'AbortError')
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Logs operations if debug is enabled
|
|
72
|
+
*/
|
|
73
|
+
private log(operation: string, ...args: unknown[]): void {
|
|
74
|
+
if (this.debug) {
|
|
75
|
+
console.log(`[MockAdapter] ${operation}:`, ...args)
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async query(queries: readonly Query[], options?: QueryOptions): Promise<QueryResult[]> {
|
|
80
|
+
this.log('query', { queries })
|
|
81
|
+
await this.simulateDelay(options?.signal)
|
|
82
|
+
|
|
83
|
+
return queries.map(q => {
|
|
84
|
+
if (q.type === 'get') {
|
|
85
|
+
return this.executeGet(q)
|
|
86
|
+
} else {
|
|
87
|
+
return this.executeList(q)
|
|
88
|
+
}
|
|
89
|
+
})
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
private executeGet(query: GetQuery): QueryResult {
|
|
93
|
+
const entityStore = this.store[query.entityType]
|
|
94
|
+
if (!entityStore) {
|
|
95
|
+
return { type: 'get', data: null }
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Find entity by unique field(s) in 'by'
|
|
99
|
+
const by = query.by
|
|
100
|
+
const [field, value] = Object.entries(by)[0] ?? []
|
|
101
|
+
|
|
102
|
+
if (!field || value === undefined) {
|
|
103
|
+
return { type: 'get', data: null }
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
let entity: Record<string, unknown> | undefined
|
|
107
|
+
if (field === 'id') {
|
|
108
|
+
// Direct lookup by ID
|
|
109
|
+
entity = entityStore[value as string]
|
|
110
|
+
} else {
|
|
111
|
+
// Search by other unique field
|
|
112
|
+
entity = Object.values(entityStore).find(e => e[field] === value)
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (!entity) {
|
|
116
|
+
return { type: 'get', data: null }
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const result = this.queryEngine.projectFields(entity, query.spec.fields)
|
|
120
|
+
this.log('executeGet result', result)
|
|
121
|
+
|
|
122
|
+
return { type: 'get', data: result }
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
private executeList(query: ListQuery): QueryResult {
|
|
126
|
+
const entityStore = this.store[query.entityType]
|
|
127
|
+
if (!entityStore) {
|
|
128
|
+
return { type: 'list', data: [] }
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
let entities = Object.values(entityStore)
|
|
132
|
+
|
|
133
|
+
// Apply filter using query engine
|
|
134
|
+
if (query.filter) {
|
|
135
|
+
entities = this.queryEngine.filter(entities, query.filter as Record<string, unknown>)
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Apply ordering using query engine
|
|
139
|
+
if (query.orderBy && query.orderBy.length > 0) {
|
|
140
|
+
entities = this.queryEngine.orderBy(entities, query.orderBy)
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Apply pagination using query engine
|
|
144
|
+
entities = this.queryEngine.paginate(entities, query.offset, query.limit)
|
|
145
|
+
|
|
146
|
+
const results = entities.map(entity => this.queryEngine.projectFields(entity, query.spec.fields))
|
|
147
|
+
this.log('executeList result', results)
|
|
148
|
+
|
|
149
|
+
return { type: 'list', data: results }
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
async persist(
|
|
153
|
+
entityType: string,
|
|
154
|
+
id: string,
|
|
155
|
+
changes: Record<string, unknown>,
|
|
156
|
+
): Promise<PersistResult> {
|
|
157
|
+
this.log('persist', { entityType, id, changes })
|
|
158
|
+
await this.simulateDelay()
|
|
159
|
+
|
|
160
|
+
const entityStore = this.store[entityType]
|
|
161
|
+
if (!entityStore) {
|
|
162
|
+
return {
|
|
163
|
+
ok: false,
|
|
164
|
+
errorMessage: `Entity type '${entityType}' not found in store`,
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const entity = entityStore[id]
|
|
169
|
+
if (!entity) {
|
|
170
|
+
return {
|
|
171
|
+
ok: false,
|
|
172
|
+
errorMessage: `Entity '${entityType}:${id}' not found`,
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// Process changes with Contember-style operation support
|
|
177
|
+
this.applyChanges(entity, changes)
|
|
178
|
+
this.log('persist result', entity)
|
|
179
|
+
|
|
180
|
+
return { ok: true }
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Applies changes to an entity, handling Contember-style relation operations.
|
|
185
|
+
* Supports:
|
|
186
|
+
* - Scalar fields: direct assignment
|
|
187
|
+
* - HasOne: { connect: { id } }, { disconnect: true }, { delete: true }, { create: {...} }, { update: {...} }
|
|
188
|
+
* - HasMany: [{ connect: { id } }, { disconnect: { id } }, { delete: { id } }, { create: {...} }, { update: { by: { id }, data: {...} } }]
|
|
189
|
+
*/
|
|
190
|
+
private applyChanges(entity: Record<string, unknown>, changes: Record<string, unknown>): void {
|
|
191
|
+
for (const [key, value] of Object.entries(changes)) {
|
|
192
|
+
if (value === null || value === undefined) {
|
|
193
|
+
entity[key] = value
|
|
194
|
+
continue
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// Check if it's a Contember-style relation operation
|
|
198
|
+
if (typeof value === 'object' && !Array.isArray(value)) {
|
|
199
|
+
const operation = value as Record<string, unknown>
|
|
200
|
+
|
|
201
|
+
if ('connect' in operation) {
|
|
202
|
+
// HasOne connect: { connect: { id: '...' } }
|
|
203
|
+
const connectData = operation['connect'] as Record<string, unknown>
|
|
204
|
+
const targetId = connectData['id'] as string
|
|
205
|
+
entity[key] = this.resolveRelatedEntity(targetId)
|
|
206
|
+
} else if ('disconnect' in operation && operation['disconnect'] === true) {
|
|
207
|
+
// HasOne disconnect: { disconnect: true }
|
|
208
|
+
entity[key] = null
|
|
209
|
+
} else if ('delete' in operation && operation['delete'] === true) {
|
|
210
|
+
// HasOne delete: { delete: true }
|
|
211
|
+
// Delete the related entity and set to null
|
|
212
|
+
const existingRelation = entity[key] as Record<string, unknown> | null
|
|
213
|
+
if (existingRelation?.['id']) {
|
|
214
|
+
this.deleteRelatedEntity(existingRelation['id'] as string)
|
|
215
|
+
}
|
|
216
|
+
entity[key] = null
|
|
217
|
+
} else if ('create' in operation) {
|
|
218
|
+
// HasOne create: { create: { name: '...', ... } }
|
|
219
|
+
const createData = operation['create'] as Record<string, unknown>
|
|
220
|
+
entity[key] = this.createRelatedEntity(createData)
|
|
221
|
+
} else if ('update' in operation) {
|
|
222
|
+
// HasOne update: { update: { name: '...', ... } }
|
|
223
|
+
const updateData = operation['update'] as Record<string, unknown>
|
|
224
|
+
const existingRelation = entity[key] as Record<string, unknown> | null
|
|
225
|
+
if (existingRelation) {
|
|
226
|
+
this.applyChanges(existingRelation, updateData)
|
|
227
|
+
}
|
|
228
|
+
} else {
|
|
229
|
+
// Regular object value (not a Contember operation)
|
|
230
|
+
entity[key] = value
|
|
231
|
+
}
|
|
232
|
+
} else if (Array.isArray(value)) {
|
|
233
|
+
// HasMany operations: [{ connect: { id } }, { disconnect: { id } }, ...]
|
|
234
|
+
entity[key] = this.applyHasManyOperations(
|
|
235
|
+
entity[key] as Array<Record<string, unknown>> | undefined,
|
|
236
|
+
value as Array<Record<string, unknown>>,
|
|
237
|
+
)
|
|
238
|
+
} else {
|
|
239
|
+
// Scalar field
|
|
240
|
+
entity[key] = value
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Applies hasMany relation operations.
|
|
247
|
+
*/
|
|
248
|
+
private applyHasManyOperations(
|
|
249
|
+
currentItems: Array<Record<string, unknown>> | undefined,
|
|
250
|
+
operations: Array<Record<string, unknown>>,
|
|
251
|
+
): Array<Record<string, unknown>> {
|
|
252
|
+
const items = [...(currentItems ?? [])]
|
|
253
|
+
|
|
254
|
+
for (const operation of operations) {
|
|
255
|
+
if ('connect' in operation) {
|
|
256
|
+
// Connect: { connect: { id: '...' } }
|
|
257
|
+
const connectData = operation['connect'] as Record<string, unknown>
|
|
258
|
+
const targetId = connectData['id'] as string
|
|
259
|
+
// Only add if not already present
|
|
260
|
+
if (!items.some(item => item['id'] === targetId)) {
|
|
261
|
+
const relatedEntity = this.resolveRelatedEntity(targetId)
|
|
262
|
+
if (relatedEntity) {
|
|
263
|
+
items.push(relatedEntity)
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
} else if ('disconnect' in operation) {
|
|
267
|
+
// Disconnect: { disconnect: { id: '...' } }
|
|
268
|
+
const disconnectData = operation['disconnect'] as Record<string, unknown>
|
|
269
|
+
const targetId = disconnectData['id'] as string
|
|
270
|
+
const index = items.findIndex(item => item['id'] === targetId)
|
|
271
|
+
if (index >= 0) {
|
|
272
|
+
items.splice(index, 1)
|
|
273
|
+
}
|
|
274
|
+
} else if ('delete' in operation) {
|
|
275
|
+
// Delete: { delete: { id: '...' } }
|
|
276
|
+
const deleteData = operation['delete'] as Record<string, unknown>
|
|
277
|
+
const targetId = deleteData['id'] as string
|
|
278
|
+
const index = items.findIndex(item => item['id'] === targetId)
|
|
279
|
+
if (index >= 0) {
|
|
280
|
+
items.splice(index, 1)
|
|
281
|
+
this.deleteRelatedEntity(targetId)
|
|
282
|
+
}
|
|
283
|
+
} else if ('create' in operation) {
|
|
284
|
+
// Create: { create: { name: '...', ... } }
|
|
285
|
+
const createData = operation['create'] as Record<string, unknown>
|
|
286
|
+
const newEntity = this.createRelatedEntity(createData)
|
|
287
|
+
if (newEntity) {
|
|
288
|
+
items.push(newEntity)
|
|
289
|
+
}
|
|
290
|
+
} else if ('update' in operation) {
|
|
291
|
+
// Update: { update: { by: { id: '...' }, data: { ... } } }
|
|
292
|
+
const updateOp = operation['update'] as Record<string, unknown>
|
|
293
|
+
const by = updateOp['by'] as Record<string, unknown>
|
|
294
|
+
const data = updateOp['data'] as Record<string, unknown>
|
|
295
|
+
const targetId = by['id'] as string
|
|
296
|
+
const item = items.find(item => item['id'] === targetId)
|
|
297
|
+
if (item) {
|
|
298
|
+
this.applyChanges(item, data)
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
return items
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* Resolves a related entity by ID from any entity store.
|
|
308
|
+
* Returns a copy of the entity data or { id } if not found.
|
|
309
|
+
*/
|
|
310
|
+
private resolveRelatedEntity(id: string): Record<string, unknown> | null {
|
|
311
|
+
for (const entityStore of Object.values(this.store)) {
|
|
312
|
+
if (entityStore[id]) {
|
|
313
|
+
return { ...entityStore[id] }
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
// Entity not found - return just the ID
|
|
317
|
+
return { id }
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* Creates a related entity with a generated ID.
|
|
322
|
+
*/
|
|
323
|
+
private createRelatedEntity(data: Record<string, unknown>): Record<string, unknown> {
|
|
324
|
+
const id = this.generateId()
|
|
325
|
+
return { id, ...data }
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* Deletes a related entity from any entity store.
|
|
330
|
+
*/
|
|
331
|
+
private deleteRelatedEntity(id: string): void {
|
|
332
|
+
for (const entityStore of Object.values(this.store)) {
|
|
333
|
+
if (entityStore[id]) {
|
|
334
|
+
delete entityStore[id]
|
|
335
|
+
return
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
async create(
|
|
341
|
+
entityType: string,
|
|
342
|
+
data: Record<string, unknown>,
|
|
343
|
+
): Promise<CreateResult> {
|
|
344
|
+
this.log('create', { entityType, data })
|
|
345
|
+
await this.simulateDelay()
|
|
346
|
+
|
|
347
|
+
// Ensure entity store exists
|
|
348
|
+
if (!this.store[entityType]) {
|
|
349
|
+
this.store[entityType] = {}
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
// Generate ID if not provided
|
|
353
|
+
const id = (data['id'] as string) ?? this.generateId()
|
|
354
|
+
const entity = { ...data, id }
|
|
355
|
+
|
|
356
|
+
this.store[entityType]![id] = entity
|
|
357
|
+
this.log('create result', entity)
|
|
358
|
+
|
|
359
|
+
return { ok: true, data: entity }
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
async delete(entityType: string, id: string): Promise<DeleteResult> {
|
|
363
|
+
this.log('delete', { entityType, id })
|
|
364
|
+
await this.simulateDelay()
|
|
365
|
+
|
|
366
|
+
const entityStore = this.store[entityType]
|
|
367
|
+
if (entityStore) {
|
|
368
|
+
delete entityStore[id]
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
return { ok: true }
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/**
|
|
375
|
+
* Generates a simple unique ID
|
|
376
|
+
*/
|
|
377
|
+
private generateId(): string {
|
|
378
|
+
return `mock_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/**
|
|
382
|
+
* Gets the current store state (for testing/debugging)
|
|
383
|
+
*/
|
|
384
|
+
getStore(): MockDataStore {
|
|
385
|
+
return this.store
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/**
|
|
389
|
+
* Resets the store to initial state
|
|
390
|
+
*/
|
|
391
|
+
resetStore(newStore: MockDataStore): void {
|
|
392
|
+
this.store = newStore
|
|
393
|
+
}
|
|
394
|
+
}
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
import type { QueryFieldSpec } from '../selection/buildQuery.js'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* MockQueryEngine provides in-memory query operations for MockAdapter.
|
|
5
|
+
* Extracted for better testability and separation of concerns.
|
|
6
|
+
*
|
|
7
|
+
* Supports:
|
|
8
|
+
* - Filtering with various operators (eq, notEq, in, lt, gt, contains, etc.)
|
|
9
|
+
* - Ordering with multi-field support and null handling
|
|
10
|
+
* - Pagination (offset/limit)
|
|
11
|
+
* - Field projection with nested relation support
|
|
12
|
+
*/
|
|
13
|
+
export class MockQueryEngine {
|
|
14
|
+
/**
|
|
15
|
+
* Filters entities based on a filter object.
|
|
16
|
+
*/
|
|
17
|
+
filter(
|
|
18
|
+
entities: Record<string, unknown>[],
|
|
19
|
+
filter: Record<string, unknown>,
|
|
20
|
+
): Record<string, unknown>[] {
|
|
21
|
+
return entities.filter(entity => this.matchesFilter(entity, filter))
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Orders entities based on orderBy specifications.
|
|
26
|
+
*/
|
|
27
|
+
orderBy(
|
|
28
|
+
entities: Record<string, unknown>[],
|
|
29
|
+
orderBy: readonly Record<string, unknown>[],
|
|
30
|
+
): Record<string, unknown>[] {
|
|
31
|
+
return [...entities].sort((a, b) => {
|
|
32
|
+
for (const order of orderBy) {
|
|
33
|
+
for (const [field, direction] of Object.entries(order)) {
|
|
34
|
+
if (field.startsWith('_')) continue // Skip _random, _randomSeeded
|
|
35
|
+
const aVal = a[field]
|
|
36
|
+
const bVal = b[field]
|
|
37
|
+
|
|
38
|
+
let comparison = 0
|
|
39
|
+
if (aVal === bVal) comparison = 0
|
|
40
|
+
else if (aVal === null || aVal === undefined) comparison = 1
|
|
41
|
+
else if (bVal === null || bVal === undefined) comparison = -1
|
|
42
|
+
else if (aVal < bVal) comparison = -1
|
|
43
|
+
else comparison = 1
|
|
44
|
+
|
|
45
|
+
if (direction === 'desc' || direction === 'descNullsLast') {
|
|
46
|
+
comparison = -comparison
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
if (comparison !== 0) return comparison
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return 0
|
|
53
|
+
})
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Paginates entities using offset and limit.
|
|
58
|
+
*/
|
|
59
|
+
paginate(
|
|
60
|
+
entities: Record<string, unknown>[],
|
|
61
|
+
offset?: number,
|
|
62
|
+
limit?: number,
|
|
63
|
+
): Record<string, unknown>[] {
|
|
64
|
+
let result = entities
|
|
65
|
+
if (offset !== undefined) {
|
|
66
|
+
result = result.slice(offset)
|
|
67
|
+
}
|
|
68
|
+
if (limit !== undefined) {
|
|
69
|
+
result = result.slice(0, limit)
|
|
70
|
+
}
|
|
71
|
+
return result
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Projects only the requested fields from source data.
|
|
76
|
+
* Supports nested relations (hasOne and hasMany).
|
|
77
|
+
*/
|
|
78
|
+
projectFields(
|
|
79
|
+
source: Record<string, unknown>,
|
|
80
|
+
fields: QueryFieldSpec[],
|
|
81
|
+
basePath: string[] = [],
|
|
82
|
+
): Record<string, unknown> {
|
|
83
|
+
const result: Record<string, unknown> = {}
|
|
84
|
+
|
|
85
|
+
for (const field of fields) {
|
|
86
|
+
// Get relative path by removing the base path prefix
|
|
87
|
+
const relativePath = field.sourcePath.slice(basePath.length)
|
|
88
|
+
const value = this.getNestedValue(source, relativePath)
|
|
89
|
+
|
|
90
|
+
if (field.nested && Array.isArray(value)) {
|
|
91
|
+
// Has-many relation — apply filter/orderBy/limit/offset, return flat array + metadata
|
|
92
|
+
let items = value as Record<string, unknown>[]
|
|
93
|
+
if (field.filter) {
|
|
94
|
+
items = this.filter(items, field.filter as Record<string, unknown>)
|
|
95
|
+
}
|
|
96
|
+
if (field.orderBy) {
|
|
97
|
+
items = this.orderBy(items, field.orderBy as readonly Record<string, unknown>[])
|
|
98
|
+
}
|
|
99
|
+
const totalCount = items.length
|
|
100
|
+
if (field.limit !== undefined || field.offset !== undefined) {
|
|
101
|
+
items = this.paginate(items, field.offset, field.limit)
|
|
102
|
+
}
|
|
103
|
+
const projected = items.map(item =>
|
|
104
|
+
this.projectFields(item as Record<string, unknown>, field.nested!.fields, []),
|
|
105
|
+
)
|
|
106
|
+
if (field.totalCount) {
|
|
107
|
+
Object.defineProperty(projected, 'totalCount', {
|
|
108
|
+
value: totalCount,
|
|
109
|
+
enumerable: false,
|
|
110
|
+
writable: false,
|
|
111
|
+
})
|
|
112
|
+
}
|
|
113
|
+
result[field.name] = projected
|
|
114
|
+
} else if (field.nested && value && typeof value === 'object') {
|
|
115
|
+
// Project nested object (has-one relation)
|
|
116
|
+
result[field.name] = this.projectFields(
|
|
117
|
+
value as Record<string, unknown>,
|
|
118
|
+
field.nested.fields,
|
|
119
|
+
[],
|
|
120
|
+
)
|
|
121
|
+
} else {
|
|
122
|
+
// Scalar or leaf value
|
|
123
|
+
result[field.name] = value
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return result
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Checks if an entity matches a filter.
|
|
132
|
+
*/
|
|
133
|
+
private matchesFilter(entity: Record<string, unknown>, filter: Record<string, unknown>): boolean {
|
|
134
|
+
for (const [key, condition] of Object.entries(filter)) {
|
|
135
|
+
if (key === 'and' && Array.isArray(condition)) {
|
|
136
|
+
if (!condition.every(c => this.matchesFilter(entity, c as Record<string, unknown>))) {
|
|
137
|
+
return false
|
|
138
|
+
}
|
|
139
|
+
continue
|
|
140
|
+
}
|
|
141
|
+
if (key === 'or' && Array.isArray(condition)) {
|
|
142
|
+
if (!condition.some(c => this.matchesFilter(entity, c as Record<string, unknown>))) {
|
|
143
|
+
return false
|
|
144
|
+
}
|
|
145
|
+
continue
|
|
146
|
+
}
|
|
147
|
+
if (key === 'not' && condition && typeof condition === 'object') {
|
|
148
|
+
if (this.matchesFilter(entity, condition as Record<string, unknown>)) {
|
|
149
|
+
return false
|
|
150
|
+
}
|
|
151
|
+
continue
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const fieldValue = entity[key]
|
|
155
|
+
if (condition && typeof condition === 'object' && !Array.isArray(condition)) {
|
|
156
|
+
// It's a condition object like { eq: 'value' }
|
|
157
|
+
if (!this.matchesCondition(fieldValue, condition as Record<string, unknown>)) {
|
|
158
|
+
return false
|
|
159
|
+
}
|
|
160
|
+
} else {
|
|
161
|
+
// Direct value comparison (for backward compatibility)
|
|
162
|
+
if (fieldValue !== condition) {
|
|
163
|
+
return false
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
return true
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Checks if a value matches a condition object.
|
|
172
|
+
*/
|
|
173
|
+
private matchesCondition(value: unknown, condition: Record<string, unknown>): boolean {
|
|
174
|
+
for (const [op, expected] of Object.entries(condition)) {
|
|
175
|
+
switch (op) {
|
|
176
|
+
case 'eq':
|
|
177
|
+
if (value !== expected) return false
|
|
178
|
+
break
|
|
179
|
+
case 'notEq':
|
|
180
|
+
if (value === expected) return false
|
|
181
|
+
break
|
|
182
|
+
case 'in':
|
|
183
|
+
if (!Array.isArray(expected) || !expected.includes(value)) return false
|
|
184
|
+
break
|
|
185
|
+
case 'notIn':
|
|
186
|
+
if (Array.isArray(expected) && expected.includes(value)) return false
|
|
187
|
+
break
|
|
188
|
+
case 'lt':
|
|
189
|
+
if (typeof value !== 'number' || typeof expected !== 'number' || value >= expected) return false
|
|
190
|
+
break
|
|
191
|
+
case 'lte':
|
|
192
|
+
if (typeof value !== 'number' || typeof expected !== 'number' || value > expected) return false
|
|
193
|
+
break
|
|
194
|
+
case 'gt':
|
|
195
|
+
if (typeof value !== 'number' || typeof expected !== 'number' || value <= expected) return false
|
|
196
|
+
break
|
|
197
|
+
case 'gte':
|
|
198
|
+
if (typeof value !== 'number' || typeof expected !== 'number' || value < expected) return false
|
|
199
|
+
break
|
|
200
|
+
case 'isNull':
|
|
201
|
+
if (expected === true && value !== null && value !== undefined) return false
|
|
202
|
+
if (expected === false && (value === null || value === undefined)) return false
|
|
203
|
+
break
|
|
204
|
+
case 'contains':
|
|
205
|
+
if (typeof value !== 'string' || typeof expected !== 'string' || !value.includes(expected)) return false
|
|
206
|
+
break
|
|
207
|
+
case 'startsWith':
|
|
208
|
+
if (typeof value !== 'string' || typeof expected !== 'string' || !value.startsWith(expected)) return false
|
|
209
|
+
break
|
|
210
|
+
case 'endsWith':
|
|
211
|
+
if (typeof value !== 'string' || typeof expected !== 'string' || !value.endsWith(expected)) return false
|
|
212
|
+
break
|
|
213
|
+
case 'containsCI':
|
|
214
|
+
if (typeof value !== 'string' || typeof expected !== 'string' || !value.toLowerCase().includes(expected.toLowerCase())) return false
|
|
215
|
+
break
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
return true
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Gets a nested value from an object using a path.
|
|
223
|
+
*/
|
|
224
|
+
private getNestedValue(obj: Record<string, unknown>, path: string[]): unknown {
|
|
225
|
+
let current: unknown = obj
|
|
226
|
+
|
|
227
|
+
for (const key of path) {
|
|
228
|
+
if (current === null || current === undefined || typeof current !== 'object') {
|
|
229
|
+
return undefined
|
|
230
|
+
}
|
|
231
|
+
current = (current as Record<string, unknown>)[key]
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
return current
|
|
235
|
+
}
|
|
236
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
export type {
|
|
2
|
+
BackendAdapter,
|
|
3
|
+
QueryOptions,
|
|
4
|
+
Query,
|
|
5
|
+
GetQuery,
|
|
6
|
+
ListQuery,
|
|
7
|
+
QueryResult,
|
|
8
|
+
GetQueryResult,
|
|
9
|
+
ListQueryResult,
|
|
10
|
+
PersistResult,
|
|
11
|
+
CreateResult,
|
|
12
|
+
DeleteResult,
|
|
13
|
+
EntityUniqueWhere,
|
|
14
|
+
UniqueWhereValue,
|
|
15
|
+
TransactionMutation,
|
|
16
|
+
TransactionMutationResult,
|
|
17
|
+
TransactionResult,
|
|
18
|
+
} from './types.js'
|
|
19
|
+
export { MockAdapter, type MockDataStore, type MockAdapterOptions } from './MockAdapter.js'
|
|
20
|
+
export { MockQueryEngine } from './MockQueryEngine.js'
|
|
21
|
+
export { ContemberAdapter, type ContemberAdapterOptions } from './ContemberAdapter.js'
|