dzql 0.5.5 → 0.5.7

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.
@@ -0,0 +1,553 @@
1
+ /**
2
+ * Drop Semantics Code Generator
3
+ * Generates a JSON manifest describing valid drag-and-drop interactions for a canvas UI
4
+ *
5
+ * Terminology clarification:
6
+ * - "source" = the entity being dragged
7
+ * - "target" = the entity being dropped onto
8
+ *
9
+ * Derivation rules:
10
+ * 1. FK on entity A pointing to entity B (e.g., tasks.group_id REFERENCES task_groups):
11
+ * - A.droppable_on.B: drag A onto B → update A.group_id = B.id
12
+ * - A.accepts.B: drag B onto A → update A.group_id = B.id (same operation, different drag direction)
13
+ *
14
+ * 2. Junction table (M2M):
15
+ * - Both entities are droppable_on each other via junction insert
16
+ * - Both entities accept each other
17
+ *
18
+ * 3. Self-referential FK:
19
+ * - Entity is droppable on itself
20
+ *
21
+ * Visual semantics:
22
+ * - "containment": node moves inside container (tree structures, folders)
23
+ * - "frame": visual bounding box around members (sets, collections)
24
+ * - "edge": arrow drawn between nodes (dependencies, relationships)
25
+ * - "badge": tag/chip displayed on node (assignments, references)
26
+ */
27
+
28
+ export class DropSemanticsCodegen {
29
+ /**
30
+ * @param {Object} entities - Map of tableName -> entityConfig
31
+ */
32
+ constructor(entities) {
33
+ this.entities = entities;
34
+ }
35
+
36
+ /**
37
+ * Generate the complete drop semantics manifest
38
+ * @returns {Object} Drop semantics JSON structure
39
+ */
40
+ generate() {
41
+ // Initialize result structure for all entities
42
+ const semantics = {};
43
+ for (const tableName of Object.keys(this.entities)) {
44
+ semantics[tableName] = {
45
+ droppable_on: {},
46
+ accepts: {}
47
+ };
48
+ }
49
+
50
+ // Process all FK relationships (adds to source's droppable_on and accepts)
51
+ for (const [tableName, config] of Object.entries(this.entities)) {
52
+ this._processFKRelationships(tableName, config, semantics);
53
+ }
54
+
55
+ // Process all M2M relationships (adds to source's droppable_on and accepts)
56
+ for (const [tableName, config] of Object.entries(this.entities)) {
57
+ this._processM2MRelationships(tableName, config, semantics);
58
+ }
59
+
60
+ // Second pass: populate target's accepts from source's droppable_on
61
+ // This ensures that if posts.droppable_on.tags exists, tags.accepts.posts also exists
62
+ this._populateTargetAccepts(semantics);
63
+
64
+ // Filter out entities with no semantics
65
+ const result = { entities: {} };
66
+ for (const [tableName, sem] of Object.entries(semantics)) {
67
+ if (Object.keys(sem.droppable_on).length > 0 || Object.keys(sem.accepts).length > 0) {
68
+ result.entities[tableName] = sem;
69
+ }
70
+ }
71
+
72
+ return result;
73
+ }
74
+
75
+ /**
76
+ * Populate target's accepts from source's droppable_on
77
+ * If A.droppable_on.B exists, then B.accepts.A should also exist
78
+ * @private
79
+ */
80
+ _populateTargetAccepts(semantics) {
81
+ for (const [sourceTable, sem] of Object.entries(semantics)) {
82
+ for (const [targetTable, actions] of Object.entries(sem.droppable_on)) {
83
+ // Skip if target doesn't exist in our entities
84
+ if (!semantics[targetTable]) continue;
85
+
86
+ // For each droppable_on action, create a corresponding accepts entry
87
+ for (const action of actions) {
88
+ if (!semantics[targetTable].accepts[sourceTable]) {
89
+ semantics[targetTable].accepts[sourceTable] = [];
90
+ }
91
+
92
+ // Check if this exact relation already exists (avoid duplicates)
93
+ const exists = semantics[targetTable].accepts[sourceTable].some(
94
+ a => a.relation === action.relation && a.type === action.type
95
+ );
96
+
97
+ if (!exists) {
98
+ // Create the inverse action - swap source and target in params
99
+ const inverseAction = this._createInverseAction(action, sourceTable, targetTable);
100
+ semantics[targetTable].accepts[sourceTable].push(inverseAction);
101
+ }
102
+ }
103
+ }
104
+ }
105
+ }
106
+
107
+ /**
108
+ * Create an inverse action for accepts (swap source/target perspective)
109
+ * @private
110
+ */
111
+ _createInverseAction(action, sourceTable, targetTable) {
112
+ // For the inverse, @source becomes what was @target and vice versa
113
+ const swapRefs = (params) => {
114
+ const swapped = {};
115
+ for (const [key, value] of Object.entries(params)) {
116
+ if (typeof value === 'string') {
117
+ swapped[key] = value
118
+ .replace('@source.', '@__tmp__.')
119
+ .replace('@target.', '@source.')
120
+ .replace('@__tmp__.', '@target.');
121
+ } else {
122
+ swapped[key] = value;
123
+ }
124
+ }
125
+ return swapped;
126
+ };
127
+
128
+ // For inverse (accepts), visual is typically badge unless it's an edge
129
+ let inverseVisual = 'badge';
130
+ if (action.visual === 'edge') {
131
+ inverseVisual = 'edge'; // Edges are bidirectional visually
132
+ }
133
+
134
+ const result = {
135
+ ...action,
136
+ action: action.type === 'fk' ? 'assign' : action.action,
137
+ visual: inverseVisual,
138
+ label: action.type === 'fk'
139
+ ? this._generateLabel(action.relation, 'assign')
140
+ : action.label,
141
+ operation: {
142
+ ...action.operation,
143
+ params: swapRefs(action.operation.params)
144
+ },
145
+ remove_operation: action.remove_operation ? {
146
+ ...action.remove_operation,
147
+ params: swapRefs(action.remove_operation.params)
148
+ } : undefined
149
+ };
150
+
151
+ // For edge visual, swap direction
152
+ if (action.direction === 'source_to_target') {
153
+ result.direction = 'target_to_source';
154
+ }
155
+
156
+ return result;
157
+ }
158
+
159
+ /**
160
+ * Process FK relationships for an entity
161
+ * @private
162
+ */
163
+ _processFKRelationships(tableName, config, semantics) {
164
+ const fkIncludes = config.fkIncludes || {};
165
+ const primaryKey = config.primaryKey || ['id'];
166
+
167
+ for (const [alias, targetTable] of Object.entries(fkIncludes)) {
168
+ // Skip reverse FKs (child arrays) - indicated when alias === targetTable
169
+ if (alias === targetTable) {
170
+ continue;
171
+ }
172
+
173
+ const fkColumn = alias.endsWith('_id') ? alias : `${alias}_id`;
174
+ const isSelfReferential = targetTable === tableName;
175
+
176
+ // 1. Source (tableName) can be dropped onto target
177
+ // e.g., tasks.droppable_on.task_groups - drag task onto group
178
+ if (!semantics[tableName].droppable_on[targetTable]) {
179
+ semantics[tableName].droppable_on[targetTable] = [];
180
+ }
181
+
182
+ const action = isSelfReferential ? this._getSelfReferentialAction(fkColumn) : 'move';
183
+ const visual = this._inferVisual('fk', targetTable, isSelfReferential);
184
+
185
+ // Determine if this is primarily an "accepts" relationship (assign pattern)
186
+ // e.g., tasks.assigned_to_user_id - natural gesture is to drop user onto task
187
+ const isAssignPattern = this._isAssignPattern(alias);
188
+
189
+ semantics[tableName].droppable_on[targetTable].push({
190
+ relation: fkColumn,
191
+ type: 'fk',
192
+ action: action,
193
+ visual: visual,
194
+ label: this._generateLabel(fkColumn, action),
195
+ ...(isAssignPattern && { primary_direction: 'accepts' }),
196
+ operation: {
197
+ method: 'save',
198
+ entity: tableName,
199
+ params: this._buildPKParams(primaryKey, '@source', { [fkColumn]: '@target.id' })
200
+ },
201
+ removable: true,
202
+ remove_operation: {
203
+ method: 'save',
204
+ entity: tableName,
205
+ params: this._buildPKParams(primaryKey, '@source', { [fkColumn]: null })
206
+ }
207
+ });
208
+
209
+ // 2. Source (tableName) accepts target being dropped on it
210
+ // e.g., tasks.accepts.users - drag user onto task to assign
211
+ // This only makes sense for non-self-referential FKs
212
+ if (!isSelfReferential && this.entities[targetTable]) {
213
+ if (!semantics[tableName].accepts[targetTable]) {
214
+ semantics[tableName].accepts[targetTable] = [];
215
+ }
216
+
217
+ // For accepts, visual is always badge (something is being attached to this entity)
218
+ semantics[tableName].accepts[targetTable].push({
219
+ relation: fkColumn,
220
+ type: 'fk',
221
+ action: 'assign',
222
+ visual: 'badge',
223
+ label: this._generateLabel(alias, 'assign'),
224
+ operation: {
225
+ method: 'save',
226
+ entity: tableName, // Update the entity with the FK
227
+ params: this._buildPKParams(primaryKey, '@target', { [fkColumn]: '@source.id' })
228
+ },
229
+ removable: true,
230
+ remove_operation: {
231
+ method: 'save',
232
+ entity: tableName,
233
+ params: this._buildPKParams(primaryKey, '@target', { [fkColumn]: null })
234
+ }
235
+ });
236
+ }
237
+ }
238
+ }
239
+
240
+ /**
241
+ * Process M2M relationships for an entity
242
+ * @private
243
+ */
244
+ _processM2MRelationships(tableName, config, semantics) {
245
+ const manyToMany = config.manyToMany || {};
246
+
247
+ for (const [relationKey, m2mConfig] of Object.entries(manyToMany)) {
248
+ const { junction_table, local_key, foreign_key, target_entity } = m2mConfig;
249
+
250
+ if (!junction_table || !local_key || !foreign_key || !target_entity) {
251
+ continue;
252
+ }
253
+
254
+ const isSelfReferential = target_entity === tableName;
255
+ const visual = this._inferVisual('junction', target_entity, isSelfReferential);
256
+
257
+ // 1. Source (tableName) can be dropped onto target
258
+ if (!semantics[tableName].droppable_on[target_entity]) {
259
+ semantics[tableName].droppable_on[target_entity] = [];
260
+ }
261
+
262
+ const baseEntry = {
263
+ relation: junction_table,
264
+ type: 'junction',
265
+ action: 'link',
266
+ visual: visual,
267
+ label: this._generateLabel(junction_table, 'link'),
268
+ operation: {
269
+ method: 'save',
270
+ entity: junction_table,
271
+ params: {
272
+ [local_key]: '@source.id',
273
+ [foreign_key]: '@target.id'
274
+ }
275
+ },
276
+ removable: true,
277
+ remove_operation: {
278
+ method: 'delete',
279
+ entity: junction_table,
280
+ params: {
281
+ [local_key]: '@source.id',
282
+ [foreign_key]: '@target.id'
283
+ }
284
+ },
285
+ ...(isSelfReferential && { self_referential: true })
286
+ };
287
+
288
+ // For edge visual (self-referential), add direction hint
289
+ if (visual === 'edge') {
290
+ baseEntry.direction = 'source_to_target';
291
+ }
292
+
293
+ semantics[tableName].droppable_on[target_entity].push(baseEntry);
294
+
295
+ // 2. Source (tableName) accepts target being dropped on it
296
+ // For M2M, the operation is symmetric but params swap
297
+ if (!isSelfReferential && this.entities[target_entity]) {
298
+ if (!semantics[tableName].accepts[target_entity]) {
299
+ semantics[tableName].accepts[target_entity] = [];
300
+ }
301
+
302
+ // For accepts on M2M, use frame if target is a set, otherwise badge
303
+ const acceptVisual = this._matchesSetPattern(target_entity) ? 'frame' : 'badge';
304
+
305
+ semantics[tableName].accepts[target_entity].push({
306
+ relation: junction_table,
307
+ type: 'junction',
308
+ action: 'link',
309
+ visual: acceptVisual,
310
+ label: this._generateLabel(junction_table, 'link'),
311
+ operation: {
312
+ method: 'save',
313
+ entity: junction_table,
314
+ params: {
315
+ [local_key]: '@target.id', // target is the entity with the M2M config
316
+ [foreign_key]: '@source.id' // source is what's being dropped
317
+ }
318
+ },
319
+ removable: true,
320
+ remove_operation: {
321
+ method: 'delete',
322
+ entity: junction_table,
323
+ params: {
324
+ [local_key]: '@target.id',
325
+ [foreign_key]: '@source.id'
326
+ }
327
+ }
328
+ });
329
+ }
330
+ }
331
+ }
332
+
333
+ /**
334
+ * Build params object with primary key fields
335
+ * @private
336
+ */
337
+ _buildPKParams(primaryKey, refPrefix, additionalParams) {
338
+ const params = {};
339
+
340
+ for (const pkField of primaryKey) {
341
+ params[pkField] = `${refPrefix}.${pkField}`;
342
+ }
343
+
344
+ Object.assign(params, additionalParams);
345
+ return params;
346
+ }
347
+
348
+ /**
349
+ * Infer the visual representation type for a relationship
350
+ * @private
351
+ * @param {string} type - 'fk' or 'junction'
352
+ * @param {string} targetTable - The target entity name
353
+ * @param {boolean} isSelfReferential - Whether this is a self-referential relation
354
+ * @returns {string} Visual type: 'containment', 'frame', 'edge', or 'badge'
355
+ */
356
+ _inferVisual(type, targetTable, isSelfReferential) {
357
+ // Rule 1: Self-referential junction → edge (arrows between same entity type)
358
+ if (type === 'junction' && isSelfReferential) {
359
+ return 'edge';
360
+ }
361
+
362
+ // Rule 2: Self-referential FK → containment (tree/hierarchy)
363
+ if (type === 'fk' && isSelfReferential) {
364
+ return 'containment';
365
+ }
366
+
367
+ // Rule 3: Target entity has self-referential FK → it's a tree/container
368
+ if (this._isTreeEntity(targetTable)) {
369
+ return 'containment';
370
+ }
371
+
372
+ // Rule 4: Naming convention fallback
373
+ if (this._matchesContainerPattern(targetTable)) {
374
+ return 'containment';
375
+ }
376
+
377
+ if (this._matchesSetPattern(targetTable)) {
378
+ return 'frame';
379
+ }
380
+
381
+ // Default: badge (tag/chip on node)
382
+ return 'badge';
383
+ }
384
+
385
+ /**
386
+ * Check if an entity has a self-referential FK (making it a tree structure)
387
+ * @private
388
+ */
389
+ _isTreeEntity(tableName) {
390
+ const config = this.entities[tableName];
391
+ if (!config) return false;
392
+
393
+ const fkIncludes = config.fkIncludes || {};
394
+ for (const [alias, target] of Object.entries(fkIncludes)) {
395
+ if (target === tableName) {
396
+ return true;
397
+ }
398
+ }
399
+ return false;
400
+ }
401
+
402
+ /**
403
+ * Check if entity name matches container patterns
404
+ * @private
405
+ */
406
+ _matchesContainerPattern(tableName) {
407
+ const patterns = ['_groups', '_folders', '_categories', '_containers', '_parents'];
408
+ return patterns.some(p => tableName.endsWith(p));
409
+ }
410
+
411
+ /**
412
+ * Check if entity name matches set/collection patterns
413
+ * @private
414
+ */
415
+ _matchesSetPattern(tableName) {
416
+ const patterns = ['_sets', '_collections', '_lists', '_pools', '_batches'];
417
+ return patterns.some(p => tableName.endsWith(p));
418
+ }
419
+
420
+ /**
421
+ * Determine action type for self-referential FK
422
+ * @private
423
+ */
424
+ _getSelfReferentialAction(fkColumn) {
425
+ if (fkColumn.includes('parent')) {
426
+ return 'reparent';
427
+ }
428
+ if (fkColumn.includes('depends') || fkColumn.includes('dependency')) {
429
+ return 'link';
430
+ }
431
+ return 'nest';
432
+ }
433
+
434
+ /**
435
+ * Check if an FK alias represents an "assign" pattern
436
+ * where the natural gesture is to drop the target onto the source
437
+ * e.g., "assigned_to_user" - you drop user onto task, not task onto user
438
+ * @private
439
+ */
440
+ _isAssignPattern(alias) {
441
+ const assignPatterns = [
442
+ /^assigned_to_/,
443
+ /^created_by_/,
444
+ /^updated_by_/,
445
+ /^owned_by_/,
446
+ /^approved_by_/,
447
+ /^reviewed_by_/,
448
+ /^managed_by_/,
449
+ /^author$/,
450
+ /^owner$/,
451
+ /^assignee$/,
452
+ /^reviewer$/,
453
+ /^approver$/
454
+ ];
455
+
456
+ return assignPatterns.some(pattern => pattern.test(alias));
457
+ }
458
+
459
+ /**
460
+ * Generate human-readable label from relation name
461
+ * @private
462
+ */
463
+ _generateLabel(relationName, action) {
464
+ // Remove common suffixes and extract the core noun
465
+ let name = relationName
466
+ .replace(/_id$/, '')
467
+ .replace(/^fk_/, '');
468
+
469
+ // Strip preposition patterns to get the core entity name
470
+ // "assigned_to_user" → "user"
471
+ // "depends_on_task" → "task" (but keep "depends on" for special handling)
472
+ // "created_by_user" → "user"
473
+ // "owner_org" → "org"
474
+ const prepositionPatterns = [
475
+ /^assigned_to_/,
476
+ /^created_by_/,
477
+ /^updated_by_/,
478
+ /^owned_by_/,
479
+ /^belongs_to_/,
480
+ /^managed_by_/,
481
+ /^approved_by_/,
482
+ /^reviewed_by_/
483
+ ];
484
+
485
+ let strippedName = name;
486
+ for (const pattern of prepositionPatterns) {
487
+ if (pattern.test(name)) {
488
+ strippedName = name.replace(pattern, '');
489
+ break;
490
+ }
491
+ }
492
+
493
+ // Convert snake_case to Title Case
494
+ const words = strippedName.split('_').map(word =>
495
+ word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()
496
+ );
497
+
498
+ // Map action to verb
499
+ const verbs = {
500
+ 'move': 'Move to',
501
+ 'assign': 'Assign',
502
+ 'link': 'Add',
503
+ 'nest': 'Set as child of',
504
+ 'reparent': 'Set parent'
505
+ };
506
+
507
+ const verb = verbs[action] || '';
508
+
509
+ // Special handling for junction tables (link action)
510
+ if (action === 'link') {
511
+ const singularName = this._singularize(words.join(' '));
512
+ return `Add ${singularName.toLowerCase()}`;
513
+ }
514
+
515
+ return `${verb} ${words.join(' ').toLowerCase()}`.trim();
516
+ }
517
+
518
+ /**
519
+ * Simple singularization
520
+ * @private
521
+ */
522
+ _singularize(word) {
523
+ if (word.endsWith('ies')) {
524
+ return word.slice(0, -3) + 'y';
525
+ }
526
+ if (word.endsWith('es') && !word.endsWith('ses')) {
527
+ return word.slice(0, -2);
528
+ }
529
+ if (word.endsWith('s') && !word.endsWith('ss')) {
530
+ return word.slice(0, -1);
531
+ }
532
+ return word;
533
+ }
534
+ }
535
+
536
+ /**
537
+ * Generate drop semantics from parsed entities
538
+ * @param {Array|Object} entities - Array of entity configs or map of tableName -> config
539
+ * @returns {Object} Drop semantics manifest
540
+ */
541
+ export function generateDropSemantics(entities) {
542
+ // Convert array to map if needed
543
+ let entityMap = entities;
544
+ if (Array.isArray(entities)) {
545
+ entityMap = {};
546
+ for (const entity of entities) {
547
+ entityMap[entity.tableName] = entity;
548
+ }
549
+ }
550
+
551
+ const gen = new DropSemanticsCodegen(entityMap);
552
+ return gen.generate();
553
+ }
@@ -433,6 +433,71 @@ $$ LANGUAGE plpgsql IMMUTABLE;`;
433
433
  jsonb_build_object('${firstParam}', COALESCE((p_new->>'${relFK}')::int, (p_old->>'${relFK}')::int))
434
434
  ];`;
