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