json-rest-api 1.0.10 → 1.0.13

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 (117) hide show
  1. package/.claude/settings.local.json +9 -1
  2. package/AGENTS.md +34 -0
  3. package/CLAUDE.md +26 -1
  4. package/CRUSH.md +55 -0
  5. package/README.md +3 -3
  6. package/TODO.todo +8 -0
  7. package/docs/API.md +17 -18
  8. package/docs/COMPARISON.md +18 -13
  9. package/docs/GUIDE/GUIDE_1_Initial_Setup.md +17 -21
  10. package/docs/GUIDE/GUIDE_2_1_The_Starting_Point.md +33 -2
  11. package/docs/GUIDE/GUIDE_2_2_Manipulating_And_Searching_Tables.md +61 -7
  12. package/docs/GUIDE/GUIDE_2_3_BelongsTo_Relationships.md +58 -61
  13. package/docs/GUIDE/GUIDE_2_4_HasMany_Records.md +37 -37
  14. package/docs/GUIDE/GUIDE_2_5_HasMany_Polymorphic.md +103 -87
  15. package/docs/GUIDE/GUIDE_2_6_Many_To_Many.md +37 -34
  16. package/docs/GUIDE/GUIDE_2_7_Pagination_And_Ordering.md +45 -42
  17. package/docs/GUIDE/GUIDE_2_8_Effects_of_PUT_and_PATCH.md +3 -3
  18. package/docs/GUIDE/GUIDE_2_Data_And_Relations.md +1 -1
  19. package/docs/GUIDE/GUIDE_3_Field_Transformations.md +5 -5
  20. package/docs/GUIDE/GUIDE_4_Authentication_And_Permissions.md +498 -755
  21. package/docs/GUIDE/GUIDE_6_Positioning.md +1 -3
  22. package/docs/GUIDE/GUIDE_7_Hooks_Data_Management_And_Plugins.md +4 -5
  23. package/docs/GUIDE/GUIDE_X_Bulk_Operations.md +15 -6
  24. package/docs/GUIDE/GUIDE_X_Cors.md +2 -5
  25. package/docs/GUIDE/GUIDE_X_File_Uploads.md +2 -3
  26. package/docs/GUIDE/GUIDE_X_Multihome.md +1 -4
  27. package/docs/GUIDE/GUIDE_X_SocketIO.md +0 -2
  28. package/docs/GUIDE/GUIDE_X_URL_Management.md +317 -0
  29. package/docs/GUIDE/index.md +3 -0
  30. package/docs/QUICKSTART.md +12 -12
  31. package/docs/index.md +2 -2
  32. package/docs/mini-guides/file-uploads-complete.md +0 -1
  33. package/examples/url-override-example.js +137 -0
  34. package/index.js +7 -1
  35. package/package.json +7 -11
  36. package/plugins/core/bulk-operations-plugin.js +2 -0
  37. package/plugins/core/connectors/express-plugin.js +43 -24
  38. package/plugins/core/connectors/lib/request-helpers.js +2 -2
  39. package/plugins/core/lib/anyapi/anyapi-registry.js +821 -0
  40. package/plugins/core/lib/anyapi/query/anyapi-query-adapter.js +647 -0
  41. package/plugins/core/lib/anyapi/schema-utils.js +186 -0
  42. package/plugins/core/lib/anyapi/utils/descriptor-helpers.js +113 -0
  43. package/plugins/core/lib/dbTablesOperations.js +305 -0
  44. package/plugins/core/lib/querying/knex-cross-table-search.js +64 -108
  45. package/plugins/core/lib/querying/knex-json-api-transformers-querying.js +21 -27
  46. package/plugins/core/lib/querying/knex-pagination-helpers.js +36 -20
  47. package/plugins/core/lib/querying/knex-query-helpers-base.js +49 -14
  48. package/plugins/core/lib/querying/knex-query-helpers.js +378 -222
  49. package/plugins/core/lib/querying/knex-relationship-includes.js +262 -87
  50. package/plugins/core/lib/querying/url-helpers.js +119 -0
  51. package/plugins/core/lib/querying-writing/compile-schemas.js +36 -28
  52. package/plugins/core/lib/querying-writing/database-capabilities.js +10 -9
  53. package/plugins/core/lib/querying-writing/knex-field-helpers.js +9 -3
  54. package/plugins/core/lib/querying-writing/knex-json-api-transformers.js +2 -2
  55. package/plugins/core/lib/querying-writing/payload-validators.js +10 -16
  56. package/plugins/core/lib/querying-writing/schema-helpers.js +52 -60
  57. package/plugins/core/lib/querying-writing/scope-validations.js +78 -22
  58. package/plugins/core/lib/querying-writing/simplified-helpers.js +208 -61
  59. package/plugins/core/lib/storage/storage-adapter.js +160 -0
  60. package/plugins/core/lib/writing/relationship-processor.js +4 -11
  61. package/plugins/core/rest-api-access.js +456 -0
  62. package/plugins/core/rest-api-anyapi-knex-plugin.js +2285 -0
  63. package/plugins/core/rest-api-knex-plugin.js +226 -87
  64. package/plugins/core/rest-api-multihome-plugin.js +89 -27
  65. package/plugins/core/rest-api-plugin-hooks/turn-scope-init-into-vars.js +0 -1
  66. package/plugins/core/rest-api-plugin-methods/check-permissions.js +1 -5
  67. package/plugins/core/rest-api-plugin-methods/common.js +46 -10
  68. package/plugins/core/rest-api-plugin-methods/delete-relationship.js +31 -27
  69. package/plugins/core/rest-api-plugin-methods/delete.js +1 -4
  70. package/plugins/core/rest-api-plugin-methods/enrich-attributes.js +13 -1
  71. package/plugins/core/rest-api-plugin-methods/get-related.js +114 -56
  72. package/plugins/core/rest-api-plugin-methods/get-relationship.js +4 -3
  73. package/plugins/core/rest-api-plugin-methods/get.js +2 -5
  74. package/plugins/core/rest-api-plugin-methods/patch.js +15 -16
  75. package/plugins/core/rest-api-plugin-methods/post-relationship.js +21 -14
  76. package/plugins/core/rest-api-plugin-methods/post.js +23 -11
  77. package/plugins/core/rest-api-plugin-methods/put.js +18 -21
  78. package/plugins/core/rest-api-plugin-methods/query.js +3 -4
  79. package/plugins/core/rest-api-plugin-methods/release.js +13 -0
  80. package/plugins/core/rest-api-plugin.js +3 -4
  81. package/plugins/core/rest-api-positioning-plugin.js +253 -76
  82. package/plugins/core/socketio-plugin.js +436 -502
  83. package/quickTest.js +2 -2
  84. package/scripts/debug-anyapi-review-filter.mjs +63 -0
  85. package/scripts/inspect-anyapi-descriptor-country.mjs +19 -0
  86. package/scripts/inspect-anyapi-descriptor.mjs +19 -0
  87. package/test-debug.js +59 -0
  88. package/tests/README.md +7 -1
  89. package/tests/anyapi-basic.test.js +102 -0
  90. package/tests/anyapi-cursor-pagination.test.js +144 -0
  91. package/tests/computed-fields.test.js +27 -12
  92. package/tests/cors.test.js +5 -6
  93. package/tests/curl.test.js +1 -1
  94. package/tests/custom-idproperty-relationships.test.js +37 -29
  95. package/tests/field-getters.test.js +25 -7
  96. package/tests/fixtures/api-configs.js +576 -150
  97. package/tests/helpers/storage-mode.js +65 -0
  98. package/tests/helpers/test-utils.js +85 -3
  99. package/tests/include-depth-validation.test.js +35 -1
  100. package/tests/nested-includes.test.js +130 -103
  101. package/tests/pagination-cursor-multifield.test.js +17 -13
  102. package/tests/pagination-enhanced.test.js +19 -24
  103. package/tests/polymorphic-simple.test.js +9 -9
  104. package/tests/positioning-advanced.test.js +19 -19
  105. package/tests/positioning-extended.test.js +25 -25
  106. package/tests/positioning-multifilter.test.js +62 -62
  107. package/tests/positioning.test.js +7 -7
  108. package/tests/queries.test.js +5 -5
  109. package/tests/query-limits.test.js +77 -92
  110. package/tests/rest-api-access.test.js +206 -0
  111. package/tests/searchschema-merge.test.js +182 -0
  112. package/tests/socketio.test.js +33 -14
  113. package/tests/virtual-fields.test.js +0 -1
  114. package/docs/DOCS_ALL.md +0 -21894
  115. package/plugins/core/jwt-auth-plugin.js +0 -897
  116. package/plugins/core/lib/jwt-auth-helpers.js +0 -130
  117. package/tests/jwt-auth.test.js +0 -692
