json-rest-api 1.0.28 → 2.0.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.
Files changed (289) hide show
  1. package/LICENSE +10 -670
  2. package/LICENSE-GPL-3.0 +674 -0
  3. package/LICENSE-MIT +21 -0
  4. package/README.md +39 -30
  5. package/docs/API.md +746 -1273
  6. package/docs/GUIDE/01-getting-started.md +338 -0
  7. package/docs/GUIDE/02-resources-and-relationships.md +33 -0
  8. package/docs/GUIDE/03-running-example.md +87 -0
  9. package/docs/GUIDE/04-creating-and-querying.md +181 -0
  10. package/docs/GUIDE/05-belongs-to.md +177 -0
  11. package/docs/GUIDE/06-has-many.md +184 -0
  12. package/docs/GUIDE/07-polymorphic-relationships.md +191 -0
  13. package/docs/GUIDE/08-many-to-many.md +184 -0
  14. package/docs/GUIDE/09-pagination-and-sorting.md +149 -0
  15. package/docs/GUIDE/10-put-and-patch.md +174 -0
  16. package/docs/GUIDE/11-relationship-endpoints.md +174 -0
  17. package/docs/GUIDE/12-field-transformations.md +287 -0
  18. package/docs/GUIDE/13-hooks-and-lifecycle.md +479 -0
  19. package/docs/GUIDE/14-custom-resource-methods.md +125 -0
  20. package/docs/GUIDE/15-query-projections.md +266 -0
  21. package/docs/GUIDE/{GUIDE_X_Autofiltering.md → 16-autofiltering.md} +77 -15
  22. package/docs/GUIDE/{GUIDE_X_Row_Policies.md → 17-row-policies.md} +137 -44
  23. package/docs/GUIDE/18-labels.md +86 -0
  24. package/docs/GUIDE/19-managed-transactions.md +179 -0
  25. package/docs/GUIDE/20-transaction-outcomes.md +207 -0
  26. package/docs/GUIDE/21-schema-and-migrations.md +634 -0
  27. package/docs/GUIDE/22-version-field-migrations.md +208 -0
  28. package/docs/GUIDE/23-fastify.md +179 -0
  29. package/docs/GUIDE/24-cors.md +172 -0
  30. package/docs/GUIDE/{GUIDE_X_URL_Management.md → 25-url-management.md} +7 -1
  31. package/docs/GUIDE/26-file-uploads.md +278 -0
  32. package/docs/GUIDE/27-bulk-operations.md +277 -0
  33. package/docs/GUIDE/28-socketio.md +325 -0
  34. package/docs/GUIDE/29-writing-plugins.md +278 -0
  35. package/docs/GUIDE/30-backend-capabilities.md +100 -0
  36. package/docs/GUIDE/31-positioning.md +181 -0
  37. package/docs/GUIDE/{GUIDE_Y_Appendices.md → 32-date-and-time.md} +90 -31
  38. package/docs/GUIDE/33-migrating-to-v2.md +3827 -0
  39. package/docs/GUIDE/index.md +51 -59
  40. package/docs/QUICKSTART.md +120 -287
  41. package/docs/architecture.md +75 -0
  42. package/docs/contributing.md +178 -0
  43. package/index.d.ts +36 -0
  44. package/index.js +1 -22
  45. package/lib/enhanced-logger.js +91 -85
  46. package/lib/error-context.js +445 -5
  47. package/lib/error-formatter.js +219 -59
  48. package/lib/knex-transaction.js +64 -0
  49. package/lib/logger-types.d.ts +22 -0
  50. package/lib/missing-package.js +4 -0
  51. package/lib/rest-api-errors.js +103 -8
  52. package/lib/runtime/json-rest-api.js +172 -0
  53. package/lib/transaction-types.d.ts +59 -0
  54. package/package.json +97 -27
  55. package/plugins/core/bulk-operations-plugin.js +191 -184
  56. package/plugins/core/connectors/express-plugin.js +148 -219
  57. package/plugins/core/connectors/fastify-plugin.js +125 -82
  58. package/plugins/core/connectors/lib/busboy-detector.js +74 -156
  59. package/plugins/core/connectors/lib/connector-core.js +144 -20
  60. package/plugins/core/connectors/lib/formidable-detector.js +63 -120
  61. package/plugins/core/connectors/lib/http-validators.js +77 -0
  62. package/plugins/core/connectors/lib/multipart-helpers.js +18 -0
  63. package/plugins/core/connectors/lib/request-helpers.js +10 -1
  64. package/plugins/core/connectors/lib/transport-http-helpers.js +89 -8
  65. package/plugins/core/connectors/lib/transport-route-schemas.js +7 -4
  66. package/plugins/core/file-handling-plugin.js +87 -174
  67. package/plugins/core/lib/anyapi/anyapi-registry.js +213 -164
  68. package/plugins/core/lib/anyapi/canonical-link-store.js +507 -0
  69. package/plugins/core/lib/anyapi/canonical-relationship-reader.js +598 -0
  70. package/plugins/core/lib/anyapi/schema-utils.js +25 -16
  71. package/plugins/core/lib/anyapi/utils/descriptor-helpers.js +0 -64
  72. package/plugins/core/lib/dbIntrospection.js +228 -21
  73. package/plugins/core/lib/dbTablesOperations.js +409 -92
  74. package/plugins/core/lib/fractional-positioning.js +12 -48
  75. package/plugins/core/lib/querying/identifier-query.js +19 -0
  76. package/plugins/core/lib/querying/include-query-helpers.js +364 -0
  77. package/plugins/core/lib/querying/include-to-many.js +428 -0
  78. package/plugins/core/lib/querying/include-to-one.js +579 -0
  79. package/plugins/core/lib/querying/knex-cross-table-search.js +31 -307
  80. package/plugins/core/lib/querying/knex-json-api-transformers-querying.js +99 -401
  81. package/plugins/core/lib/querying/knex-pagination-helpers.js +67 -330
  82. package/plugins/core/lib/querying/knex-process-includes.js +5 -110
  83. package/plugins/core/lib/querying/knex-query-helpers-base.js +21 -56
  84. package/plugins/core/lib/querying/knex-query-helpers.js +298 -448
  85. package/plugins/core/lib/querying/knex-relationship-includes.js +120 -2138
  86. package/plugins/core/lib/querying/knex-window-queries.js +62 -253
  87. package/plugins/core/lib/querying/query-builder-utils.js +44 -4
  88. package/plugins/core/lib/querying/query-constraint.d.ts +12 -0
  89. package/plugins/core/lib/querying/query-constraint.js +27 -0
  90. package/plugins/core/lib/querying/query-field-sort-helpers.js +205 -30
  91. package/plugins/core/lib/querying/relationship-identifiers.js +208 -0
  92. package/plugins/core/lib/querying/sort-helpers.js +20 -1
  93. package/plugins/core/lib/querying/storage-adapter-utils.js +13 -5
  94. package/plugins/core/lib/querying/url-helpers.js +11 -4
  95. package/plugins/core/lib/querying-writing/compile-schemas.js +148 -249
  96. package/plugins/core/lib/querying-writing/connectors-query-parser.js +33 -118
  97. package/plugins/core/lib/querying-writing/database-capabilities.js +149 -223
  98. package/plugins/core/lib/querying-writing/database-value-normalizers.js +238 -187
  99. package/plugins/core/lib/querying-writing/default-data-helpers.js +15 -45
  100. package/plugins/core/lib/querying-writing/field-selection-types.d.ts +43 -0
  101. package/plugins/core/lib/querying-writing/field-utils.js +113 -92
  102. package/plugins/core/lib/querying-writing/knex-constants.d.ts +18 -0
  103. package/plugins/core/lib/querying-writing/knex-constants.js +2 -26
  104. package/plugins/core/lib/querying-writing/knex-field-helpers.js +111 -287
  105. package/plugins/core/lib/querying-writing/knex-json-api-transformers.js +31 -120
  106. package/plugins/core/lib/querying-writing/query-field-helpers.js +108 -32
  107. package/plugins/core/lib/querying-writing/query-field-types.d.ts +60 -0
  108. package/plugins/core/lib/querying-writing/relationship-contracts.js +33 -13
  109. package/plugins/core/lib/querying-writing/request-contracts.js +109 -44
  110. package/plugins/core/lib/querying-writing/resource-id-normalization.js +23 -5
  111. package/plugins/core/lib/querying-writing/response-options.js +35 -0
  112. package/plugins/core/lib/querying-writing/schema-helpers.js +203 -324
  113. package/plugins/core/lib/querying-writing/scope-validations.js +92 -290
  114. package/plugins/core/lib/querying-writing/simplified-helpers.js +113 -712
  115. package/plugins/core/lib/storage/canonical-data-helpers.js +683 -0
  116. package/plugins/core/lib/storage/canonical-storage-mapping.js +51 -20
  117. package/plugins/core/lib/storage/ordinary-data-helpers.js +710 -0
  118. package/plugins/core/lib/storage/storage-adapter.js +142 -62
  119. package/plugins/core/lib/storage/storage-mapping.js +78 -28
  120. package/plugins/core/lib/storage/storage-types.d.ts +502 -0
  121. package/plugins/core/lib/writing/many-to-many-manipulations.js +114 -284
  122. package/plugins/core/lib/writing/relationship-processor.js +94 -158
  123. package/plugins/core/lib/writing/resource-version.js +172 -0
  124. package/plugins/core/lib/writing/reverse-relationship-manipulations.js +110 -0
  125. package/plugins/core/rest-api-anyapi-knex-plugin.js +116 -2509
  126. package/plugins/core/rest-api-autofilter-plugin.js +17 -19
  127. package/plugins/core/rest-api-cors-plugin.js +52 -205
  128. package/plugins/core/rest-api-knex-plugin.js +45 -1069
  129. package/plugins/core/rest-api-label-plugin.js +25 -38
  130. package/plugins/core/rest-api-plugin-hooks/register-relationship-routes.js +5 -5
  131. package/plugins/core/rest-api-plugin-hooks/register-scope-routes.js +9 -6
  132. package/plugins/core/rest-api-plugin-hooks/turn-scope-init-into-vars.js +7 -26
  133. package/plugins/core/rest-api-plugin-hooks/validate-include-configurations.js +20 -26
  134. package/plugins/core/rest-api-plugin-methods/add-route.js +4 -6
  135. package/plugins/core/rest-api-plugin-methods/apply-query-filters.js +42 -13
  136. package/plugins/core/rest-api-plugin-methods/check-permissions.js +11 -5
  137. package/plugins/core/rest-api-plugin-methods/common.js +291 -385
  138. package/plugins/core/rest-api-plugin-methods/delete-relationship.js +49 -47
  139. package/plugins/core/rest-api-plugin-methods/delete.js +31 -25
  140. package/plugins/core/rest-api-plugin-methods/enrich-attributes.js +108 -145
  141. package/plugins/core/rest-api-plugin-methods/get-related.js +113 -305
  142. package/plugins/core/rest-api-plugin-methods/get-relationship.js +25 -18
  143. package/plugins/core/rest-api-plugin-methods/get.js +38 -69
  144. package/plugins/core/rest-api-plugin-methods/lifecycle-types.d.ts +225 -0
  145. package/plugins/core/rest-api-plugin-methods/patch-relationship.js +28 -25
  146. package/plugins/core/rest-api-plugin-methods/patch.js +46 -99
  147. package/plugins/core/rest-api-plugin-methods/post-relationship.js +49 -53
  148. package/plugins/core/rest-api-plugin-methods/post.js +51 -51
  149. package/plugins/core/rest-api-plugin-methods/put.js +69 -148
  150. package/plugins/core/rest-api-plugin-methods/query.js +39 -82
  151. package/plugins/core/rest-api-plugin-methods/release.js +2 -1
  152. package/plugins/core/rest-api-plugin.js +32 -46
  153. package/plugins/core/rest-api-positioning-plugin.js +183 -628
  154. package/plugins/core/rest-api-query-projections-plugin.js +19 -102
  155. package/plugins/core/rest-api-row-policy-plugin.js +3 -3
  156. package/plugins/core/socketio-plugin.js +364 -344
  157. package/plugins/storage/local-storage.js +40 -36
  158. package/types/autofilter.d.ts +57 -0
  159. package/types/bulk-methods.d.ts +63 -0
  160. package/types/errors.d.ts +118 -0
  161. package/types/fastify-options.d.ts +19 -0
  162. package/types/file-detectors.d.ts +33 -0
  163. package/types/file-storage.d.ts +48 -0
  164. package/types/hook-context.d.ts +180 -0
  165. package/types/plugin-options.d.ts +53 -0
  166. package/types/relationship-methods.d.ts +74 -0
  167. package/types/representations.d.ts +50 -0
  168. package/types/resource-methods.d.ts +95 -0
  169. package/types/resource-schema.d.ts +157 -0
  170. package/types/row-policy.d.ts +31 -0
  171. package/types/runtime.d.ts +132 -0
  172. package/types/transactions.d.ts +21 -0
  173. package/.claude/agents/library-guide-expert.md +0 -30
  174. package/.claude/agents/rest-api-internals-expert.md +0 -54
  175. package/.claude/settings.local.json +0 -70
  176. package/AGENTS.md +0 -34
  177. package/CLAUDE.md +0 -160
  178. package/CRUSH.md +0 -55
  179. package/JSKIT_BACKEND_MIGRATION_TRACKER.txt +0 -180
  180. package/SINGLE_VALIDATION_CONTRACT_TODO.txt +0 -172
  181. package/TODO.todo +0 -13
  182. package/connectors_ideas/fastify.js +0 -217
  183. package/connectors_ideas/koa-plugin.js +0 -112
  184. package/docs/.bundle/config +0 -2
  185. package/docs/COMPARISON.md +0 -433
  186. package/docs/GUIDE/GUIDE_1_Initial_Setup.md +0 -1057
  187. package/docs/GUIDE/GUIDE_2_1_The_Starting_Point.md +0 -88
  188. package/docs/GUIDE/GUIDE_2_2_Manipulating_And_Searching_Tables.md +0 -808
  189. package/docs/GUIDE/GUIDE_2_3_BelongsTo_Relationships.md +0 -491
  190. package/docs/GUIDE/GUIDE_2_4_HasMany_Records.md +0 -398
  191. package/docs/GUIDE/GUIDE_2_5_HasMany_Polymorphic.md +0 -898
  192. package/docs/GUIDE/GUIDE_2_6_Many_To_Many.md +0 -712
  193. package/docs/GUIDE/GUIDE_2_7_Pagination_And_Ordering.md +0 -1238
  194. package/docs/GUIDE/GUIDE_2_8_Effects_of_PUT_and_PATCH.md +0 -748
  195. package/docs/GUIDE/GUIDE_2_9_Relationships_Urls.md +0 -517
  196. package/docs/GUIDE/GUIDE_2_Data_And_Relations.md +0 -66
  197. package/docs/GUIDE/GUIDE_3_Field_Transformations.md +0 -1252
  198. package/docs/GUIDE/GUIDE_5_Non-Db_Resources.md +0 -1107
  199. package/docs/GUIDE/GUIDE_6_Positioning.md +0 -692
  200. package/docs/GUIDE/GUIDE_7_Hooks_Data_Management_And_Plugins.md +0 -1491
  201. package/docs/GUIDE/GUIDE_X_Bulk_Operations.md +0 -732
  202. package/docs/GUIDE/GUIDE_X_Cors.md +0 -1957
  203. package/docs/GUIDE/GUIDE_X_Fastify.md +0 -78
  204. package/docs/GUIDE/GUIDE_X_File_Uploads.md +0 -729
  205. package/docs/GUIDE/GUIDE_X_Knex_Schema_And_Migrations.md +0 -370
  206. package/docs/GUIDE/GUIDE_X_Query_Projections.md +0 -159
  207. package/docs/GUIDE/GUIDE_X_SocketIO.md +0 -1040
  208. package/docs/GUIDE/GUIDE_X_Writing_Plugins.md +0 -201
  209. package/docs/Gemfile +0 -6
  210. package/docs/Gemfile.lock +0 -308
  211. package/docs/ONBOARDING.md +0 -315
  212. package/docs/_config.yml +0 -36
  213. package/docs/_layouts/default.html +0 -123
  214. package/docs/apple-touch-icon.svg +0 -4
  215. package/docs/assets/css/style.scss +0 -392
  216. package/docs/favicon.svg +0 -4
  217. package/docs/index.md +0 -164
  218. package/docs/mini-guides/file-uploads-complete.md +0 -161
  219. package/eslint.config.js +0 -13
  220. package/examples/url-override-example.js +0 -137
  221. package/fixing_plan.md +0 -112
  222. package/plugins/core/lib/anyapi/query/anyapi-query-adapter.js +0 -657
  223. package/plugins/core/lib/querying-writing/return-record-settings.js +0 -47
  224. package/plugins/core/lib/writing/knex-json-api-transformers-writing.js +0 -110
  225. package/plugins/storage/s3-storage.js +0 -93
  226. package/quickTest.js +0 -136
  227. package/scripts/debug-anyapi-review-filter.mjs +0 -63
  228. package/scripts/inspect-anyapi-descriptor-country.mjs +0 -19
  229. package/scripts/inspect-anyapi-descriptor.mjs +0 -19
  230. package/scripts/run-docs.js +0 -133
  231. package/test-debug.js +0 -59
  232. package/tests/README.md +0 -202
  233. package/tests/TEST_TEMPLATE.test.js +0 -178
  234. package/tests/anyapi-basic.test.js +0 -105
  235. package/tests/anyapi-cursor-pagination.test.js +0 -150
  236. package/tests/anyapi-custom-idproperty.test.js +0 -163
  237. package/tests/autofilter.test.js +0 -463
  238. package/tests/bulk-operations.test.js +0 -657
  239. package/tests/computed-fields.test.js +0 -586
  240. package/tests/cors.test.js +0 -834
  241. package/tests/curl.test.js +0 -467
  242. package/tests/custom-id-mysql-returning-fallback.test.js +0 -128
  243. package/tests/custom-id-simplified-post.test.js +0 -75
  244. package/tests/custom-idproperty-relationships.test.js +0 -932
  245. package/tests/db-introspection.test.js +0 -521
  246. package/tests/db-table-operations.test.js +0 -798
  247. package/tests/fastify-plugin.test.js +0 -184
  248. package/tests/field-getters.test.js +0 -370
  249. package/tests/field-setters.test.js +0 -306
  250. package/tests/file-handling.test.js +0 -118
  251. package/tests/fixtures/api-configs.js +0 -2509
  252. package/tests/full-jsonapi-belongsto-linkage.test.js +0 -150
  253. package/tests/helpers/fake-fastify.js +0 -99
  254. package/tests/helpers/storage-mode.js +0 -65
  255. package/tests/helpers/test-utils.js +0 -203
  256. package/tests/http-connectors-parity.test.js +0 -395
  257. package/tests/id-normalization.test.js +0 -444
  258. package/tests/include-depth-validation.test.js +0 -331
  259. package/tests/includes.test.js +0 -427
  260. package/tests/local-storage.test.js +0 -96
  261. package/tests/logical-id-write-validation.test.js +0 -151
  262. package/tests/nested-includes.test.js +0 -617
  263. package/tests/pagination-cursor-multifield.test.js +0 -643
  264. package/tests/pagination-enhanced.test.js +0 -563
  265. package/tests/polymorphic-relationships.test.js +0 -347
  266. package/tests/polymorphic-simple.test.js +0 -148
  267. package/tests/positioning-advanced.test.js +0 -692
  268. package/tests/positioning-extended.test.js +0 -915
  269. package/tests/positioning-multifilter.test.js +0 -699
  270. package/tests/positioning.test.js +0 -560
  271. package/tests/post-commit-hooks.test.js +0 -120
  272. package/tests/projected-fields.test.js +0 -319
  273. package/tests/public-exports.test.js +0 -15
  274. package/tests/put-create-parity.test.js +0 -216
  275. package/tests/queries.test.js +0 -521
  276. package/tests/query-limits.test.js +0 -584
  277. package/tests/relationship-endpoints.test.js +0 -620
  278. package/tests/relationship-include-storage-adapter.test.js +0 -56
  279. package/tests/relationships.test.js +0 -681
  280. package/tests/rest-api.test.js +0 -337
  281. package/tests/return-record-settings.test.js +0 -184
  282. package/tests/row-policy.test.js +0 -489
  283. package/tests/s3-storage.test.js +0 -38
  284. package/tests/searchschema-knex-behavior.test.js +0 -187
  285. package/tests/searchschema-merge.test.js +0 -151
  286. package/tests/socketio.test.js +0 -860
  287. package/tests/storage-mapping.test.js +0 -362
  288. package/tests/test-development-guide.md +0 -370
  289. package/tests/virtual-fields.test.js +0 -404
