@objectql/server 1.7.2 → 1.8.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.
@@ -0,0 +1,523 @@
1
+ import { IObjectQL, ObjectConfig, FieldConfig } from '@objectql/types';
2
+ import { ObjectQLServer } from '../server';
3
+ import { ErrorCode } from '../types';
4
+ import { IncomingMessage, ServerResponse } from 'http';
5
+ import { graphql, GraphQLSchema, GraphQLObjectType, GraphQLString, GraphQLInt, GraphQLFloat, GraphQLBoolean, GraphQLList, GraphQLNonNull, GraphQLInputObjectType, GraphQLFieldConfigMap, GraphQLOutputType, GraphQLInputType } from 'graphql';
6
+
7
+ /**
8
+ * Normalize ObjectQL response to use 'id' instead of '_id'
9
+ */
10
+ function normalizeId(data: unknown): unknown {
11
+ if (!data) return data;
12
+
13
+ if (Array.isArray(data)) {
14
+ return data.map(item => normalizeId(item));
15
+ }
16
+
17
+ if (typeof data === 'object') {
18
+ const normalized = { ...data as Record<string, unknown> };
19
+
20
+ // Map _id to id if present
21
+ if ('_id' in normalized) {
22
+ normalized.id = normalized._id;
23
+ delete normalized._id;
24
+ }
25
+
26
+ // Remove '@type' field as it's not needed in GraphQL
27
+ delete normalized['@type'];
28
+
29
+ return normalized;
30
+ }
31
+
32
+ return data;
33
+ }
34
+
35
+ /**
36
+ * Map ObjectQL field types to GraphQL types
37
+ */
38
+ function mapFieldTypeToGraphQL(field: FieldConfig, isInput: boolean = false): GraphQLOutputType | GraphQLInputType {
39
+ const type = field.type;
40
+
41
+ switch (type) {
42
+ case 'text':
43
+ case 'textarea':
44
+ case 'markdown':
45
+ case 'html':
46
+ case 'email':
47
+ case 'url':
48
+ case 'phone':
49
+ case 'password':
50
+ return GraphQLString;
51
+ case 'number':
52
+ case 'currency':
53
+ case 'percent':
54
+ return GraphQLFloat;
55
+ case 'auto_number':
56
+ return GraphQLInt;
57
+ case 'boolean':
58
+ return GraphQLBoolean;
59
+ case 'date':
60
+ case 'datetime':
61
+ case 'time':
62
+ return GraphQLString; // ISO 8601 string format
63
+ case 'select':
64
+ // For select fields, we could create an enum type, but for simplicity use String
65
+ return GraphQLString;
66
+ case 'lookup':
67
+ case 'master_detail':
68
+ // For relationships, return ID reference
69
+ return GraphQLString;
70
+ case 'file':
71
+ case 'image':
72
+ // File fields return metadata object (simplified as String for now)
73
+ return GraphQLString;
74
+ case 'object':
75
+ case 'formula':
76
+ case 'summary':
77
+ case 'location':
78
+ case 'vector':
79
+ case 'grid':
80
+ // Return as JSON string
81
+ return GraphQLString;
82
+ default:
83
+ return GraphQLString;
84
+ }
85
+ }
86
+
87
+ /**
88
+ * Sanitize field/object names to be valid GraphQL identifiers
89
+ * GraphQL names must match /^[_a-zA-Z][_a-zA-Z0-9]*$/
90
+ */
91
+ function sanitizeGraphQLName(name: string): string {
92
+ // Replace invalid characters with underscores
93
+ let sanitized = name.replace(/[^_a-zA-Z0-9]/g, '_');
94
+
95
+ // Ensure it starts with a letter or underscore
96
+ if (!/^[_a-zA-Z]/.test(sanitized)) {
97
+ sanitized = '_' + sanitized;
98
+ }
99
+
100
+ return sanitized;
101
+ }
102
+
103
+ /**
104
+ * Generate GraphQL schema from ObjectQL metadata
105
+ */
106
+ export function generateGraphQLSchema(app: IObjectQL): GraphQLSchema {
107
+ const objects = app.metadata.list<ObjectConfig>('object');
108
+
109
+ // Validate that there are objects to generate schema from
110
+ if (!objects || objects.length === 0) {
111
+ // Create a minimal schema with a dummy query to avoid GraphQL error
112
+ return new GraphQLSchema({
113
+ query: new GraphQLObjectType({
114
+ name: 'Query',
115
+ fields: {
116
+ _schema: {
117
+ type: GraphQLString,
118
+ description: 'Schema introspection placeholder',
119
+ resolve: () => 'No objects registered in ObjectQL metadata'
120
+ }
121
+ }
122
+ })
123
+ });
124
+ }
125
+
126
+ const typeMap: Record<string, GraphQLObjectType> = {};
127
+ const inputTypeMap: Record<string, GraphQLInputObjectType> = {};
128
+ const deleteResultTypeMap: Record<string, GraphQLObjectType> = {};
129
+
130
+ // Create a shared ObjectQL server instance to reuse across resolvers
131
+ // This is safe because ObjectQLServer is stateless - it only holds a reference to the app
132
+ // and creates fresh contexts for each request via handle()
133
+ const server = new ObjectQLServer(app);
134
+
135
+ // First pass: Create all object types
136
+ for (const config of objects) {
137
+ const objectName = config.name;
138
+
139
+ // Skip if no name or fields defined
140
+ if (!objectName || !config.fields || Object.keys(config.fields).length === 0) {
141
+ continue;
142
+ }
143
+
144
+ const sanitizedTypeName = sanitizeGraphQLName(objectName.charAt(0).toUpperCase() + objectName.slice(1));
145
+
146
+ // Create output type
147
+ const fields: GraphQLFieldConfigMap<any, any> = {
148
+ id: { type: new GraphQLNonNull(GraphQLString) }
149
+ };
150
+
151
+ for (const [fieldName, fieldConfig] of Object.entries(config.fields)) {
152
+ const sanitizedFieldName = sanitizeGraphQLName(fieldName);
153
+ const gqlType = mapFieldTypeToGraphQL(fieldConfig, false) as GraphQLOutputType;
154
+ fields[sanitizedFieldName] = {
155
+ type: fieldConfig.required ? new GraphQLNonNull(gqlType) : gqlType,
156
+ description: fieldConfig.label || fieldName
157
+ };
158
+ }
159
+
160
+ typeMap[objectName] = new GraphQLObjectType({
161
+ name: sanitizedTypeName,
162
+ description: config.label || objectName,
163
+ fields
164
+ });
165
+
166
+ // Create input type for mutations
167
+ const inputFields: Record<string, any> = {};
168
+
169
+ for (const [fieldName, fieldConfig] of Object.entries(config.fields)) {
170
+ const sanitizedFieldName = sanitizeGraphQLName(fieldName);
171
+ const gqlType = mapFieldTypeToGraphQL(fieldConfig, true) as GraphQLInputType;
172
+ inputFields[sanitizedFieldName] = {
173
+ type: gqlType,
174
+ description: fieldConfig.label || fieldName
175
+ };
176
+ }
177
+
178
+ inputTypeMap[objectName] = new GraphQLInputObjectType({
179
+ name: sanitizedTypeName + 'Input',
180
+ description: `Input type for ${config.label || objectName}`,
181
+ fields: inputFields
182
+ });
183
+
184
+ // Create delete result type (shared across all delete mutations for this object)
185
+ deleteResultTypeMap[objectName] = new GraphQLObjectType({
186
+ name: 'Delete' + sanitizedTypeName + 'Result',
187
+ fields: {
188
+ id: { type: new GraphQLNonNull(GraphQLString) },
189
+ deleted: { type: new GraphQLNonNull(GraphQLBoolean) }
190
+ }
191
+ });
192
+ }
193
+
194
+ // Build query root
195
+ const queryFields: GraphQLFieldConfigMap<any, any> = {};
196
+
197
+ for (const config of objects) {
198
+ const objectName = config.name;
199
+
200
+ if (!objectName || !typeMap[objectName]) continue;
201
+
202
+ // Query single record by ID
203
+ queryFields[objectName] = {
204
+ type: typeMap[objectName],
205
+ args: {
206
+ id: { type: new GraphQLNonNull(GraphQLString) }
207
+ },
208
+ resolve: async (_, args) => {
209
+ const result = await server.handle({
210
+ op: 'findOne',
211
+ object: objectName,
212
+ args: args.id
213
+ });
214
+
215
+ if (result.error) {
216
+ throw new Error(result.error.message);
217
+ }
218
+
219
+ return normalizeId(result);
220
+ }
221
+ };
222
+
223
+ // Query list of records
224
+ // Using 'List' suffix to avoid naming conflicts and handle irregular plurals
225
+ queryFields[objectName + 'List'] = {
226
+ type: new GraphQLList(typeMap[objectName]),
227
+ args: {
228
+ limit: { type: GraphQLInt },
229
+ skip: { type: GraphQLInt },
230
+ filters: { type: GraphQLString }, // JSON string
231
+ fields: { type: new GraphQLList(GraphQLString) },
232
+ sort: { type: GraphQLString } // JSON string
233
+ },
234
+ resolve: async (_, args) => {
235
+ const queryArgs: any = {};
236
+ if (args.limit) queryArgs.limit = args.limit;
237
+ if (args.skip) queryArgs.skip = args.skip;
238
+ if (args.fields) queryArgs.fields = args.fields;
239
+ if (args.filters) {
240
+ try {
241
+ queryArgs.filters = JSON.parse(args.filters);
242
+ } catch (e) {
243
+ throw new Error('Invalid filters JSON');
244
+ }
245
+ }
246
+ if (args.sort) {
247
+ try {
248
+ queryArgs.sort = JSON.parse(args.sort);
249
+ } catch (e) {
250
+ throw new Error('Invalid sort JSON');
251
+ }
252
+ }
253
+
254
+ const result = await server.handle({
255
+ op: 'find',
256
+ object: objectName,
257
+ args: queryArgs
258
+ });
259
+
260
+ if (result.error) {
261
+ throw new Error(result.error.message);
262
+ }
263
+
264
+ return normalizeId(result.items || []);
265
+ }
266
+ };
267
+ }
268
+
269
+ const queryType = new GraphQLObjectType({
270
+ name: 'Query',
271
+ fields: queryFields
272
+ });
273
+
274
+ // Build mutation root
275
+ const mutationFields: GraphQLFieldConfigMap<any, any> = {};
276
+
277
+ for (const config of objects) {
278
+ const objectName = config.name;
279
+
280
+ if (!objectName || !typeMap[objectName] || !inputTypeMap[objectName]) continue;
281
+
282
+ const capitalizedName = sanitizeGraphQLName(objectName.charAt(0).toUpperCase() + objectName.slice(1));
283
+
284
+ // Create mutation
285
+ mutationFields['create' + capitalizedName] = {
286
+ type: typeMap[objectName],
287
+ args: {
288
+ input: { type: new GraphQLNonNull(inputTypeMap[objectName]) }
289
+ },
290
+ resolve: async (_, args) => {
291
+ const result = await server.handle({
292
+ op: 'create',
293
+ object: objectName,
294
+ args: args.input
295
+ });
296
+
297
+ if (result.error) {
298
+ throw new Error(result.error.message);
299
+ }
300
+
301
+ return normalizeId(result);
302
+ }
303
+ };
304
+
305
+ // Update mutation
306
+ mutationFields['update' + capitalizedName] = {
307
+ type: typeMap[objectName],
308
+ args: {
309
+ id: { type: new GraphQLNonNull(GraphQLString) },
310
+ input: { type: new GraphQLNonNull(inputTypeMap[objectName]) }
311
+ },
312
+ resolve: async (_, args) => {
313
+ const result = await server.handle({
314
+ op: 'update',
315
+ object: objectName,
316
+ args: {
317
+ id: args.id,
318
+ data: args.input
319
+ }
320
+ });
321
+
322
+ if (result.error) {
323
+ throw new Error(result.error.message);
324
+ }
325
+
326
+ return normalizeId(result);
327
+ }
328
+ };
329
+
330
+ // Delete mutation - use shared delete result type
331
+ mutationFields['delete' + capitalizedName] = {
332
+ type: deleteResultTypeMap[objectName],
333
+ args: {
334
+ id: { type: new GraphQLNonNull(GraphQLString) }
335
+ },
336
+ resolve: async (_, args) => {
337
+ const result = await server.handle({
338
+ op: 'delete',
339
+ object: objectName,
340
+ args: { id: args.id }
341
+ });
342
+
343
+ if (result.error) {
344
+ throw new Error(result.error.message);
345
+ }
346
+
347
+ return result;
348
+ }
349
+ };
350
+ }
351
+
352
+ const mutationType = new GraphQLObjectType({
353
+ name: 'Mutation',
354
+ fields: mutationFields
355
+ });
356
+
357
+ return new GraphQLSchema({
358
+ query: queryType,
359
+ mutation: mutationType
360
+ });
361
+ }
362
+
363
+ /**
364
+ * Parse GraphQL request body
365
+ */
366
+ function readBody(req: IncomingMessage): Promise<any> {
367
+ return new Promise((resolve, reject) => {
368
+ let body = '';
369
+ req.on('data', chunk => body += chunk.toString());
370
+ req.on('end', () => {
371
+ if (!body) return resolve({});
372
+ try {
373
+ resolve(JSON.parse(body));
374
+ } catch (e) {
375
+ reject(new Error('Invalid JSON'));
376
+ }
377
+ });
378
+ req.on('error', reject);
379
+ });
380
+ }
381
+
382
+ /**
383
+ * Send JSON response
384
+ */
385
+ function sendJSON(res: ServerResponse, statusCode: number, data: any) {
386
+ res.setHeader('Content-Type', 'application/json');
387
+ res.statusCode = statusCode;
388
+ res.end(JSON.stringify(data));
389
+ }
390
+
391
+ /**
392
+ * Creates a GraphQL HTTP request handler for ObjectQL
393
+ *
394
+ * Endpoints:
395
+ * - POST /api/graphql - GraphQL queries and mutations
396
+ * - GET /api/graphql - GraphQL queries via URL parameters
397
+ */
398
+ export function createGraphQLHandler(app: IObjectQL) {
399
+ // Generate schema once - Note: Schema is static after handler creation.
400
+ // If metadata changes at runtime, create a new handler or regenerate the schema.
401
+ const schema = generateGraphQLSchema(app);
402
+
403
+ return async (req: IncomingMessage & { body?: any }, res: ServerResponse) => {
404
+ try {
405
+ // CORS headers
406
+ const requestOrigin = req.headers.origin;
407
+ const configuredOrigin = process.env.OBJECTQL_CORS_ORIGIN;
408
+ const isProduction = process.env.NODE_ENV === 'production';
409
+
410
+ if (!isProduction) {
411
+ res.setHeader('Access-Control-Allow-Origin', configuredOrigin || '*');
412
+ } else if (configuredOrigin && (!requestOrigin || requestOrigin === configuredOrigin)) {
413
+ res.setHeader('Access-Control-Allow-Origin', configuredOrigin);
414
+ }
415
+ res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
416
+ res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');
417
+
418
+ if (req.method === 'OPTIONS') {
419
+ res.statusCode = 200;
420
+ res.end();
421
+ return;
422
+ }
423
+
424
+ const url = req.url || '';
425
+ const method = req.method || 'POST';
426
+
427
+ if (method !== 'GET' && method !== 'POST') {
428
+ sendJSON(res, 405, {
429
+ errors: [{
430
+ message: 'Method not allowed. Use GET or POST.'
431
+ }]
432
+ });
433
+ return;
434
+ }
435
+
436
+ let query: string = '';
437
+ let variables: any = null;
438
+ let operationName: string | null = null;
439
+
440
+ if (method === 'GET') {
441
+ // Parse query string for GET requests
442
+ const urlObj = new URL(url, `http://${req.headers.host || 'localhost'}`);
443
+ query = urlObj.searchParams.get('query') || '';
444
+ const varsParam = urlObj.searchParams.get('variables');
445
+ if (varsParam) {
446
+ try {
447
+ variables = JSON.parse(varsParam);
448
+ } catch (e) {
449
+ sendJSON(res, 400, {
450
+ errors: [{
451
+ message: 'Invalid variables JSON'
452
+ }]
453
+ });
454
+ return;
455
+ }
456
+ }
457
+ operationName = urlObj.searchParams.get('operationName');
458
+ } else {
459
+ // Parse body for POST requests
460
+ const body = req.body || await readBody(req);
461
+ query = body.query || '';
462
+ variables = body.variables || null;
463
+ operationName = body.operationName || null;
464
+ }
465
+
466
+ if (!query) {
467
+ sendJSON(res, 400, {
468
+ errors: [{
469
+ message: 'Must provide query string'
470
+ }]
471
+ });
472
+ return;
473
+ }
474
+
475
+ // Execute GraphQL query
476
+ const result = await graphql({
477
+ schema,
478
+ source: query,
479
+ variableValues: variables,
480
+ operationName,
481
+ contextValue: { app }
482
+ });
483
+
484
+ sendJSON(res, 200, result);
485
+
486
+ } catch (e: any) {
487
+ console.error('[GraphQL Handler] Error:', e);
488
+
489
+ const errorResponse: {
490
+ errors: Array<{
491
+ message: string;
492
+ extensions: {
493
+ code: ErrorCode;
494
+ debug?: {
495
+ message?: string;
496
+ stack?: string;
497
+ };
498
+ };
499
+ }>;
500
+ } = {
501
+ errors: [{
502
+ message: 'Internal server error',
503
+ extensions: {
504
+ code: ErrorCode.INTERNAL_ERROR
505
+ }
506
+ }]
507
+ };
508
+
509
+ // In non-production environments, include additional error details to aid debugging
510
+ if (typeof process !== 'undefined' &&
511
+ process.env &&
512
+ process.env.NODE_ENV !== 'production') {
513
+ const firstError = errorResponse.errors[0];
514
+ firstError.extensions.debug = {
515
+ message: e && typeof e.message === 'string' ? e.message : undefined,
516
+ stack: e && typeof e.stack === 'string' ? e.stack : undefined
517
+ };
518
+ }
519
+
520
+ sendJSON(res, 500, errorResponse);
521
+ }
522
+ };
523
+ }
@@ -64,11 +64,13 @@ function sendJSON(res: ServerResponse, statusCode: number, data: any) {
64
64
  * Creates a REST-style HTTP request handler for ObjectQL
65
65
  *
66
66
  * Endpoints:
67
- * - GET /api/data/:object - List records
68
- * - GET /api/data/:object/:id - Get single record
69
- * - POST /api/data/:object - Create record
70
- * - PUT /api/data/:object/:id - Update record
71
- * - DELETE /api/data/:object/:id - Delete record
67
+ * - GET /api/data/:object - List records
68
+ * - GET /api/data/:object/:id - Get single record
69
+ * - POST /api/data/:object - Create record (or create many if array)
70
+ * - POST /api/data/:object/bulk-update - Update many records
71
+ * - POST /api/data/:object/bulk-delete - Delete many records
72
+ * - PUT /api/data/:object/:id - Update record
73
+ * - DELETE /api/data/:object/:id - Delete record
72
74
  */
73
75
  export function createRESTHandler(app: IObjectQL) {
74
76
  const server = new ObjectQLServer(app);
@@ -99,7 +101,7 @@ export function createRESTHandler(app: IObjectQL) {
99
101
  const url = req.url || '';
100
102
  const method = req.method || 'GET';
101
103
 
102
- // Parse URL: /api/data/:object or /api/data/:object/:id
104
+ // Parse URL: /api/data/:object or /api/data/:object/:id or /api/data/:object/bulk-*
103
105
  const match = url.match(/^\/api\/data\/([^\/\?]+)(?:\/([^\/\?]+))?(\?.*)?$/);
104
106
 
105
107
  if (!match) {
@@ -143,7 +145,7 @@ export function createRESTHandler(app: IObjectQL) {
143
145
  : [[queryParams.sort, 'asc']];
144
146
  }
145
147
  if (queryParams.top || queryParams.limit) {
146
- args.top = queryParams.top || queryParams.limit;
148
+ args.limit = queryParams.top || queryParams.limit;
147
149
  }
148
150
  if (queryParams.skip || queryParams.offset) {
149
151
  args.skip = queryParams.skip || queryParams.offset;
@@ -161,13 +163,43 @@ export function createRESTHandler(app: IObjectQL) {
161
163
  break;
162
164
 
163
165
  case 'POST':
164
- // POST /api/data/:object - Create record
165
166
  const createBody = req.body || await readBody(req);
166
- qlRequest = {
167
- op: 'create',
168
- object: objectName,
169
- args: createBody
170
- };
167
+
168
+ // Check for bulk operations
169
+ if (id === 'bulk-update') {
170
+ // POST /api/data/:object/bulk-update - Update many records
171
+ qlRequest = {
172
+ op: 'updateMany',
173
+ object: objectName,
174
+ args: {
175
+ filters: createBody.filters,
176
+ data: createBody.data
177
+ }
178
+ };
179
+ } else if (id === 'bulk-delete') {
180
+ // POST /api/data/:object/bulk-delete - Delete many records
181
+ qlRequest = {
182
+ op: 'deleteMany',
183
+ object: objectName,
184
+ args: {
185
+ filters: createBody.filters || {}
186
+ }
187
+ };
188
+ } else if (Array.isArray(createBody)) {
189
+ // POST /api/data/:object with array - Create many records
190
+ qlRequest = {
191
+ op: 'createMany',
192
+ object: objectName,
193
+ args: createBody
194
+ };
195
+ } else {
196
+ // POST /api/data/:object - Create single record
197
+ qlRequest = {
198
+ op: 'create',
199
+ object: objectName,
200
+ args: createBody
201
+ };
202
+ }
171
203
  break;
172
204
 
173
205
  case 'PUT':
@@ -252,8 +284,10 @@ export function createRESTHandler(app: IObjectQL) {
252
284
  default:
253
285
  statusCode = 500;
254
286
  }
255
- } else if (method === 'POST') {
256
- statusCode = 201; // Created
287
+ } else if (method === 'POST' && qlRequest.op === 'create') {
288
+ statusCode = 201; // Created - only for single create
289
+ } else if (method === 'POST' && qlRequest.op === 'createMany') {
290
+ statusCode = 201; // Created - for bulk create
257
291
  }
258
292
 
259
293
  sendJSON(res, statusCode, result);
package/src/index.ts CHANGED
@@ -8,3 +8,5 @@ export * from './studio';
8
8
  export * from './adapters/node';
9
9
  // Export REST adapter
10
10
  export * from './adapters/rest';
11
+ // Export GraphQL adapter
12
+ export * from './adapters/graphql';
package/src/metadata.ts CHANGED
@@ -73,14 +73,14 @@ export function createMetadataHandler(app: IObjectQL) {
73
73
  description: obj.description,
74
74
  fields: obj.fields || {}
75
75
  }));
76
- // Return both keys for compatibility during migration
77
- return sendJson({ objects, object: objects });
76
+ // Return standardized format with items
77
+ return sendJson({ items: objects });
78
78
  }
79
79
 
80
80
  const entries = app.metadata.list(type);
81
- // Return simple list
81
+ // Return standardized list format
82
82
  return sendJson({
83
- [type]: entries
83
+ items: entries
84
84
  });
85
85
  }
86
86
 
@@ -152,7 +152,7 @@ export function createMetadataHandler(app: IObjectQL) {
152
152
  // 4. Object Sub-resources (Fields, Actions)
153
153
  // ---------------------------------------------------------
154
154
 
155
- // GET /api/metadata/objects/:name/fields/:field
155
+ // GET /api/metadata/object/:name/fields/:field
156
156
  // Legacy path support.
157
157
  const fieldMatch = url.match(/^\/api\/metadata\/(?:objects|object)\/([^\/]+)\/fields\/([^\/\?]+)$/);
158
158
  if (method === 'GET' && fieldMatch) {
@@ -180,7 +180,7 @@ export function createMetadataHandler(app: IObjectQL) {
180
180
  });
181
181
  }
182
182
 
183
- // GET /api/metadata/objects/:name/actions
183
+ // GET /api/metadata/object/:name/actions
184
184
  const actionsMatch = url.match(/^\/api\/metadata\/(?:objects|object)\/([^\/]+)\/actions$/);
185
185
  if (method === 'GET' && actionsMatch) {
186
186
  const [, objectName] = actionsMatch;
@@ -201,7 +201,7 @@ export function createMetadataHandler(app: IObjectQL) {
201
201
  };
202
202
  });
203
203
 
204
- return sendJson({ actions: formattedActions });
204
+ return sendJson({ items: formattedActions });
205
205
  }
206
206
 
207
207
  // Not found