json-rest-api 1.0.29 → 2.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +10 -670
- package/LICENSE-GPL-3.0 +674 -0
- package/LICENSE-MIT +21 -0
- package/README.md +39 -30
- package/docs/API.md +744 -1273
- package/docs/GUIDE/01-getting-started.md +338 -0
- package/docs/GUIDE/02-resources-and-relationships.md +33 -0
- package/docs/GUIDE/03-running-example.md +87 -0
- package/docs/GUIDE/04-creating-and-querying.md +181 -0
- package/docs/GUIDE/05-belongs-to.md +177 -0
- package/docs/GUIDE/06-has-many.md +184 -0
- package/docs/GUIDE/07-polymorphic-relationships.md +191 -0
- package/docs/GUIDE/08-many-to-many.md +184 -0
- package/docs/GUIDE/09-pagination-and-sorting.md +149 -0
- package/docs/GUIDE/10-put-and-patch.md +174 -0
- package/docs/GUIDE/11-relationship-endpoints.md +174 -0
- package/docs/GUIDE/12-field-transformations.md +287 -0
- package/docs/GUIDE/13-hooks-and-lifecycle.md +479 -0
- package/docs/GUIDE/14-custom-resource-methods.md +125 -0
- package/docs/GUIDE/15-query-projections.md +266 -0
- package/docs/GUIDE/{GUIDE_X_Autofiltering.md → 16-autofiltering.md} +77 -15
- package/docs/GUIDE/{GUIDE_X_Row_Policies.md → 17-row-policies.md} +137 -44
- package/docs/GUIDE/18-labels.md +86 -0
- package/docs/GUIDE/19-managed-transactions.md +179 -0
- package/docs/GUIDE/20-transaction-outcomes.md +207 -0
- package/docs/GUIDE/21-schema-and-migrations.md +733 -0
- package/docs/GUIDE/22-version-field-migrations.md +208 -0
- package/docs/GUIDE/23-fastify.md +179 -0
- package/docs/GUIDE/24-cors.md +172 -0
- package/docs/GUIDE/{GUIDE_X_URL_Management.md → 25-url-management.md} +7 -1
- package/docs/GUIDE/26-file-uploads.md +278 -0
- package/docs/GUIDE/27-bulk-operations.md +277 -0
- package/docs/GUIDE/28-socketio.md +325 -0
- package/docs/GUIDE/29-writing-plugins.md +278 -0
- package/docs/GUIDE/30-backend-capabilities.md +103 -0
- package/docs/GUIDE/31-positioning.md +181 -0
- package/docs/GUIDE/{GUIDE_Y_Appendices.md → 32-date-and-time.md} +74 -30
- package/docs/GUIDE/33-migrating-to-v2.md +3838 -0
- package/docs/GUIDE/index.md +51 -59
- package/docs/QUICKSTART.md +120 -287
- package/docs/architecture.md +75 -0
- package/docs/contributing.md +178 -0
- package/index.d.ts +37 -0
- package/index.js +3 -22
- package/lib/enhanced-logger.js +91 -85
- package/lib/error-context.js +445 -5
- package/lib/error-formatter.js +219 -59
- package/lib/knex-transaction.js +70 -0
- package/lib/logger-types.d.ts +22 -0
- package/lib/missing-package.js +4 -0
- package/lib/rest-api-errors.js +103 -8
- package/lib/runtime/json-rest-api.js +172 -0
- package/lib/transaction-types.d.ts +59 -0
- package/package.json +97 -27
- package/plugins/core/bulk-operations-plugin.js +191 -184
- package/plugins/core/connectors/express-plugin.js +148 -219
- package/plugins/core/connectors/fastify-plugin.js +125 -82
- package/plugins/core/connectors/lib/busboy-detector.js +74 -156
- package/plugins/core/connectors/lib/connector-core.js +144 -20
- package/plugins/core/connectors/lib/formidable-detector.js +63 -120
- package/plugins/core/connectors/lib/http-validators.js +77 -0
- package/plugins/core/connectors/lib/multipart-helpers.js +18 -0
- package/plugins/core/connectors/lib/request-helpers.js +10 -1
- package/plugins/core/connectors/lib/transport-http-helpers.js +89 -8
- package/plugins/core/connectors/lib/transport-route-schemas.js +7 -4
- package/plugins/core/file-handling-plugin.js +87 -177
- package/plugins/core/lib/anyapi/anyapi-registry.js +586 -418
- package/plugins/core/lib/anyapi/canonical-link-store.js +507 -0
- package/plugins/core/lib/anyapi/canonical-relationship-reader.js +598 -0
- package/plugins/core/lib/anyapi/schema-utils.js +120 -56
- package/plugins/core/lib/anyapi/stored-definition.js +109 -0
- package/plugins/core/lib/anyapi/utils/descriptor-helpers.js +0 -64
- package/plugins/core/lib/dbIntrospection.js +228 -21
- package/plugins/core/lib/dbTablesOperations.js +409 -92
- package/plugins/core/lib/fractional-positioning.js +12 -48
- package/plugins/core/lib/querying/identifier-query.js +19 -0
- package/plugins/core/lib/querying/include-query-helpers.js +364 -0
- package/plugins/core/lib/querying/include-to-many.js +428 -0
- package/plugins/core/lib/querying/include-to-one.js +579 -0
- package/plugins/core/lib/querying/knex-cross-table-search.js +31 -307
- package/plugins/core/lib/querying/knex-json-api-transformers-querying.js +99 -401
- package/plugins/core/lib/querying/knex-pagination-helpers.js +48 -317
- package/plugins/core/lib/querying/knex-process-includes.js +5 -110
- package/plugins/core/lib/querying/knex-query-helpers-base.js +21 -56
- package/plugins/core/lib/querying/knex-query-helpers.js +298 -448
- package/plugins/core/lib/querying/knex-relationship-includes.js +120 -2138
- package/plugins/core/lib/querying/knex-window-queries.js +62 -253
- package/plugins/core/lib/querying/query-builder-utils.js +44 -4
- package/plugins/core/lib/querying/query-constraint.d.ts +12 -0
- package/plugins/core/lib/querying/query-constraint.js +27 -0
- package/plugins/core/lib/querying/query-field-sort-helpers.js +134 -37
- package/plugins/core/lib/querying/relationship-identifiers.js +208 -0
- package/plugins/core/lib/querying/sort-helpers.js +13 -1
- package/plugins/core/lib/querying/storage-adapter-utils.js +13 -5
- package/plugins/core/lib/querying/url-helpers.js +11 -4
- package/plugins/core/lib/querying-writing/compile-schemas.js +148 -249
- package/plugins/core/lib/querying-writing/connectors-query-parser.js +33 -118
- package/plugins/core/lib/querying-writing/database-capabilities.js +149 -223
- package/plugins/core/lib/querying-writing/database-value-normalizers.js +179 -194
- package/plugins/core/lib/querying-writing/default-data-helpers.js +15 -45
- package/plugins/core/lib/querying-writing/field-selection-types.d.ts +43 -0
- package/plugins/core/lib/querying-writing/field-utils.js +113 -92
- package/plugins/core/lib/querying-writing/knex-constants.d.ts +18 -0
- package/plugins/core/lib/querying-writing/knex-constants.js +2 -26
- package/plugins/core/lib/querying-writing/knex-field-helpers.js +100 -290
- package/plugins/core/lib/querying-writing/knex-json-api-transformers.js +31 -120
- package/plugins/core/lib/querying-writing/query-field-helpers.js +108 -32
- package/plugins/core/lib/querying-writing/query-field-types.d.ts +60 -0
- package/plugins/core/lib/querying-writing/relationship-contracts.js +33 -13
- package/plugins/core/lib/querying-writing/request-contracts.js +93 -38
- package/plugins/core/lib/querying-writing/resource-id-normalization.js +23 -5
- package/plugins/core/lib/querying-writing/response-options.js +35 -0
- package/plugins/core/lib/querying-writing/schema-helpers.js +203 -324
- package/plugins/core/lib/querying-writing/scope-validations.js +92 -290
- package/plugins/core/lib/querying-writing/simplified-helpers.js +113 -712
- package/plugins/core/lib/storage/canonical-data-helpers.js +683 -0
- package/plugins/core/lib/storage/canonical-storage-mapping.js +51 -20
- package/plugins/core/lib/storage/ordinary-data-helpers.js +710 -0
- package/plugins/core/lib/storage/storage-adapter.js +128 -62
- package/plugins/core/lib/storage/storage-mapping.js +78 -28
- package/plugins/core/lib/storage/storage-types.d.ts +502 -0
- package/plugins/core/lib/writing/many-to-many-manipulations.js +114 -284
- package/plugins/core/lib/writing/relationship-processor.js +94 -158
- package/plugins/core/lib/writing/resource-version.js +172 -0
- package/plugins/core/lib/writing/reverse-relationship-manipulations.js +110 -0
- package/plugins/core/rest-api-anyapi-knex-plugin.js +290 -2475
- package/plugins/core/rest-api-autofilter-plugin.js +17 -19
- package/plugins/core/rest-api-cors-plugin.js +52 -205
- package/plugins/core/rest-api-knex-plugin.js +45 -1077
- package/plugins/core/rest-api-label-plugin.js +25 -38
- package/plugins/core/rest-api-plugin-hooks/register-relationship-routes.js +5 -5
- package/plugins/core/rest-api-plugin-hooks/register-scope-routes.js +9 -6
- package/plugins/core/rest-api-plugin-hooks/turn-scope-init-into-vars.js +7 -26
- package/plugins/core/rest-api-plugin-hooks/validate-include-configurations.js +20 -26
- package/plugins/core/rest-api-plugin-methods/add-route.js +4 -6
- package/plugins/core/rest-api-plugin-methods/apply-query-filters.js +42 -13
- package/plugins/core/rest-api-plugin-methods/check-permissions.js +11 -5
- package/plugins/core/rest-api-plugin-methods/common.js +291 -395
- package/plugins/core/rest-api-plugin-methods/delete-relationship.js +49 -47
- package/plugins/core/rest-api-plugin-methods/delete.js +31 -25
- package/plugins/core/rest-api-plugin-methods/enrich-attributes.js +108 -148
- package/plugins/core/rest-api-plugin-methods/get-related.js +113 -305
- package/plugins/core/rest-api-plugin-methods/get-relationship.js +25 -18
- package/plugins/core/rest-api-plugin-methods/get.js +36 -68
- package/plugins/core/rest-api-plugin-methods/lifecycle-types.d.ts +225 -0
- package/plugins/core/rest-api-plugin-methods/patch-relationship.js +28 -25
- package/plugins/core/rest-api-plugin-methods/patch.js +46 -99
- package/plugins/core/rest-api-plugin-methods/post-relationship.js +49 -53
- package/plugins/core/rest-api-plugin-methods/post.js +51 -51
- package/plugins/core/rest-api-plugin-methods/put.js +69 -148
- package/plugins/core/rest-api-plugin-methods/query.js +37 -81
- package/plugins/core/rest-api-plugin-methods/release.js +2 -1
- package/plugins/core/rest-api-plugin.js +32 -46
- package/plugins/core/rest-api-positioning-plugin.js +183 -628
- package/plugins/core/rest-api-query-projections-plugin.js +19 -102
- package/plugins/core/rest-api-row-policy-plugin.js +3 -3
- package/plugins/core/socketio-plugin.js +364 -344
- package/plugins/storage/local-storage.js +40 -36
- package/types/anyapi.d.ts +30 -0
- package/types/autofilter.d.ts +57 -0
- package/types/bulk-methods.d.ts +63 -0
- package/types/errors.d.ts +118 -0
- package/types/fastify-options.d.ts +19 -0
- package/types/file-detectors.d.ts +33 -0
- package/types/file-storage.d.ts +48 -0
- package/types/hook-context.d.ts +180 -0
- package/types/plugin-options.d.ts +65 -0
- package/types/relationship-methods.d.ts +74 -0
- package/types/representations.d.ts +50 -0
- package/types/resource-methods.d.ts +95 -0
- package/types/resource-schema.d.ts +157 -0
- package/types/row-policy.d.ts +31 -0
- package/types/runtime.d.ts +132 -0
- package/types/transactions.d.ts +21 -0
- package/.claude/agents/library-guide-expert.md +0 -30
- package/.claude/agents/rest-api-internals-expert.md +0 -54
- package/.claude/settings.local.json +0 -70
- package/AGENTS.md +0 -34
- package/CLAUDE.md +0 -160
- package/CRUSH.md +0 -55
- package/JSKIT_BACKEND_MIGRATION_TRACKER.txt +0 -180
- package/SINGLE_VALIDATION_CONTRACT_TODO.txt +0 -172
- package/TODO.todo +0 -13
- package/connectors_ideas/fastify.js +0 -217
- package/connectors_ideas/koa-plugin.js +0 -112
- package/docs/.bundle/config +0 -2
- package/docs/COMPARISON.md +0 -433
- package/docs/GUIDE/GUIDE_1_Initial_Setup.md +0 -1057
- package/docs/GUIDE/GUIDE_2_1_The_Starting_Point.md +0 -88
- package/docs/GUIDE/GUIDE_2_2_Manipulating_And_Searching_Tables.md +0 -808
- package/docs/GUIDE/GUIDE_2_3_BelongsTo_Relationships.md +0 -491
- package/docs/GUIDE/GUIDE_2_4_HasMany_Records.md +0 -398
- package/docs/GUIDE/GUIDE_2_5_HasMany_Polymorphic.md +0 -898
- package/docs/GUIDE/GUIDE_2_6_Many_To_Many.md +0 -712
- package/docs/GUIDE/GUIDE_2_7_Pagination_And_Ordering.md +0 -1247
- package/docs/GUIDE/GUIDE_2_8_Effects_of_PUT_and_PATCH.md +0 -748
- package/docs/GUIDE/GUIDE_2_9_Relationships_Urls.md +0 -517
- package/docs/GUIDE/GUIDE_2_Data_And_Relations.md +0 -66
- package/docs/GUIDE/GUIDE_3_Field_Transformations.md +0 -1252
- package/docs/GUIDE/GUIDE_5_Non-Db_Resources.md +0 -1107
- package/docs/GUIDE/GUIDE_6_Positioning.md +0 -692
- package/docs/GUIDE/GUIDE_7_Hooks_Data_Management_And_Plugins.md +0 -1491
- package/docs/GUIDE/GUIDE_X_Bulk_Operations.md +0 -732
- package/docs/GUIDE/GUIDE_X_Cors.md +0 -1957
- package/docs/GUIDE/GUIDE_X_Fastify.md +0 -78
- package/docs/GUIDE/GUIDE_X_File_Uploads.md +0 -729
- package/docs/GUIDE/GUIDE_X_Knex_Schema_And_Migrations.md +0 -370
- package/docs/GUIDE/GUIDE_X_Query_Projections.md +0 -159
- package/docs/GUIDE/GUIDE_X_SocketIO.md +0 -1040
- package/docs/GUIDE/GUIDE_X_Writing_Plugins.md +0 -201
- package/docs/Gemfile +0 -6
- package/docs/Gemfile.lock +0 -308
- package/docs/ONBOARDING.md +0 -315
- package/docs/_config.yml +0 -36
- package/docs/_layouts/default.html +0 -123
- package/docs/apple-touch-icon.svg +0 -4
- package/docs/assets/css/style.scss +0 -392
- package/docs/favicon.svg +0 -4
- package/docs/index.md +0 -164
- package/docs/mini-guides/file-uploads-complete.md +0 -161
- package/eslint.config.js +0 -13
- package/examples/url-override-example.js +0 -137
- package/fixing_plan.md +0 -112
- package/plugins/core/lib/anyapi/query/anyapi-query-adapter.js +0 -657
- package/plugins/core/lib/querying-writing/return-record-settings.js +0 -47
- package/plugins/core/lib/writing/knex-json-api-transformers-writing.js +0 -110
- package/plugins/storage/s3-storage.js +0 -93
- package/quickTest.js +0 -136
- package/scripts/debug-anyapi-review-filter.mjs +0 -63
- package/scripts/inspect-anyapi-descriptor-country.mjs +0 -19
- package/scripts/inspect-anyapi-descriptor.mjs +0 -19
- package/scripts/run-docs.js +0 -133
- package/test-debug.js +0 -59
- package/tests/README.md +0 -202
- package/tests/TEST_TEMPLATE.test.js +0 -178
- package/tests/anyapi-basic.test.js +0 -105
- package/tests/anyapi-cursor-pagination.test.js +0 -148
- package/tests/anyapi-custom-idproperty.test.js +0 -163
- package/tests/autofilter.test.js +0 -463
- package/tests/bulk-operations.test.js +0 -657
- package/tests/computed-fields.test.js +0 -586
- package/tests/cors.test.js +0 -834
- package/tests/curl.test.js +0 -467
- package/tests/custom-id-mysql-returning-fallback.test.js +0 -128
- package/tests/custom-id-simplified-post.test.js +0 -75
- package/tests/custom-idproperty-relationships.test.js +0 -932
- package/tests/db-introspection.test.js +0 -521
- package/tests/db-table-operations.test.js +0 -798
- package/tests/fastify-plugin.test.js +0 -184
- package/tests/field-getters.test.js +0 -370
- package/tests/field-setters.test.js +0 -306
- package/tests/file-handling.test.js +0 -155
- package/tests/fixtures/api-configs.js +0 -2566
- package/tests/full-jsonapi-belongsto-linkage.test.js +0 -150
- package/tests/helpers/fake-fastify.js +0 -99
- package/tests/helpers/storage-mode.js +0 -65
- package/tests/helpers/test-utils.js +0 -203
- package/tests/http-connectors-parity.test.js +0 -395
- package/tests/id-normalization.test.js +0 -444
- package/tests/include-depth-validation.test.js +0 -331
- package/tests/includes.test.js +0 -427
- package/tests/local-storage.test.js +0 -96
- package/tests/logical-id-write-validation.test.js +0 -151
- package/tests/nested-includes.test.js +0 -617
- package/tests/pagination-boundaries.test.js +0 -154
- package/tests/pagination-contract.test.js +0 -122
- package/tests/pagination-cursor-multifield.test.js +0 -641
- package/tests/pagination-enhanced.test.js +0 -563
- package/tests/polymorphic-relationships.test.js +0 -347
- package/tests/polymorphic-simple.test.js +0 -148
- package/tests/positioning-advanced.test.js +0 -692
- package/tests/positioning-extended.test.js +0 -915
- package/tests/positioning-multifilter.test.js +0 -699
- package/tests/positioning.test.js +0 -560
- package/tests/post-commit-hooks.test.js +0 -120
- package/tests/projected-fields.test.js +0 -319
- package/tests/public-exports.test.js +0 -15
- package/tests/put-create-parity.test.js +0 -216
- package/tests/queries.test.js +0 -521
- package/tests/query-limits.test.js +0 -584
- package/tests/relationship-endpoints.test.js +0 -620
- package/tests/relationship-include-storage-adapter.test.js +0 -56
- package/tests/relationships.test.js +0 -681
- package/tests/rest-api.test.js +0 -337
- package/tests/return-record-settings.test.js +0 -184
- package/tests/row-policy.test.js +0 -489
- package/tests/s3-storage.test.js +0 -38
- package/tests/searchschema-knex-behavior.test.js +0 -187
- package/tests/searchschema-merge.test.js +0 -151
- package/tests/socketio.test.js +0 -860
- package/tests/storage-mapping.test.js +0 -362
- package/tests/temporal-boundaries.test.js +0 -208
- package/tests/test-development-guide.md +0 -370
- package/tests/virtual-fields.test.js +0 -404
|
@@ -1,6 +1,22 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
3
|
-
|
|
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
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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
|
-
|
|
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
|
|
162
|
-
|
|
163
|
-
|
|
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
|
|
176
|
-
if (!
|
|
177
|
-
throw Object.assign(new Error(`
|
|
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
|
-
|
|
181
|
-
|
|
182
|
-
|
|
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
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
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
|
-
|
|
200
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
227
|
-
|
|
228
|
-
|
|
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
|
-
|
|
238
|
-
|
|
239
|
-
|
|
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
|
-
|
|
243
|
-
|
|
244
|
-
|
|
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
|
-
|
|
250
|
-
|
|
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
|
-
|
|
280
|
-
|
|
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
|
|
318
|
-
|
|
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
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
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
|
-
|
|
370
|
-
|
|
371
|
-
|
|
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
|
|
387
|
-
|
|
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
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
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
|
-
|
|
412
|
-
|
|
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
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
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
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
'
|
|
427
|
-
|
|
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
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
]
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
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 =
|
|
426
|
+
socket.data.subscriptions = []
|
|
448
427
|
next()
|
|
449
428
|
} catch (error) {
|
|
450
|
-
|
|
451
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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?.
|
|
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.
|
|
508
|
-
const hasOther =
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
556
|
+
logSocketInfo(`Socket disconnected: ${socket.id}`, {
|
|
557
|
+
...getOperationDiagnosticContext({}, { phase: 'socketDisconnected', method: 'socketDisconnect', backend: 'socketio' }),
|
|
563
558
|
reason,
|
|
564
|
-
subscriptionCount: socket.data.subscriptions?.
|
|
559
|
+
subscriptionCount: socket.data.subscriptions?.length || 0
|
|
565
560
|
})
|
|
566
561
|
})
|
|
567
562
|
})
|
|
568
563
|
|
|
569
|
-
|
|
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('
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
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
|
-
|
|
586
|
-
|
|
587
|
-
|
|
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
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
pendingBroadcasts.get(
|
|
595
|
-
|
|
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
|
-
|
|
607
|
-
|
|
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
|
})
|