@@ -1,6 +1,22 @@
1
- import { requirePackage } from 'hooked-api'
2
-
3
- const pendingBroadcasts = new WeakMap()
1
+ import { throwMissingPackage } from '../../lib/missing-package.js'
2
+ import { randomUUID } from 'node:crypto'
3
+ import { createEnhancedLogger } from '../../lib/enhanced-logger.js'
4
+ import { errorMessage, getOperationDiagnosticContext } from '../../lib/error-context.js'
5
+ import { queryConstraint } from './lib/querying/query-constraint.js'
6
+
7
+ const pendingRoomChanges = new WeakMap()
8
+
9
+ // Preserve room mutation order when an adapter completes joins/leaves asynchronously.
10
+ async function changeRoom (socket, method, room) {
11
+ const previous = pendingRoomChanges.get(socket) || Promise.resolve()
12
+ const pending = previous.catch(() => {}).then(() => socket[method](room))
13
+ pendingRoomChanges.set(socket, pending)
14
+ try {
15
+ await pending
16
+ } finally {
17
+ if (pendingRoomChanges.get(socket) === pending) pendingRoomChanges.delete(socket)
18
+ }
19
+ }
4
20
 
5
21
  function normalizeAuthContext (auth) {
6
22
  if (!auth) return null
@@ -15,96 +31,28 @@ function normalizeAuthContext (auth) {
15
31
  return normalized
16
32
  }
17
33
 
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) {
34
+ async function matchesSubscription ({ subscription, scopeName, id, transaction, api, scopes, log }) {
35
+ try {
36
+ const result = await api.resources[scopeName].query({
37
+ format: 'jsonapi',
38
+ transaction,
39
+ queryParams: { filters: subscription.filters, include: [], page: { size: 1 } },
40
+ [queryConstraint]: { scopeName, values: { id } }
41
+ }, { ...subscription.context, auth: subscription.auth })
42
+ return result.data.some(record => record.type === scopeName && String(record.id) === String(id))
43
+ } catch (error) {
44
+ try {
45
+ await createEnhancedLogger(log, { schemaInfo: scopes[scopeName]?.vars?.schemaInfo })
46
+ .warn('Socket.IO subscription query failed', {
47
+ ...getOperationDiagnosticContext({ transaction }, {
48
+ phase: 'subscriptionMatch', method: 'query', scopeName, backend: api.knex?.instance?.client?.config?.client
49
+ }),
50
+ subscriptionId: subscription.id,
51
+ error
52
+ })
53
+ } catch { /* Warning failure must preserve the existing non-match result. */ }
24
54
  return false
25
55
  }
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]
39
- }
40
-
41
- if (recordValue === null || recordValue === undefined) {
42
- if (filterValue !== null && filterValue !== undefined) {
43
- return false
44
- }
45
- continue
46
- }
47
-
48
- if (typeof fieldDef.filterOperator === 'function') {
49
- if (!fieldDef.filterRecord?.(record, filterValue)) {
50
- return false
51
- }
52
- continue
53
- }
54
-
55
- const operator = fieldDef.filterOperator || '='
56
-
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)) {
66
- return false
67
- }
68
- } else if (recordValue !== filterValue) {
69
- return false
70
- }
71
- break
72
- case 'between':
73
- if (Array.isArray(filterValue) && filterValue.length === 2) {
74
- if (recordValue < filterValue[0] || recordValue > filterValue[1]) {
75
- return false
76
- }
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
- }
106
-
107
- return true
108
56
  }