435
435
  }
436
+
437
+ /**
438
+ * Extract all tables in scope for this subscribable
439
+ * Used for efficient event filtering - only events from these tables need consideration
440
+ * @returns {string[]} Array of table names
441
+ */
442
+ extractScopeTables() {
443
+ const tables = new Set([this.rootEntity]);
444
+
445
+ const extractFromRelations = (relations) => {
446
+ for (const [relName, relConfig] of Object.entries(relations || {})) {
447
+ const entity = typeof relConfig === 'string' ? relConfig : relConfig.entity;
448
+ if (entity) tables.add(entity);
449
+
450
+ // Handle nested relations (include or relations)
451
+ if (typeof relConfig === 'object') {
452
+ if (relConfig.include) {
453
+ extractFromRelations(relConfig.include);
454
+ }
455
+ if (relConfig.relations) {
456
+ extractFromRelations(relConfig.relations);
457
+ }
458
+ }
459
+ }
460
+ };
461
+
462
+ extractFromRelations(this.relations);
463
+ return Array.from(tables);
464
+ }
465
+
466
+ /**
467
+ * Build path mapping for client-side patching
468
+ * Maps table names to their path in the document structure
469
+ * @returns {Object} Map of table name -> document path
470
+ */
471
+ buildPathMapping() {
472
+ const paths = {};
473
+
474
+ // Root entity maps to top level
475
+ paths[this.rootEntity] = '.';
476
+
477
+ const buildPaths = (relations, parentPath = '') => {
478
+ for (const [relName, relConfig] of Object.entries(relations || {})) {
479
+ const entity = typeof relConfig === 'string' ? relConfig : relConfig.entity;
480
+ const currentPath = parentPath ? `${parentPath}.${relName}` : relName;
481
+
482
+ if (entity) {
483
+ paths[entity] = currentPath;
484
+ }
485
+
486
+ // Handle nested relations
487
+ if (typeof relConfig === 'object') {
488
+ if (relConfig.include) {
489
+ buildPaths(relConfig.include, currentPath);
490
+ }
491
+ if (relConfig.relations) {
492
+ buildPaths(relConfig.relations, currentPath);
493
+ }
494
+ }
495
+ }
496
+ };
497
+
498
+ buildPaths(this.relations);
499
+ return paths;
500
+ }
436
501
  }