@@ -1,232 +1,409 @@
1
1
  import { requirePackage } from 'hooked-api';
2
2
 
3
- // WeakMap to store pending broadcasts per transaction
4
3
  const pendingBroadcasts = new WeakMap();
5
4
 
6
- export const SocketIOPlugin = {
7
- name: 'socketio',
8
- dependencies: ['rest-api', 'jwt-auth'],
5
+ function normalizeAuthContext(auth) {
6
+ if (!auth) return null;
7
+ if (typeof auth !== 'object') return null;
8
+ const normalized = { ...auth };
9
+ if (normalized.userId !== undefined && normalized.userId !== null) {
10
+ normalized.userId = String(normalized.userId);
11
+ }
12
+ if (normalized.roles && !Array.isArray(normalized.roles)) {
13
+ normalized.roles = [normalized.roles];
14
+ }
15
+ return normalized;
16
+ }
9
17
 
10
- async install({ api, addHook, log, scopes, helpers, vars, runHooks }) {
11
- // Dynamic imports for socket.io and related packages
12
- let Server, createAdapter, createClient;
13
-
14
- try {
15
- ({ Server } = await import('socket.io'));
16
- } catch (e) {
17
- requirePackage('socket.io', 'socketio',
18
- 'Socket.IO is required for WebSocket support. This is a peer dependency.');
18
+ function matchesFilters(record, filters, searchSchemaStructure) {
19
+ if (!filters || Object.keys(filters).length === 0) return true;
20
+
21
+ if (!record) return false;
22
+
23
+ if (!searchSchemaStructure || Object.keys(searchSchemaStructure).length === 0) {
24
+ return false;
25
+ }
26
+
27
+ for (const [filterKey, filterValue] of Object.entries(filters)) {
28
+ const fieldDef = searchSchemaStructure[filterKey];
29
+ if (!fieldDef) continue;
30
+
31
+ const fieldName = fieldDef.actualField || filterKey;
32
+
33
+ let recordValue;
34
+ if (fieldDef.isRelationship) {
35
+ const relationshipData = record.relationships?.[filterKey]?.data;
36
+ recordValue = relationshipData?.id;
37
+ } else {
38
+ recordValue = record.attributes?.[fieldName] ?? record[fieldName];
19
39
  }
20
-
21
- let io;
22
40
 
23
- // Helper function to match filters using searchSchema
24
- function matchesFilters(record, filters, searchSchema) {
25
- if (!filters || Object.keys(filters).length === 0) {
26
- return true;
41
+ if (recordValue === null || recordValue === undefined) {
42
+ if (filterValue !== null && filterValue !== undefined) {
43
+ return false;
27
44
  }
45
+ continue;
46
+ }
28
47
 
29
- if (!searchSchema) {
30
- return true; // No schema means no filtering
48
+ if (typeof fieldDef.filterOperator === 'function') {
49
+ if (!fieldDef.filterRecord?.(record, filterValue)) {
50
+ return false;
31
51
  }
52
+ continue;
53
+ }
32
54
 
33
- // Check each filter
34
- for (const [filterKey, filterValue] of Object.entries(filters)) {
35
- const fieldDef = searchSchema.structure[filterKey];
36
- if (!fieldDef) continue; // Skip unknown filters
37
-
38
- // Get the actual field name and value
39
- const fieldName = fieldDef.actualField || filterKey;
40
-
41
- // Check if this is a foreign key field (ends with _id and has belongsTo)
42
- let recordValue;
43
- if (filterKey.endsWith('_id') && fieldDef.belongsTo) {
44
- // For foreign keys, check the relationship
45
- const relationName = fieldDef.as || fieldDef.belongsTo;
46
- const relationshipData = record.relationships?.[relationName]?.data;
47
- recordValue = relationshipData?.id;
48
- } else {
49
- // Handle both JSON:API structure (attributes nested) and flat structure (minimal records)
50
- recordValue = record.attributes?.[fieldName] ?? record[fieldName];
51
- }
55
+ const operator = fieldDef.filterOperator || '=';
52
56
 
53
- // Handle null/undefined
54
- if (recordValue === null || recordValue === undefined) {
55
- if (filterValue !== null && filterValue !== undefined) {
57
+ switch (operator) {
58
+ case 'like':
59
+ if (!String(recordValue).toLowerCase().includes(String(filterValue).toLowerCase())) {
60
+ return false;
61
+ }
62
+ break;
63
+ case 'in':
64
+ if (Array.isArray(filterValue)) {
65
+ if (!filterValue.includes(recordValue)) {
56
66
  return false;
57
67
  }
58
- continue;
68
+ } else if (recordValue !== filterValue) {
69
+ return false;
59
70
  }
60
-
61
- // Check if filterOperator is a function
62
- if (typeof fieldDef.filterOperator === 'function') {
63
- // Must use filterRecord (validated during subscription)
64
- if (!fieldDef.filterRecord(record, filterValue)) {
71
+ break;
72
+ case 'between':
73
+ if (Array.isArray(filterValue) && filterValue.length === 2) {
74
+ if (recordValue < filterValue[0] || recordValue > filterValue[1]) {
65
75
  return false;
66
76
  }
67
- } else {
68
- // Use simple operator logic
69
- const operator = fieldDef.filterOperator || '=';
70
-
71
- switch (operator) {
72
- case 'like':
73
- if (!String(recordValue).toLowerCase().includes(String(filterValue).toLowerCase())) {
74
- return false;
75
- }
76
- break;
77
-
78
- case 'in':
79
- if (Array.isArray(filterValue)) {
80
- if (!filterValue.includes(recordValue)) {
81
- return false;
82
- }
83
- } else if (recordValue !== filterValue) {
84
- return false;
85
- }
86
- break;
77
+ }
78
+ break;
79
+ case '>':
80
+ if (!(recordValue > filterValue)) return false;
81
+ break;
82
+ case '>=':
83
+ if (!(recordValue >= filterValue)) return false;
84
+ break;
85
+ case '<':
86
+ if (!(recordValue < filterValue)) return false;
87
+ break;
88
+ case '<=':
89
+ if (!(recordValue <= filterValue)) return false;
90
+ break;
91
+ case '!=':
92
+ case '<>':
93
+ if (recordValue === filterValue) return false;
94
+ break;
95
+ case '=':
96
+ default:
97
+ if (fieldDef.isRelationship) {
98
+ if (String(recordValue) !== String(filterValue)) {
99
+ return false;
100
+ }
101
+ } else if (recordValue !== filterValue) {
102
+ return false;
103
+ }
104
+ }
105
+ }
87
106
 
88
- case 'between':
89
- if (Array.isArray(filterValue) && filterValue.length === 2) {
90
- if (recordValue < filterValue[0] || recordValue > filterValue[1]) {
91
- return false;
92
- }
93
- }
94
- break;
95
-
96
- case '=':
97
- // For ID comparisons, convert both to strings to handle JSON:API string IDs
98
- if (filterKey.endsWith('_id') && fieldDef.belongsTo) {
99
- if (String(recordValue) !== String(filterValue)) {
100
- return false;
101
- }
102
- } else if (recordValue !== filterValue) {
103
- return false;
104
- }
105
- break;
107
+ return true;
108
+ }
109
+
110
+ function buildConfig(pluginOptions = {}) {
111
+ const authOptions = pluginOptions.auth || {};
112
+ const subscriptionOptions = pluginOptions.subscriptions || {};
113
+
114
+ return {
115
+ auth: {
116
+ authenticate: authOptions.authenticate || null,
117
+ requireAuth: authOptions.requireAuth === true,
118
+ allowClientProvidedAuth: authOptions.allowClientProvidedAuth === true,
119
+ anonymousContext: authOptions.anonymousContext || null,
120
+ onAuthenticationFailed: authOptions.onAuthenticationFailed || null
121
+ },
122
+ subscriptions: {
123
+ maxPerSocket: subscriptionOptions.maxPerSocket ?? 100
124
+ },
125
+ transport: pluginOptions.transport || {}
126
+ };
127
+ }
128
+
129
+ async function authenticateSocket({ socket, api, helpers, log, config }) {
130
+ const { authenticate, allowClientProvidedAuth, anonymousContext, requireAuth, onAuthenticationFailed } = config.auth;
131
+
132
+ try {
133
+ let authContext = null;
134
+
135
+ if (authenticate) {
136
+ authContext = await authenticate({ socket, api, helpers, log });
137
+ } else if (allowClientProvidedAuth && socket.handshake.auth && typeof socket.handshake.auth === 'object') {
138
+ authContext = socket.handshake.auth;
139
+ } else if (anonymousContext) {
140
+ authContext = anonymousContext;
141
+ }
142
+
143
+ const normalized = normalizeAuthContext(authContext);
144
+ if (!normalized && requireAuth) {
145
+ throw new Error('Authentication required');
146
+ }
147
+
148
+ return normalized;
149
+ } catch (error) {
150
+ if (onAuthenticationFailed) {
151
+ try {
152
+ await onAuthenticationFailed({ socket, error, log });
153
+ } catch (hookError) {
154
+ log.error('socketio auth failure handler threw error', hookError);
155
+ }
156
+ }
157
+ throw error;
158
+ }
159
+ }
160
+
161
+ async function registerSubscription({
162
+ socket,
163
+ data,
164
+ scopes,
165
+ runHooks,
166
+ log,
167
+ config
168
+ }) {
169
+ const { resource, filters = {}, include, fields, subscriptionId } = data || {};
170
+
171
+ if (!resource || typeof resource !== 'string') {
172
+ throw Object.assign(new Error('Resource is required'), { code: 'RESOURCE_REQUIRED' });
173
+ }
106
174
 
107
- case '>':
108
- if (!(recordValue > filterValue)) return false;
109
- break;
175
+ const scope = scopes[resource];
176
+ if (!scope) {
177
+ throw Object.assign(new Error(`Resource '${resource}' not found`), { code: 'RESOURCE_NOT_FOUND' });
178
+ }
110
179
 
111
- case '>=':
112
- if (!(recordValue >= filterValue)) return false;
113
- break;
180
+ const auth = socket.data.auth || null;
114
181
 
115
- case '<':
116
- if (!(recordValue < filterValue)) return false;
117
- break;
182
+ if (scope.checkPermissions) {
183
+ await scope.checkPermissions({
184
+ method: 'query',
185
+ originalContext: { auth }
186
+ });
187
+ }
118
188
 
119
- case '<=':
120
- if (!(recordValue <= filterValue)) return false;
121
- break;
189
+ const schemaInfo = scope.vars?.schemaInfo;
190
+ const searchSchemaStructure = schemaInfo?.searchSchemaStructure;
191
+ const searchSchemaInstance = schemaInfo?.searchSchemaInstance;
122
192
 
123
- case '!=':
124
- case '<>':
125
- if (recordValue === filterValue) return false;
126
- break;
193
+ const validatedFilters = { ...filters };
194
+ if (Object.keys(validatedFilters).length > 0) {
195
+ if (!searchSchemaStructure || Object.keys(searchSchemaStructure).length === 0 || !searchSchemaInstance) {
196
+ throw Object.assign(new Error(`Filtering is not enabled for resource '${resource}'`), { code: 'FILTERING_NOT_ENABLED' });
197
+ }
127
198
 
128
- default:
129
- // Unknown operator, treat as equality
130
- if (recordValue !== filterValue) return false;
131
- }
132
- }
199
+ for (const filterKey of Object.keys(validatedFilters)) {
200
+ const fieldDef = searchSchemaStructure[filterKey];
201
+ if (fieldDef && typeof fieldDef.filterOperator === 'function' && !fieldDef.filterRecord) {
202
+ throw Object.assign(
203
+ new Error(`Filter '${filterKey}' uses custom SQL logic and requires 'filterRecord' for real-time subscriptions`),
204
+ { code: 'UNSUPPORTED_FILTER' }
205
+ );
133
206
  }
207
+ }
134
208
 
135
- return true;
209
+ const { validatedObject, errors } = await searchSchemaInstance.validate(validatedFilters, {
210
+ onlyObjectValues: true
211
+ });
212
+
213
+ if (errors && Object.keys(errors).length > 0) {
214
+ const error = new Error('Invalid filter values');
215
+ error.code = 'INVALID_FILTERS';
216
+ error.details = errors;
217
+ throw error;
136
218
  }
137
219
 
138
- // Main broadcast function
139
- async function performBroadcast({ method, scopeName, id, context }) {
140
- console.log(`[SOCKETIO DEBUG] performBroadcast called:`, {
141
- method,
142
- scopeName,
143
- id,
144
- hasMinimalRecord: !!context.minimalRecord
145
- });
146
-
147
- const scope = api.resources[scopeName];
148
- if (!scope) {
149
- log.error(`Scope ${scopeName} not found for broadcasting`);
150
- return;
220
+ Object.assign(validatedFilters, validatedObject);
221
+ }
222
+
223
+ if (include !== undefined) {
224
+ if (!Array.isArray(include)) {
225
+ throw Object.assign(new Error('Include parameter must be an array'), { code: 'INVALID_INCLUDE' });
226
+ }
227
+
228
+ const relationships = schemaInfo?.schemaRelationships || {};
229
+ for (const includePath of include) {
230
+ const baseName = includePath.split('.')[0];
231
+ if (!relationships[baseName]) {
232
+ throw Object.assign(new Error(`Invalid relationship '${baseName}' for resource '${resource}'`), {
233
+ code: 'INVALID_INCLUDE'
234
+ });
151
235
  }
236
+ }
237
+ }
152
238
 
153
- // Get all sockets in the resource room
154
- const roomName = `${scopeName}:updates`;
155
- const socketsInRoom = await io.in(roomName).fetchSockets();
239
+ if (fields !== undefined) {
240
+ if (typeof fields !== 'object' || Array.isArray(fields) || fields === null) {
241
+ throw Object.assign(new Error('Fields parameter must be an object'), { code: 'INVALID_FIELDS' });
242
+ }
156
243
 
157
- if (socketsInRoom.length === 0) {
158
- log.debug(`No sockets subscribed to ${roomName}`);
159
- return;
244
+ for (const [resourceType, fieldList] of Object.entries(fields)) {
245
+ if (!Array.isArray(fieldList)) {
246
+ throw Object.assign(new Error(`Fields for '${resourceType}' must be an array`), { code: 'INVALID_FIELDS' });
160
247
  }
248
+ }
249
+ }
161
250
 
162
- // Get searchSchema for filter matching
163
- const searchSchema = scope.vars.schemaInfo?.searchSchema;
251
+ if (!socket.data.subscriptions) {
252
+ socket.data.subscriptions = new Map();
253
+ }
164
254
 
165
- // Use context.minimalRecord - it's GUARANTEED to be there!
166
- const recordForFiltering = context.minimalRecord;
167
-
255
+ if (socket.data.subscriptions.size >= config.subscriptions.maxPerSocket) {
256
+ throw Object.assign(new Error('Subscription limit reached for this connection'), { code: 'SUBSCRIPTION_LIMIT' });
257
+ }
168
258
 
169
- // Process each socket's subscriptions
170
- for (const socket of socketsInRoom) {
171
- try {
172
- // Limit subscriptions per socket
173
- if (socket.data.subscriptions?.size > 100) {
174
- log.warn(`Socket ${socket.id} has too many subscriptions (${socket.data.subscriptions.size})`);
175
- continue;
176
- }
259
+ const subscription = {
260
+ id: subscriptionId || `${resource}-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`,
261
+ resource,
262
+ filters: validatedFilters,
263
+ include: include || [],
264
+ fields: fields || {},
265
+ auth,
266
+ createdAt: new Date()
267
+ };
268
+
269
+ await runHooks('subscriptionFilters', { subscription, auth });
270
+
271
+ const roomName = `${resource}:updates`;
272
+ socket.join(roomName);
273
+ socket.data.subscriptions.set(subscription.id, subscription);
274
+
275
+ log.info(`Socket ${socket.id} subscribed to ${resource}`, {
276
+ subscriptionId: subscription.id,
277
+ filters: subscription.filters
278
+ });
279
+
280
+ return {
281
+ subscriptionId: subscription.id,
282
+ resource: subscription.resource,
283
+ filters: subscription.filters,
284
+ include: subscription.include,
285
+ fields: subscription.fields,
286
+ status: 'active'
287
+ };
288
+ }
289
+
290
+ async function handleRestoreSubscriptions({ socket, subscriptions, scopes, runHooks, log, config }) {
291
+ const restored = [];
292
+ const failed = [];
293
+
294
+ for (const sub of subscriptions) {
295
+ try {
296
+ const response = await registerSubscription({
297
+ socket,
298
+ data: sub,
299
+ scopes,
300
+ runHooks,
301
+ log,
302
+ config
303
+ });
304
+ restored.push(response.subscriptionId);
305
+ } catch (error) {
306
+ failed.push({
307
+ subscriptionId: sub?.subscriptionId || null,
308
+ error: {
309
+ code: error.code || 'SUBSCRIBE_ERROR',
310
+ message: error.message
311
+ }
312
+ });
313
+ }
314
+ }
177
315
 
178
- // Find matching subscriptions
179
- const matchingSubscriptions = Array.from(socket.data.subscriptions?.values() || [])
180
- .filter(sub => sub.resource === scopeName);
316
+ return { restored, failed };
317
+ }
181
318
 
182
- for (const subscription of matchingSubscriptions) {
183
- // Check if record matches filters using searchSchema
184
- if (!matchesFilters(recordForFiltering, subscription.filters, searchSchema)) {
185
- continue;
186
- }
319
+ function getRecordForFiltering(context) {
320
+ return context?.minimalRecord || context?.originalMinimalRecord || null;
321
+ }
187
322
 
188
- // Send minimal notification only
189
- const notification = {
190
- type: `resource.${method}d`,
191
- resource: scopeName,
192
- id: id,
193
- action: method,
194
- subscriptionId: subscription.id,
195
- meta: {
196
- timestamp: new Date().toISOString()
197
- }
198
- };
323
+ async function performBroadcast({ method, scopeName, id, context, api, io, log }) {
324
+ if (!io) return;
199
325
 
200
- // For delete, include minimal info since record is gone
201
- if (method === 'delete') {
202
- notification.deletedRecord = {
203
- id: id
204
- };
205
- }
326
+ const scope = api.resources[scopeName];
327
+ if (!scope) {
328
+ log.warn(`Socket.IO broadcast skipped: unknown resource ${scopeName}`);
329
+ return;
330
+ }
206
331
 
207
- socket.emit('subscription.update', notification);
208
- log.debug(`Broadcast ${method} notification for ${scopeName}/${id} to socket ${socket.id}`);
332
+ const roomName = `${scopeName}:updates`;
333
+ const socketsInRoom = await io.in(roomName).fetchSockets();
334
+ if (!socketsInRoom || socketsInRoom.length === 0) {
335
+ log.debug(`Socket.IO broadcast skipped: no subscribers for ${roomName}`);
336
+ return;
337
+ }
209
338
 
210
- // Only send once per socket
211
- break;
212
- }
213
- } catch (error) {
214
- log.error(`Error broadcasting to socket ${socket.id}:`, error);
215
- // Continue with next socket
339
+ const schemaInfo = scope.vars?.schemaInfo;
340
+ const searchSchemaStructure = schemaInfo?.searchSchemaStructure;
341
+ const recordForFiltering = getRecordForFiltering(context);
342
+
343
+ for (const socket of socketsInRoom) {
344
+ const subscriptions = socket.data.subscriptions;
345
+ if (!subscriptions || subscriptions.size === 0) continue;
346
+
347
+ for (const subscription of subscriptions.values()) {
348
+ if (subscription.resource !== scopeName) continue;
349
+
350
+ if (recordForFiltering && subscription.filters && Object.keys(subscription.filters).length > 0) {
351
+ if (!matchesFilters(recordForFiltering, subscription.filters, searchSchemaStructure)) {
352
+ continue;
216
353
  }
217
354
  }
355
+
356
+ const notification = {
357
+ type: `resource.${method}d`,
358
+ resource: scopeName,
359
+ id,
360
+ action: method,
361
+ subscriptionId: subscription.id,
362
+ meta: {
363
+ timestamp: new Date().toISOString()
364
+ }
365
+ };
366
+
367
+ if (method === 'delete') {
368
+ notification.deletedRecord = { id };
369
+ }
370
+
371
+ socket.emit('subscription.update', notification);
372
+ log.debug(`Socket.IO broadcast: ${scopeName}/${id} -> socket ${socket.id}`);
373
+ break;
218
374
  }
375
+ }
376
+ }
377
+
378
+ export const SocketIOPlugin = {
379
+ name: 'socketio',
380
+ dependencies: ['rest-api'],
381
+
382
+ async install({ api, addHook, log, scopes, helpers, vars, runHooks, pluginOptions = {} }) {
383
+ const config = buildConfig(pluginOptions);
384
+
385
+ let Server;
386
+ try {
387
+ ({ Server } = await import('socket.io'));
388
+ } catch (error) {
389
+ requirePackage('socket.io', 'socketio', 'Socket.IO is required for WebSocket support. This is a peer dependency.');
390
+ throw error;
391
+ }
392
+
393
+ let createAdapter;
394
+ let createClient;
395
+ let io;
396
+
397
+ api.startSocketServer = async (server, startOptions = {}) => {
398
+ const transportConfig = { ...config.transport, ...startOptions.transport };
399
+ const authConfig = { ...config.auth, ...(startOptions.auth || {}) };
400
+ const redisConfig = startOptions.redis ?? pluginOptions.redis ?? null;
401
+
402
+ config.auth = authConfig;
219
403
 
220
- // Create Socket.IO server
221
- api.startSocketServer = async (server, options = {}) => {
222
- // Default path respects mountPath for consistency with REST API
223
404
  const defaultPath = vars.transport?.mountPath ? `${vars.transport.mountPath}/socket.io` : '/socket.io';
224
-
225
- const {
226
- path = defaultPath,
227
- cors = { origin: '*', methods: ['GET', 'POST'] },
228
- redis = null
229
- } = options;
405
+ const path = startOptions.path || config.transport.path || defaultPath;
406
+ const cors = startOptions.cors || config.transport.cors || { origin: '*', methods: ['GET', 'POST'] };
230
407
 
231
408
  io = new Server(server, {
232
409
  path,
@@ -234,27 +411,26 @@ export const SocketIOPlugin = {
234
411
  transports: ['websocket', 'polling']
235
412
  });
236
413
 
237
- // Store io instance for cleanup
238
414
  vars.socketIO = io;
415
+ api.io = io;
239
416
 
240
- // Set up Redis adapter if configured
241
- if (redis) {
242
- // Dynamic import for Redis dependencies
417
+ if (redisConfig) {
243
418
  try {
244
419
  ({ createClient } = await import('redis'));
245
- } catch (e) {
246
- requirePackage('redis', 'socketio',
247
- 'Redis is required for Socket.IO horizontal scaling. This is a peer dependency.');
420
+ } catch (error) {
421
+ requirePackage('redis', 'socketio', 'Redis is required for Socket.IO horizontal scaling. This is a peer dependency.');
422
+ throw error;
248
423
  }
249
-
424
+
250
425
  try {
251
426
  ({ createAdapter } = await import('@socket.io/redis-adapter'));
252
- } catch (e) {
253
- requirePackage('@socket.io/redis-adapter', 'socketio',
427
+ } catch (error) {
428
+ requirePackage('@socket.io/redis-adapter', 'socketio',
254
429
  'Socket.IO Redis adapter is required for horizontal scaling. This is a peer dependency.');
430
+ throw error;
255
431
  }
256
-
257
- const pubClient = createClient(redis);
432
+
433
+ const pubClient = createClient(redisConfig);
258
434
  const subClient = pubClient.duplicate();
259
435
 
260
436
  await Promise.all([
@@ -263,34 +439,25 @@ export const SocketIOPlugin = {
263
439
  ]);
264
440
 
265
441
  io.adapter(createAdapter(pubClient, subClient));
266
- log.info('Socket.IO using Redis adapter');
267
-
268
- // Store Redis clients for cleanup
269
442
  vars.socketIORedisClients = { pubClient, subClient };
443
+ log.info('Socket.IO configured with Redis adapter');
270
444
  }
271
445
 
272
- // Authentication middleware
273
446
  io.use(async (socket, next) => {
274
447
  try {
275
- const token = socket.handshake.auth.token;
276
- if (!token) {
277
- return next(new Error('Authentication required'));
278
- }
279
-
280
- const decoded = await helpers.verifyToken(token);
281
- socket.data.auth = decoded;
448
+ const authContext = await authenticateSocket({ socket, api, helpers, log, config });
449
+ socket.data.auth = authContext;
282
450
  socket.data.subscriptions = new Map();
283
-
284
451
  next();
285
452
  } catch (error) {
286
- next(new Error('Invalid authentication token'));
453
+ log.warn('Socket.IO authentication failed', error);
454
+ next(new Error(error.message || 'Authentication failed'));
287
455
  }
288
456
  });
289
457
 
290
- // Connection handler
291
458
  io.on('connection', (socket) => {
292
459
  log.info(`Socket connected: ${socket.id}`, {
293
- userId: socket.data.auth?.userId
460
+ userId: socket.data.auth?.userId ?? null
294
461
  });
295
462
 
296
463
  socket.emit('connected', {
@@ -298,242 +465,59 @@ export const SocketIOPlugin = {
298
465
  serverTime: new Date().toISOString()
299
466
  });
300
467
 
301
- // Subscribe to resource updates
302
- socket.on('subscribe', async (data, callback) => {
468
+ socket.on('subscribe', async (payload, callback) => {
303
469
  try {
304
- const { resource, filters = {}, include, fields, subscriptionId } = data;
305
-
306
- // Validate resource exists
307
- if (!scopes[resource]) {
308
- const error = {
309
- code: 'RESOURCE_NOT_FOUND',
310
- message: `Resource '${resource}' not found`
311
- };
312
- if (callback) callback({ error });
313
- else socket.emit('subscription.error', { subscriptionId, error });
314
- return;
315
- }
316
-
317
- const scope = scopes[resource];
318
-
319
- // Check permission to query this resource
320
- // If checkPermissions throws an error, permission is denied
321
- // If it completes without error, permission is granted
322
- try {
323
- await scope.checkPermissions({
324
- method: 'query',
325
- auth: socket.data.auth,
326
- transaction: null
327
- });
328
- // No error thrown = permission granted, continue
329
- } catch (permissionError) {
330
- // Error thrown = permission denied
331
- const error = {
332
- code: 'PERMISSION_DENIED',
333
- message: permissionError.message || 'You do not have permission to subscribe to this resource'
334
- };
335
- if (callback) callback({ error });
336
- else socket.emit('subscription.error', { subscriptionId, error });
337
- return;
338
- }
339
-
340
- // Validate and modify filters
341
- if (filters && Object.keys(filters).length > 0) {
342
- const searchSchema = scope.vars.schemaInfo?.searchSchema;
343
-
344
- if (!searchSchema) {
345
- const error = {
346
- code: 'FILTERING_NOT_ENABLED',
347
- message: `Filtering is not enabled for resource '${resource}'`
348
- };
349
- if (callback) callback({ error });
350
- else socket.emit('subscription.error', { subscriptionId, error });
351
- return;
352
- }
353
-
354
- // Check for function-based filters without filterRecord
355
- for (const filterKey of Object.keys(filters)) {
356
- const fieldDef = searchSchema.structure[filterKey];
357
-
358
- if (fieldDef && typeof fieldDef.filterOperator === 'function' && !fieldDef.filterRecord) {
359
- const error = {
360
- code: 'UNSUPPORTED_FILTER',
361
- message: `Filter '${filterKey}' uses custom SQL logic and requires 'filterRecord' for real-time
362
- subscriptions`
363
- };
364
- if (callback) callback({ error });
365
- else socket.emit('subscription.error', { subscriptionId, error });
366
- return;
367
- }
368
- }
369
-
370
- // Validate filter values using searchSchema
371
- const { validatedObject, errors } = await searchSchema.validate(filters, {
372
- onlyObjectValues: true
373
- });
374
-
375
- if (Object.keys(errors).length > 0) {
376
- const error = {
377
- code: 'INVALID_FILTERS',
378
- message: 'Invalid filter values',
379
- details: errors
380
- };
381
- if (callback) callback({ error });
382
- else socket.emit('subscription.error', { subscriptionId, error });
383
- return;
384
- }
385
-
386
- // Use validated filters
387
- data.filters = validatedObject;
388
- }
389
-
390
- // Create subscription object
391
- const subscription = {
392
- id: subscriptionId || `${resource}-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
393
- resource,
394
- filters: data.filters || {},
395
- include: include || [],
396
- fields: fields || {},
397
- auth: socket.data.auth,
398
- createdAt: new Date()
399
- };
400
-
401
- // Run hook to modify/validate subscription filters
402
- const hookContext = { subscription, auth: socket.data.auth };
403
- await runHooks('subscriptionFilters', hookContext);
404
-
405
- // Validate include parameter
406
- if (subscription.include && subscription.include.length > 0) {
407
- if (!Array.isArray(subscription.include)) {
408
- const error = {
409
- code: 'INVALID_INCLUDE',
410
- message: 'Include parameter must be an array'
411
- };
412
- if (callback) callback({ error });
413
- else socket.emit('subscription.error', { subscriptionId, error });
414
- return;
415
- }
416
-
417
- const relationships = scope.vars.schemaInfo?.schemaRelationships || {};
418
- for (const includePath of subscription.include) {
419
- const baseName = includePath.split('.')[0];
420
- if (!relationships[baseName]) {
421
- const error = {
422
- code: 'INVALID_INCLUDE',
423
- message: `Invalid relationship '${baseName}' for resource '${resource}'`
424
- };
425
- if (callback) callback({ error });
426
- else socket.emit('subscription.error', { subscriptionId, error });
427
- return;
428
- }
429
- }
430
- }
431
-
432
- // Validate fields parameter
433
- if (subscription.fields && Object.keys(subscription.fields).length > 0) {
434
- if (typeof subscription.fields !== 'object' || Array.isArray(subscription.fields)) {
435
- const error = {
436
- code: 'INVALID_FIELDS',
437
- message: 'Fields parameter must be an object'
438
- };
439
- if (callback) callback({ error });
440
- else socket.emit('subscription.error', { subscriptionId, error });
441
- return;
442
- }
443
-
444
- for (const [resourceType, fieldList] of Object.entries(subscription.fields)) {
445
- if (!Array.isArray(fieldList)) {
446
- const error = {
447
- code: 'INVALID_FIELDS',
448
- message: `Fields for '${resourceType}' must be an array`
449
- };
450
- if (callback) callback({ error });
451
- else socket.emit('subscription.error', { subscriptionId, error });
452
- return;
453
- }
454
- }
455
- }
456
-
457
- // Join resource room
458
- const roomName = `${resource}:updates`;
459
- socket.join(roomName);
460
-
461
- // Store subscription
462
- socket.data.subscriptions.set(subscription.id, subscription);
463
-
464
- // Send success response
465
- const response = {
466
- subscriptionId: subscription.id,
467
- resource,
468
- filters: subscription.filters,
469
- include: subscription.include,
470
- fields: subscription.fields,
471
- status: 'active'
472
- };
473
-
474
- if (callback) callback({ success: true, data: response });
475
- else socket.emit('subscription.created', response);
476
-
477
- log.info(`Socket ${socket.id} subscribed to ${resource}`, {
478
- subscriptionId: subscription.id,
479
- filters: subscription.filters
470
+ const result = await registerSubscription({
471
+ socket,
472
+ data: payload,
473
+ scopes,
474
+ runHooks,
475
+ log,
476
+ config
480
477
  });
481
478
 
479
+ if (callback) callback({ success: true, data: result });
480
+ else socket.emit('subscription.created', result);
482
481
  } catch (error) {
483
- log.error('Subscribe error:', error);
484
- const errorResponse = {
485
- code: 'SUBSCRIBE_ERROR',
486
- message: error.message
482
+ log.error('Socket.IO subscribe error', error);
483
+ const response = {
484
+ error: {
485
+ code: error.code || 'SUBSCRIBE_ERROR',
486
+ message: error.message
487
+ }
487
488
  };
488
- if (callback) callback({ error: errorResponse });
489
- else socket.emit('subscription.error', { subscriptionId, error: errorResponse });
489
+ if (callback) callback(response);
490
+ else socket.emit('subscription.error', response.error);
490
491
  }
491
492
  });
492
493
 
493
- // Unsubscribe from resource updates
494
- socket.on('unsubscribe', async (data, callback) => {
494
+ socket.on('unsubscribe', (payload, callback) => {
495
495
  try {
496
- const { subscriptionId } = data;
497
-
496
+ const subscriptionId = payload?.subscriptionId;
498
497
  if (!subscriptionId) {
499
- const error = {
500
- code: 'MISSING_SUBSCRIPTION_ID',
501
- message: 'Subscription ID is required'
502
- };
498
+ const error = { code: 'MISSING_SUBSCRIPTION_ID', message: 'Subscription ID is required' };
503
499
  if (callback) callback({ error });
504
500
  return;
505
501
  }
506
502
 
507
- const subscription = socket.data.subscriptions.get(subscriptionId);
503
+ const subscription = socket.data.subscriptions?.get(subscriptionId);
508
504
  if (!subscription) {
509
- const error = {
510
- code: 'SUBSCRIPTION_NOT_FOUND',
511
- message: 'Subscription not found'
512
- };
505
+ const error = { code: 'SUBSCRIPTION_NOT_FOUND', message: 'Subscription not found' };
513
506
  if (callback) callback({ error });
514
507
  return;
515
508
  }
516
509
 
517
- // Remove subscription
518
510
  socket.data.subscriptions.delete(subscriptionId);
519
-
520
- // Leave room if no more subscriptions for this resource
521
- const hasOtherSubs = Array.from(socket.data.subscriptions.values())
522
- .some(sub => sub.resource === subscription.resource);
523
-
524
- if (!hasOtherSubs) {
525
- const roomName = `${subscription.resource}:updates`;
526
- socket.leave(roomName);
511
+ const hasOther = Array.from(socket.data.subscriptions.values())
512
+ .some((sub) => sub.resource === subscription.resource);
513
+ if (!hasOther) {
514
+ socket.leave(`${subscription.resource}:updates`);
527
515
  }
528
516
 
529
517
  if (callback) callback({ success: true });
530
-
531
- log.info(`Socket ${socket.id} unsubscribed from ${subscription.resource}`, {
532
- subscriptionId
533
- });
534
-
518
+ log.info(`Socket ${socket.id} unsubscribed from ${subscription.resource}`, { subscriptionId });
535
519
  } catch (error) {
536
- log.error('Unsubscribe error:', error);
520
+ log.error('Socket.IO unsubscribe error', error);
537
521
  if (callback) callback({
538
522
  error: {
539
523
  code: 'UNSUBSCRIBE_ERROR',
@@ -543,47 +527,27 @@ subscriptions`
543
527
  }
544
528
  });
545
529
 
546
- // Restore subscriptions on reconnect
547
- socket.on('restore-subscriptions', async (data, callback) => {
530
+ socket.on('restore-subscriptions', async (payload, callback) => {
548
531
  try {
549
- const { subscriptions } = data;
550
-
532
+ const subscriptions = payload?.subscriptions;
551
533
  if (!Array.isArray(subscriptions)) {
552
- if (callback) callback({
553
- error: {
554
- code: 'INVALID_DATA',
555
- message: 'Subscriptions must be an array'
556
- }
557
- });
534
+ const error = { code: 'INVALID_DATA', message: 'Subscriptions must be an array' };
535
+ if (callback) callback({ error });
558
536
  return;
559
537
  }
560
538
 
561
- const restored = [];
562
- const failed = [];
563
-
564
- for (const sub of subscriptions) {
565
- await new Promise((resolve) => {
566
- socket.emit('subscribe', sub, (response) => {
567
- if (response.error) {
568
- failed.push({
569
- subscriptionId: sub.subscriptionId,
570
- error: response.error
571
- });
572
- } else {
573
- restored.push(response.data.subscriptionId);
574
- }
575
- resolve();
576
- });
577
- });
578
- }
579
-
580
- if (callback) callback({
581
- success: true,
582
- restored,
583
- failed
539
+ const result = await handleRestoreSubscriptions({
540
+ socket,
541
+ subscriptions,
542
+ scopes,
543
+ runHooks,
544
+ log,
545
+ config
584
546
  });
585
547
 
548
+ if (callback) callback({ success: true, ...result });
586
549
  } catch (error) {
550
+ log.error('Socket.IO restore subscriptions error', error);
587
551
  if (callback) callback({
588
552
  error: {
589
553
  code: 'RESTORE_ERROR',
@@ -593,91 +557,61 @@ subscriptions`
593
557
  }
594
558
  });
595
559
 
596
- // Handle disconnect
597
560
  socket.on('disconnect', (reason) => {
598
561
  log.info(`Socket disconnected: ${socket.id}`, {
599
562
  reason,
600
- userId: socket.data.auth?.userId,
601
563
  subscriptionCount: socket.data.subscriptions?.size || 0
602
564
  });
603
565
  });
604
566
  });
605
567
 
606
- // Store io instance
607
- api.io = io;
608
-
609
568
  log.info('Socket.IO server started', { path });
610
-
611
569
  return io;
612
570
  };
613
571
 
614
- // Hook into REST API finish hook for broadcasting
615
572
  addHook('finish', 'socketio-broadcast', {}, async ({ context }) => {
616
573
  const { method, scopeName, id } = context;
617
574
 
618
- console.log(`[SOCKETIO DEBUG] finish hook called:`, {
619
- method,
620
- scopeName,
621
- id,
622
- hasMinimalRecord: !!context.minimalRecord,
623
- hasTransaction: !!context.transaction
624
- });
625
-
626
- // Only broadcast for write operations
627
- if (!['post', 'put', 'patch', 'delete'].includes(method)) {
628
- log.debug(`[SOCKETIO DEBUG] Skipping broadcast for method: ${method}`);
575
+ if (!io) {
576
+ log.debug('Socket.IO broadcast skipped: server not started');
629
577
  return;
630
578
  }
631
579
 
632
- // Skip if no ID (might happen in error cases)
633
- if (!id && method !== 'delete') {
634
- log.debug(`[SOCKETIO DEBUG] Skipping broadcast - no ID for method: ${method}`);
580
+ if (!['post', 'put', 'patch', 'delete'].includes(method)) {
635
581
  return;
636
582
  }
637
583
 
638
- // Skip if io not initialized
639
- if (!io) {
640
- log.debug(`[SOCKETIO DEBUG] Skipping broadcast - io not initialized`);
584
+ if (!id && method !== 'delete') {
585
+ log.debug('Socket.IO broadcast skipped: missing record id');
641
586
  return;
642
587
  }
643
588
 
644
- // If there's a transaction, defer broadcasting
645
589
  if (context.transaction) {
646
- console.log(`[SOCKETIO DEBUG] Deferring broadcast until transaction commit`);
647
- // Store broadcast info in WeakMap
648
590
  if (!pendingBroadcasts.has(context.transaction)) {
649
591
  pendingBroadcasts.set(context.transaction, []);
650
592
  }
651
- pendingBroadcasts.get(context.transaction).push({
652
- method, scopeName, id, context
653
- });
593
+ pendingBroadcasts.get(context.transaction).push({ method, scopeName, id, context });
654
594
  return;
655
595
  }
656
596
 
657
- // No transaction, broadcast immediately
658
- console.log(`[SOCKETIO DEBUG] Broadcasting immediately (no transaction)`);
659
- await performBroadcast({ method, scopeName, id, context });
597
+ await performBroadcast({ method, scopeName, id, context, api, io, log });
660
598
  });
661
599
 
662
- // Hook to handle deferred broadcasts after transaction commit
663
600
  addHook('afterCommit', 'socketio-broadcast-deferred', {}, async ({ context }) => {
664
- if (context && context.transaction) {
665
- const broadcasts = pendingBroadcasts.get(context.transaction);
666
- if (broadcasts) {
667
- for (const broadcast of broadcasts) {
668
- await performBroadcast(broadcast);
669
- }
670
- pendingBroadcasts.delete(context.transaction);
671
- }
601
+ if (!context?.transaction) return;
602
+ const broadcasts = pendingBroadcasts.get(context.transaction);
603
+ if (!broadcasts) return;
604
+
605
+ for (const broadcast of broadcasts) {
606
+ await performBroadcast({ ...broadcast, api, io, log });
672
607
  }
608
+
609
+ pendingBroadcasts.delete(context.transaction);
673
610
  });
674
611
 
675
- // Hook to clean up on transaction rollback
676
612
  addHook('afterRollback', 'socketio-cleanup-broadcasts', {}, async ({ context }) => {
677
- if (context && context.transaction) {
678
- pendingBroadcasts.delete(context.transaction);
679
- log.debug(`Cleaned up pending broadcasts for rolled back transaction`);
680
- }
613
+ if (!context?.transaction) return;
614
+ pendingBroadcasts.delete(context.transaction);
681
615
  });
682
616
  }
683
617
  };