109
57
 
110
58
  function buildConfig (pluginOptions = {}) {
@@ -151,137 +99,131 @@ async function authenticateSocket ({ socket, api, helpers, log, config }) {
151
99
  try {
152
100
  await onAuthenticationFailed({ socket, error, log })
153
101
  } catch (hookError) {
154
- log.error('socketio auth failure handler threw error', hookError)
102
+ try {
103
+ await log.error('socketio auth failure handler threw error', {
104
+ ...getOperationDiagnosticContext({}, { phase: 'authenticationFailureHook', method: 'socketAuthenticate', backend: 'socketio' }), error: hookError
105
+ })
106
+ } catch { /* A diagnostic failure must not replace the authentication rejection. */ }
155
107
  }
156
108
  }
157
109
  throw error
158
110
  }
159
111
  }
160
112
 
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' })
113
+ async function validateSubscriptionFilters (filters, scope, resource) {
114
+ if (!filters || typeof filters !== 'object' || Array.isArray(filters)) {
115
+ throw Object.assign(new Error('Filters must be an object'), { code: 'INVALID_FILTERS' })
173
116
  }
174
-
175
- const scope = scopes[resource]
176
- if (!scope) {
177
- throw Object.assign(new Error(`Resource '${resource}' not found`), { code: 'RESOURCE_NOT_FOUND' })
117
+ if (Object.keys(filters).length === 0) return {}
118
+ const { searchSchemaStructure, searchSchemaInstance } = scope.vars.schemaInfo
119
+ if (!searchSchemaStructure || !searchSchemaInstance) {
120
+ throw Object.assign(new Error(`Filtering is not enabled for resource '${resource}'`), { code: 'FILTERING_NOT_ENABLED' })
178
121
  }
179
-
180
- const auth = socket.data.auth || null
181
-
182
- if (scope.checkPermissions) {
183
- await scope.checkPermissions({
184
- method: 'query',
185
- originalContext: { auth }
186
- })
122
+ for (const key of Object.keys(filters)) {
123
+ if (!Object.hasOwn(searchSchemaStructure, key)) {
124
+ throw Object.assign(new Error(`Unknown filter '${key}'`), { code: 'INVALID_FILTERS' })
125
+ }
187
126
  }
127
+ const { validatedObject, errors } = await searchSchemaInstance.patch(filters)
128
+ if (errors && Object.keys(errors).length) {
129
+ throw Object.assign(new Error('Invalid filter values'), { code: 'INVALID_FILTERS', details: errors })
130
+ }
131
+ return validatedObject
132
+ }
188
133
 
189
- const schemaInfo = scope.vars?.schemaInfo
190
- const searchSchemaStructure = schemaInfo?.searchSchemaStructure
191
- const searchSchemaInstance = schemaInfo?.searchSchemaInstance
192
-
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' })
134
+ async function registerSubscription ({ socket, data, scopes, runHooks, log, config }) {
135
+ let scope, scopeName
136
+ try {
137
+ if (!data || typeof data !== 'object' || Array.isArray(data)) {
138
+ throw Object.assign(new Error('Subscription must be an object'), { code: 'INVALID_SUBSCRIPTION' })
197
139
  }
198
-
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
- )
140
+ for (const key of Object.keys(data)) {
141
+ if (!['resource', 'filters', 'subscriptionId'].includes(key)) {
142
+ throw Object.assign(new Error(`Subscription option '${key}' is not supported`), { code: 'UNSUPPORTED_OPTION' })
206
143
  }
207
144
  }
208
-
209
- const { validatedObject, errors } = await searchSchemaInstance.patch(validatedFilters)
210
-
211
- if (errors && Object.keys(errors).length > 0) {
212
- const error = new Error('Invalid filter values')
213
- error.code = 'INVALID_FILTERS'
214
- error.details = errors
215
- throw error
145
+ const { resource, filters = {}, subscriptionId = randomUUID() } = data
146
+ if (!resource || typeof resource !== 'string') {
147
+ throw Object.assign(new Error('Resource is required'), { code: 'RESOURCE_REQUIRED' })
216
148
  }
217
-
218
- Object.assign(validatedFilters, validatedObject)
219
- }
220
-
221
- if (include !== undefined) {
222
- if (!Array.isArray(include)) {
223
- throw Object.assign(new Error('Include parameter must be an array'), { code: 'INVALID_INCLUDE' })
149
+ const candidate = Object.hasOwn(scopes, resource) ? scopes[resource] : undefined
150
+ if (!candidate?.vars?.schemaInfo) {
151
+ throw Object.assign(new Error(`Resource '${resource}' not found`), { code: 'RESOURCE_NOT_FOUND' })
224
152
  }
225
-
226
- const relationships = schemaInfo?.schemaRelationships || {}
227
- for (const includePath of include) {
228
- const baseName = includePath.split('.')[0]
229
- if (!relationships[baseName]) {
230
- throw Object.assign(new Error(`Invalid relationship '${baseName}' for resource '${resource}'`), {
231
- code: 'INVALID_INCLUDE'
232
- })
233
- }
153
+ scope = candidate
154
+ scopeName = resource
155
+ if (typeof subscriptionId !== 'string' || subscriptionId.trim() === '') {
156
+ throw Object.assign(new Error('Subscription ID must be a nonempty string'), { code: 'INVALID_SUBSCRIPTION_ID' })
234
157
  }
235
- }
236
-
237
- if (fields !== undefined) {
238
- if (typeof fields !== 'object' || Array.isArray(fields) || fields === null) {
239
- throw Object.assign(new Error('Fields parameter must be an object'), { code: 'INVALID_FIELDS' })
158
+ const auth = socket.data.auth || null
159
+ const subscription = {
160
+ id: subscriptionId,
161
+ resource,
162
+ filters: await validateSubscriptionFilters(filters, scope, resource),
163
+ auth,
164
+ context: {},
165
+ createdAt: new Date()
166
+ }
167
+ await runHooks('subscriptionFilters', { subscription, auth })
168
+ if (subscription.id !== subscriptionId || subscription.resource !== resource) {
169
+ throw Object.assign(new Error('subscriptionFilters may change filters, not subscription identity'), { code: 'INVALID_SUBSCRIPTION' })
170
+ }
171
+ subscription.filters = await validateSubscriptionFilters(subscription.filters, scope, resource)
172
+ if (!subscription.context || typeof subscription.context !== 'object' || Array.isArray(subscription.context)) {
173
+ throw Object.assign(new Error('Subscription context must be an object'), { code: 'INVALID_SUBSCRIPTION_CONTEXT' })
240
174
  }
175
+ await scope.checkPermissions({
176
+ method: 'query',
177
+ originalContext: { ...subscription.context, auth: subscription.auth, scopeName: resource, queryParams: { filters: subscription.filters } }
178
+ })
179
+ subscription.generation = randomUUID()
241
180
 
242
- for (const [resourceType, fieldList] of Object.entries(fields)) {
243
- if (!Array.isArray(fieldList)) {
244
- throw Object.assign(new Error(`Fields for '${resourceType}' must be an array`), { code: 'INVALID_FIELDS' })
181
+ if (!socket.connected) throw Object.assign(new Error('Socket disconnected during subscription'), { code: 'SOCKET_DISCONNECTED' })
182
+ const subscriptions = socket.data.subscriptions
183
+ if (subscriptions.some(item => item.id === subscriptionId)) {
184
+ throw Object.assign(new Error('Subscription ID is already active'), { code: 'SUBSCRIPTION_EXISTS' })
185
+ }
186
+ if (subscriptions.length >= config.subscriptions.maxPerSocket) {
187
+ throw Object.assign(new Error('Subscription limit reached for this connection'), { code: 'SUBSCRIPTION_LIMIT' })
188
+ }
189
+ // Reserve capacity before awaiting an adapter's room join.
190
+ subscriptions.push(subscription)
191
+ try {
192
+ await changeRoom(socket, 'join', `${resource}:updates`)
193
+ if (!socket.connected) {
194
+ // A late adapter join can recreate the disconnected socket's membership entry.
195
+ await socket.adapter.delAll(socket.id)
196
+ throw Object.assign(new Error('Socket disconnected during subscription'), { code: 'SOCKET_DISCONNECTED' })
245
197
  }
198
+ } catch (error) {
199
+ const index = subscriptions.indexOf(subscription)
200
+ if (index !== -1) subscriptions.splice(index, 1)
201
+ throw error
246
202
  }
203
+ try {
204
+ await log.info(`Socket ${socket.id} subscribed to ${resource}`, {
205
+ ...getOperationDiagnosticContext({}, { phase: 'subscriptionReady', method: 'subscribe', scopeName, backend: 'socketio' }), subscriptionId
206
+ })
207
+ } catch { /* Logging cannot undo a subscription already installed in its room. */ }
208
+ return { subscriptionId, resource, filters: subscription.filters, status: 'active' }
209
+ } catch (error) {
210
+ // Only a successfully resolved resource supplies diagnostic field policy.
211
+ try {
212
+ await createEnhancedLogger(log, { schemaInfo: scope?.vars?.schemaInfo })
213
+ .error('Socket.IO subscribe error', {
214
+ ...getOperationDiagnosticContext({}, { phase: 'admission', method: 'subscribe', scopeName, backend: 'socketio' }), operation: 'subscribe', error
215
+ })
216
+ } catch { /* Logging must not replace the subscription rejection. */ }
217
+ throw error
247
218
  }
219
+ }
248
220
 
249
- if (!socket.data.subscriptions) {
250
- socket.data.subscriptions = new Map()
251
- }
252
-
253
- if (socket.data.subscriptions.size >= config.subscriptions.maxPerSocket) {
254
- throw Object.assign(new Error('Subscription limit reached for this connection'), { code: 'SUBSCRIPTION_LIMIT' })
255
- }
256
-
257
- const subscription = {
258
- id: subscriptionId || `${resource}-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`,
259
- resource,
260
- filters: validatedFilters,
261
- include: include || [],
262
- fields: fields || {},
263
- auth,
264
- createdAt: new Date()
265
- }
266
-
267
- await runHooks('subscriptionFilters', { subscription, auth })
268
-
269
- const roomName = `${resource}:updates`
270
- socket.join(roomName)
271
- socket.data.subscriptions.set(subscription.id, subscription)
272
-
273
- log.info(`Socket ${socket.id} subscribed to ${resource}`, {
274
- subscriptionId: subscription.id,
275
- filters: subscription.filters
276
- })
277
-
221
+ function subscriptionError (error) {
222
+ let code
223
+ try { code = error?.code } catch { /* Invalid error metadata must not prevent rejection. */ }
278
224
  return {
279
- subscriptionId: subscription.id,
280
- resource: subscription.resource,
281
- filters: subscription.filters,
282
- include: subscription.include,
283
- fields: subscription.fields,
284
- status: 'active'
225
+ code: typeof code === 'string' && code ? code : 'SUBSCRIBE_ERROR',
226
+ message: error == null ? 'Subscription failed' : errorMessage(error)
285
227
  }
286
228
  }
287
229
 
@@ -303,10 +245,7 @@ async function handleRestoreSubscriptions ({ socket, subscriptions, scopes, runH
303
245
  } catch (error) {
304
246
  failed.push({
305
247
  subscriptionId: sub?.subscriptionId || null,
306
- error: {
307
- code: error.code || 'SUBSCRIBE_ERROR',
308
- message: error.message
309
- }
248
+ error: subscriptionError(error)
310
249
  })
311
250
  }
312
251
  }
@@ -314,62 +253,49 @@ async function handleRestoreSubscriptions ({ socket, subscriptions, scopes, runH
314
253
  return { restored, failed }
315
254
  }
316
255
 
317
- function getRecordForFiltering (context) {
318
- return context?.minimalRecord || context?.originalMinimalRecord || null
319
- }
320
-
321
- async function performBroadcast ({ method, scopeName, id, context, api, io, log }) {
322
- if (!io) return
323
-
256
+ async function performBroadcast ({ method, scopeName, id, isCreate, recipients, api, scopes, io, log }) {
257
+ if (!io || recipients.length === 0) return
324
258
  const scope = api.resources[scopeName]
325
- if (!scope) {
326
- log.warn(`Socket.IO broadcast skipped: unknown resource ${scopeName}`)
327
- return
328
- }
329
-
330
- const roomName = `${scopeName}:updates`
331
- const socketsInRoom = await io.in(roomName).fetchSockets()
332
- if (!socketsInRoom || socketsInRoom.length === 0) {
333
- log.debug(`Socket.IO broadcast skipped: no subscribers for ${roomName}`)
334
- return
335
- }
336
-
337
- const schemaInfo = scope.vars?.schemaInfo
338
- const searchSchemaStructure = schemaInfo?.searchSchemaStructure
339
- const recordForFiltering = getRecordForFiltering(context)
340
-
341
- for (const socket of socketsInRoom) {
342
- const subscriptions = socket.data.subscriptions
343
- if (!subscriptions || subscriptions.size === 0) continue
344
-
345
- for (const subscription of subscriptions.values()) {
346
- if (subscription.resource !== scopeName) continue
347
-
348
- if (recordForFiltering && subscription.filters && Object.keys(subscription.filters).length > 0) {
349
- if (!matchesFilters(recordForFiltering, subscription.filters, searchSchemaStructure)) {
350
- continue
351
- }
352
- }
353
-
354
- const notification = {
355
- type: `resource.${method}d`,
356
- resource: scopeName,
357
- id,
358
- action: method,
359
- subscriptionId: subscription.id,
360
- meta: {
361
- timestamp: new Date().toISOString()
362
- }
363
- }
364
-
365
- if (method === 'delete') {
366
- notification.deletedRecord = { id }
367
- }
259
+ const sockets = new Map((await io.in(`${scopeName}:updates`).fetchSockets()).map(socket => [socket.id, socket]))
260
+
261
+ for (const recipient of recipients) {
262
+ const socket = sockets.get(recipient.socketId)
263
+ const subscription = socket?.data.subscriptions?.find(item =>
264
+ item.resource === scopeName && item.id === recipient.subscriptionId && item.generation === recipient.generation
265
+ )
266
+ if (!subscription) continue
267
+ try {
268
+ await scope.checkPermissions({
269
+ method: 'query',
270
+ originalContext: { ...subscription.context, auth: subscription.auth, scopeName, id, queryParams: { filters: subscription.filters } }
271
+ })
272
+ } catch (error) {
273
+ try {
274
+ await createEnhancedLogger(log, { schemaInfo: scopes[scopeName]?.vars?.schemaInfo })
275
+ .warn('Socket.IO notification permission check failed', {
276
+ ...getOperationDiagnosticContext({}, {
277
+ phase: 'notificationPermission', method: 'query', scopeName, backend: api.knex?.instance?.client?.config?.client
278
+ }),
279
+ subscriptionId: subscription.id,
280
+ error
281
+ })
282
+ } catch { /* Warning failure must not interrupt delivery to other recipients. */ }
283
+ continue
284
+ }
368
285
 
369
- socket.emit('subscription.update', notification)
370
- log.debug(`Socket.IO broadcast: ${scopeName}/${id} -> socket ${socket.id}`)
371
- break
286
+ let type = 'resource.updated'
287
+ if (method === 'delete') type = 'resource.deleted'
288
+ else if (method === 'post' || (method === 'put' && isCreate)) type = 'resource.created'
289
+ const notification = {
290
+ type,
291
+ resource: scopeName,
292
+ id: String(id),
293
+ action: method,
294
+ subscriptionId: subscription.id,
295
+ meta: { timestamp: new Date().toISOString() }
372
296
  }
297
+ if (method === 'delete') notification.deletedRecord = { id: String(id) }
298
+ socket.emit('subscription.update', notification)
373
299
  }
374
300
  }
375
301
 
@@ -378,82 +304,141 @@ export const SocketIOPlugin = {
378
304
  dependencies: ['rest-api'],
379
305
 
380
306
  async install ({ api, addHook, log, scopes, helpers, vars, runHooks, pluginOptions = {} }) {
307
+ log = createEnhancedLogger(log)
381
308
  const config = buildConfig(pluginOptions)
309
+ const pendingChanges = new WeakMap()
310
+ const pendingBroadcasts = new WeakMap()
382
311
 
383
312
  let Server
384
313
  try {
385
314
  ({ Server } = await import('socket.io'))
386
- } catch (error) {
387
- requirePackage('socket.io', 'socketio', 'Socket.IO is required for WebSocket support. This is a peer dependency.')
388
- throw error
315
+ } catch {
316
+ throwMissingPackage('socket.io', 'socketio', 'Socket.IO is required for WebSocket support. This is a peer dependency.')
389
317
  }
390
318
 
391
- let createAdapter
392
- let createClient
393
319
  let io
320
+ let starting = false
321
+
322
+ async function logRedisWarning (message, details) {
323
+ try { await log.warn(message, details) } catch { /* Diagnostic writers must not escape Redis event handlers. */ }
324
+ }
325
+
326
+ async function logSocketInfo (message, details) {
327
+ try { await log.info(message, details) } catch { /* Information logs must not interrupt socket event handling. */ }
328
+ }
329
+
330
+ async function closeRedisClient (role, client) {
331
+ const diagnostic = getOperationDiagnosticContext({}, { phase: 'redisShutdown', method: 'socketioShutdown', backend: 'redis' })
332
+ try {
333
+ if (client.isOpen) await client.close()
334
+ } catch (error) {
335
+ await logRedisWarning('Socket.IO Redis shutdown failed', { ...diagnostic, role, error })
336
+ }
337
+ }
394
338
 
395
339
  api.startSocketServer = async (server, startOptions = {}) => {
340
+ if (starting || io) throw new Error('Socket.IO server is already starting or started')
396
341
  const authConfig = { ...config.auth, ...(startOptions.auth || {}) }
397
342
  const redisConfig = startOptions.redis ?? pluginOptions.redis ?? null
398
343
 
399
- config.auth = authConfig
400
-
401
344
  const defaultPath = vars.transport?.mountPath ? `${vars.transport.mountPath}/socket.io` : '/socket.io'
402
345
  const path = startOptions.path || config.transport.path || defaultPath
403
346
  const cors = startOptions.cors || config.transport.cors || { origin: '*', methods: ['GET', 'POST'] }
404
347
 
405
- io = new Server(server, {
406
- path,
407
- cors,
408
- transports: ['websocket', 'polling']
409
- })
348
+ let redisClients
349
+ let createAdapter
350
+ let pendingConnections = []
351
+ starting = true
352
+ try {
353
+ if (redisConfig) {
354
+ let createClient
355
+ try {
356
+ ({ createClient } = await import('redis'))
357
+ } catch {
358
+ throwMissingPackage('redis', 'socketio', 'Redis is required for Socket.IO horizontal scaling. This is a peer dependency.')
359
+ }
410
360
 
411
- vars.socketIO = io
412
- api.io = io
361
+ try {
362
+ ({ createAdapter } = await import('@socket.io/redis-adapter'))
363
+ } catch {
364
+ throwMissingPackage('@socket.io/redis-adapter', 'socketio',
365
+ 'Socket.IO Redis adapter is required for horizontal scaling. This is a peer dependency.')
366
+ }
413
367
 
414
- if (redisConfig) {
415
- try {
416
- ({ createClient } = await import('redis'))
417
- } catch (error) {
418
- requirePackage('redis', 'socketio', 'Redis is required for Socket.IO horizontal scaling. This is a peer dependency.')
419
- throw error
368
+ const pubClient = createClient(redisConfig)
369
+ redisClients = { pubClient }
370
+ redisClients.subClient = pubClient.duplicate()
371
+ const diagnostic = getOperationDiagnosticContext({}, { phase: 'redisConnect', method: 'socketioStart', backend: 'redis' })
372
+ for (const [role, client] of Object.entries(redisClients)) {
373
+ client.on('error', error => logRedisWarning('Socket.IO Redis connection error', { ...diagnostic, role, error }))
374
+ }
375
+ // Async wrappers retain both attempts even if connect throws synchronously.
376
+ pendingConnections = Object.values(redisClients).map(async client => client.connect())
377
+ await Promise.all(pendingConnections)
420
378
  }
421
379
 
422
- try {
423
- ({ createAdapter } = await import('@socket.io/redis-adapter'))
424
- } catch (error) {
425
- requirePackage('@socket.io/redis-adapter', 'socketio',
426
- 'Socket.IO Redis adapter is required for horizontal scaling. This is a peer dependency.')
427
- throw error
380
+ const socketServer = new Server({ path, cors, transports: ['websocket', 'polling'] })
381
+ if (redisClients) {
382
+ socketServer.adapter(createAdapter(redisClients.pubClient, redisClients.subClient))
383
+ await logSocketInfo('Socket.IO configured with Redis adapter', getOperationDiagnosticContext({}, {
384
+ phase: 'redisConnect', method: 'socketioStart', backend: 'redis'
385
+ }))
428
386
  }
429
-
430
- const pubClient = createClient(redisConfig)
431
- const subClient = pubClient.duplicate()
432
-
433
- await Promise.all([
434
- pubClient.connect(),
435
- subClient.connect()
436
- ])
437
-
438
- io.adapter(createAdapter(pubClient, subClient))
439
- vars.socketIORedisClients = { pubClient, subClient }
440
- log.info('Socket.IO configured with Redis adapter')
387
+ socketServer.attach(server)
388
+ io = socketServer
389
+ vars.socketIORedisClients = redisClients
390
+ vars.socketIO = io
391
+ api.io = io
392
+ config.auth = authConfig
393
+ } catch (error) {
394
+ const errors = [error]
395
+ for (const client of Object.values(redisClients || {})) {
396
+ try {
397
+ if (client.isOpen) await client.destroy()
398
+ } catch (cleanupError) {
399
+ errors.push(cleanupError)
400
+ }
401
+ }
402
+ // Failed destruction may leave an attempt pending; do not wait for it forever.
403
+ if (errors.length > 1) {
404
+ throw new AggregateError(errors, 'Socket.IO Redis setup and cleanup failed', { cause: error })
405
+ }
406
+ await Promise.allSettled(pendingConnections)
407
+ throw error
408
+ } finally {
409
+ starting = false
441
410
  }
442
411
 
412
+ const socketServer = io
413
+ server.once('close', () => {
414
+ if (io !== socketServer) return
415
+ io = undefined
416
+ delete api.io
417
+ vars.socketIO = undefined
418
+ vars.socketIORedisClients = undefined
419
+ for (const [role, client] of Object.entries(redisClients || {})) closeRedisClient(role, client)
420
+ })
421
+
443
422
  io.use(async (socket, next) => {
444
423
  try {
445
424
  const authContext = await authenticateSocket({ socket, api, helpers, log, config })
446
425
  socket.data.auth = authContext
447
- socket.data.subscriptions = new Map()
426
+ socket.data.subscriptions = []
448
427
  next()
449
428
  } catch (error) {
450
- log.warn('Socket.IO authentication failed', error)
451
- next(new Error(error.message || 'Authentication failed'))
429
+ try {
430
+ await log.warn('Socket.IO authentication failed', {
431
+ ...getOperationDiagnosticContext({}, { phase: 'authentication', method: 'socketAuthenticate', backend: 'socketio' }), error
432
+ })
433
+ } catch { /* The client must still receive the authentication rejection. */ }
434
+ const message = error == null ? 'Authentication failed' : errorMessage(error) || 'Authentication failed'
435
+ next(new Error(message, { cause: error }))
452
436
  }
453
437
  })
454
438
 
455
439
  io.on('connection', (socket) => {
456
- log.info(`Socket connected: ${socket.id}`, {
440
+ logSocketInfo(`Socket connected: ${socket.id}`, {
441
+ ...getOperationDiagnosticContext({}, { phase: 'socketConnected', method: 'socketConnect', backend: 'socketio' }),
457
442
  userId: socket.data.auth?.userId ?? null
458
443
  })
459
444
 
@@ -463,6 +448,7 @@ export const SocketIOPlugin = {
463
448
  })
464
449
 
465
450
  socket.on('subscribe', async (payload, callback) => {
451
+ callback = typeof callback === 'function' ? callback : null
466
452
  try {
467
453
  const result = await registerSubscription({
468
454
  socket,
@@ -476,19 +462,15 @@ export const SocketIOPlugin = {
476
462
  if (callback) callback({ success: true, data: result })
477
463
  else socket.emit('subscription.created', result)
478
464
  } catch (error) {
479
- log.error('Socket.IO subscribe error', error)
480
- const response = {
481
- error: {
482
- code: error.code || 'SUBSCRIBE_ERROR',
483
- message: error.message
484
- }
485
- }
465
+ const response = { error: subscriptionError(error) }
486
466
  if (callback) callback(response)
487
467
  else socket.emit('subscription.error', response.error)
488
468
  }
489
469
  })
490
470
 
491
- socket.on('unsubscribe', (payload, callback) => {
471
+ socket.on('unsubscribe', async (payload, callback) => {
472
+ callback = typeof callback === 'function' ? callback : null
473
+ let scopeName
492
474
  try {
493
475
  const subscriptionId = payload?.subscriptionId
494
476
  if (!subscriptionId) {
@@ -497,29 +479,36 @@ export const SocketIOPlugin = {
497
479
  return
498
480
  }
499
481
 
500
- const subscription = socket.data.subscriptions?.get(subscriptionId)
482
+ const subscription = socket.data.subscriptions?.find(item => item.id === subscriptionId)
501
483
  if (!subscription) {
502
484
  const error = { code: 'SUBSCRIPTION_NOT_FOUND', message: 'Subscription not found' }
503
485
  if (callback) callback({ error })
504
486
  return
505
487
  }
488
+ scopeName = subscription.resource
506
489
 
507
- socket.data.subscriptions.delete(subscriptionId)
508
- const hasOther = Array.from(socket.data.subscriptions.values())
490
+ socket.data.subscriptions.splice(socket.data.subscriptions.indexOf(subscription), 1)
491
+ const hasOther = socket.data.subscriptions
509
492
  .some((sub) => sub.resource === subscription.resource)
510
493
  if (!hasOther) {
511
- socket.leave(`${subscription.resource}:updates`)
494
+ await changeRoom(socket, 'leave', `${subscription.resource}:updates`)
512
495
  }
513
496
 
497
+ await logSocketInfo(`Socket ${socket.id} unsubscribed from ${subscription.resource}`, {
498
+ ...getOperationDiagnosticContext({}, { phase: 'subscriptionRemoved', method: 'unsubscribe', scopeName, backend: 'socketio' }), subscriptionId
499
+ })
514
500
  if (callback) callback({ success: true })
515
- log.info(`Socket ${socket.id} unsubscribed from ${subscription.resource}`, { subscriptionId })
516
501
  } catch (error) {
517
- log.error('Socket.IO unsubscribe error', error)
502
+ try {
503
+ await createEnhancedLogger(log, { schemaInfo: scopes[scopeName]?.vars?.schemaInfo }).error('Socket.IO unsubscribe error', {
504
+ ...getOperationDiagnosticContext({}, { phase: 'subscriptionRemoval', method: 'unsubscribe', scopeName, backend: 'socketio' }), error
505
+ })
506
+ } catch { /* Report the room failure even when diagnostics fail. */ }
518
507
  if (callback) {
519
508
  callback({
520
509
  error: {
521
510
  code: 'UNSUBSCRIBE_ERROR',
522
- message: error.message
511
+ message: error == null ? 'Unsubscribe failed' : errorMessage(error)
523
512
  }
524
513
  })
525
514
  }
@@ -527,6 +516,7 @@ export const SocketIOPlugin = {
527
516
  })
528
517
 
529
518
  socket.on('restore-subscriptions', async (payload, callback) => {
519
+ callback = typeof callback === 'function' ? callback : null
530
520
  try {
531
521
  const subscriptions = payload?.subscriptions
532
522
  if (!Array.isArray(subscriptions)) {
@@ -546,12 +536,16 @@ export const SocketIOPlugin = {
546
536
 
547
537
  if (callback) callback({ success: true, ...result })
548
538
  } catch (error) {
549
- log.error('Socket.IO restore subscriptions error', error)
539
+ try {
540
+ await log.error('Socket.IO restore subscriptions error', {
541
+ ...getOperationDiagnosticContext({}, { phase: 'subscriptionRestore', method: 'restoreSubscriptions', backend: 'socketio' }), error
542
+ })
543
+ } catch { /* Report the restore failure even when diagnostics fail. */ }
550
544
  if (callback) {
551
545
  callback({
552
546
  error: {
553
547
  code: 'RESTORE_ERROR',
554
- message: error.message
548
+ message: error == null ? 'Subscription restore failed' : errorMessage(error)
555
549
  }
556
550
  })
557
551
  }
@@ -559,58 +553,84 @@ export const SocketIOPlugin = {
559
553
  })
560
554
 
561
555
  socket.on('disconnect', (reason) => {
562
- log.info(`Socket disconnected: ${socket.id}`, {
556
+ logSocketInfo(`Socket disconnected: ${socket.id}`, {
557
+ ...getOperationDiagnosticContext({}, { phase: 'socketDisconnected', method: 'socketDisconnect', backend: 'socketio' }),
563
558
  reason,
564
- subscriptionCount: socket.data.subscriptions?.size || 0
559
+ subscriptionCount: socket.data.subscriptions?.length || 0
565
560
  })
566
561
  })
567
562
  })
568
563
 
569
- log.info('Socket.IO server started', { path })
564
+ await logSocketInfo('Socket.IO server started', {
565
+ ...getOperationDiagnosticContext({}, { phase: 'socketStartup', method: 'socketioStart', backend: 'socketio' }), path
566
+ })
570
567
  return io
571
568
  }
572
569
 
573
- addHook('finish', 'socketio-broadcast', {}, async ({ context }) => {
574
- const { method, scopeName, id } = context
575
-
576
- if (!io) {
577
- log.debug('Socket.IO broadcast skipped: server not started')
578
- return
579
- }
580
-
581
- if (!['post', 'put', 'patch', 'delete'].includes(method)) {
582
- return
570
+ addHook('beforeDataCall', 'socketio-capture-before', {}, async ({ context }) => {
571
+ pendingChanges.delete(context)
572
+ if (!io || !['post', 'put', 'patch', 'delete', 'postRelationship', 'deleteRelationship'].includes(context.method)) return
573
+ const { scopeName, id, transaction } = context
574
+ const candidates = []
575
+ const sockets = await io.in(`${scopeName}:updates`).fetchSockets()
576
+ for (const socket of sockets) {
577
+ for (const subscription of socket.data.subscriptions || []) {
578
+ if (subscription.resource !== scopeName) continue
579
+ const before = context.method !== 'post' && !(context.method === 'put' && context.isCreate) && id != null &&
580
+ await matchesSubscription({ subscription, scopeName, id, transaction, api, scopes, log })
581
+ candidates.push({ socketId: socket.id, subscription, before })
582
+ }
583
583
  }
584
+ pendingChanges.set(context, candidates)
585
+ })
584
586
 
585
- if (!id && method !== 'delete') {
586
- log.debug('Socket.IO broadcast skipped: missing record id')
587
- return
587
+ addHook('finish', 'socketio-broadcast', {}, async ({ context }) => {
588
+ const candidates = pendingChanges.get(context)
589
+ if (!candidates) return
590
+ pendingChanges.delete(context)
591
+ const { scopeName, id, transaction, isCreate } = context
592
+ if (!io || id == null || id === '') return
593
+ const method = ['postRelationship', 'deleteRelationship'].includes(context.method) ? 'patch' : context.method
594
+ const recipients = []
595
+ for (const { socketId, subscription, before } of candidates) {
596
+ const after = method !== 'delete' && await matchesSubscription({ subscription, scopeName, id, transaction, api, scopes, log })
597
+ if (before || after) recipients.push({ socketId, subscriptionId: subscription.id, generation: subscription.generation })
588
598
  }
589
-
590
- if (context.transaction) {
591
- if (!pendingBroadcasts.has(context.transaction)) {
592
- pendingBroadcasts.set(context.transaction, [])
593
- }
594
- pendingBroadcasts.get(context.transaction).push({ method, scopeName, id, context })
595
- return
599
+ if (recipients.length === 0) return
600
+ // Capture operation values now; callers may reuse or mutate their context.
601
+ const change = { method, scopeName, id, isCreate, recipients }
602
+ if (transaction) {
603
+ if (!pendingBroadcasts.has(transaction)) pendingBroadcasts.set(transaction, [])
604
+ pendingBroadcasts.get(transaction).push(change)
605
+ } else {
606
+ await performBroadcast({ ...change, api, scopes, io, log })
596
607
  }
597
-
598
- await performBroadcast({ method, scopeName, id, context, api, io, log })
599
608
  })
600
609
 
601
610
  addHook('afterCommit', 'socketio-broadcast-deferred', {}, async ({ context }) => {
602
611
  if (!context?.transaction) return
603
612
  const broadcasts = pendingBroadcasts.get(context.transaction)
604
613
  if (!broadcasts) return
614
+ pendingBroadcasts.delete(context.transaction)
605
615
 
606
- for (const broadcast of broadcasts) {
607
- await performBroadcast({ ...broadcast, api, io, log })
616
+ let failed = false
617
+ let firstError
618
+ for (const [broadcastIndex, broadcast] of broadcasts.entries()) {
619
+ try {
620
+ await performBroadcast({ ...broadcast, api, scopes, io, log })
621
+ } catch (error) {
622
+ (context.cleanupErrors ||= []).push({ phase: 'socketioBroadcast', broadcastIndex, error })
623
+ if (!failed) {
624
+ failed = true
625
+ firstError = error
626
+ }
627
+ }
608
628
  }
609
-
610
- pendingBroadcasts.delete(context.transaction)
629
+ if (failed) throw firstError
611
630
  })
612
631
 
613
632
  addHook('afterRollback', 'socketio-cleanup-broadcasts', {}, async ({ context }) => {
633
+ pendingChanges.delete(context)
614
634
  if (!context?.transaction) return
615
635
  pendingBroadcasts.delete(context.transaction)
616
636
  })