437
502
 
438
503
  /**
@@ -444,3 +509,23 @@ export function generateSubscribable(subscribable) {
444
509
  const codegen = new SubscribableCodegen(subscribable);
445
510
  return codegen.generate();
446
511
  }
512
+
513
+ /**
514
+ * Extract scope tables from subscribable config
515
+ * @param {Object} subscribable - Subscribable configuration
516
+ * @returns {string[]} Array of table names in scope
517
+ */
518
+ export function extractScopeTables(subscribable) {
519
+ const codegen = new SubscribableCodegen(subscribable);
520
+ return codegen.extractScopeTables();
521
+ }
522
+
523
+ /**
524
+ * Build path mapping from subscribable config
525
+ * @param {Object} subscribable - Subscribable configuration
526
+ * @returns {Object} Map of table name -> document path
527
+ */
528
+ export function buildPathMapping(subscribable) {
529
+ const codegen = new SubscribableCodegen(subscribable);
530
+ return codegen.buildPathMapping();
531
+ }
@@ -11,6 +11,7 @@ import { generateNotificationFunction } from './codegen/notification-codegen.js'
11
11
  import { generateGraphRuleFunctions } from './codegen/graph-rules-codegen.js';
12
12
  import { generateSubscribable } from './codegen/subscribable-codegen.js';
13
13
  import { generateAuthFunctions } from './codegen/auth-codegen.js';
14
+ import { generateDropSemantics } from './codegen/drop-semantics-codegen.js';
14
15
  import crypto from 'crypto';
15
16
 
16
17
  export class DZQLCompiler {
@@ -190,7 +191,7 @@ export class DZQLCompiler {
190
191
  /**
191
192
  * Compile from SQL file
192
193
  * @param {string} sqlContent - SQL file content
193
- * @returns {Object} Compilation results
194
+ * @returns {Object} Compilation results with dropSemantics
194
195
  */
195
196
  compileFromSQL(sqlContent) {
196
197
  // Use parseEntitiesFromSQL to properly extract custom functions
@@ -200,11 +201,20 @@ export class DZQLCompiler {
200
201
  return {
201
202
  results: [],
202
203
  errors: [],
203
- summary: { total: 0, successful: 0, failed: 0 }
204
+ summary: { total: 0, successful: 0, failed: 0 },
205
+ dropSemantics: { entities: {} }
204
206
  };
205
207
  }
206
208
 
207
- return this.compileAll(entities);
209
+ const compilationResult = this.compileAll(entities);
210
+
211
+ // Generate drop semantics from all parsed entities
212
+ const dropSemantics = generateDropSemantics(entities);
213
+
214
+ return {
215
+ ...compilationResult,
216
+ dropSemantics
217
+ };
208
218
  }
209
219
 
210
220
  /**