@airoom/nextmin-node 0.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 +49 -0
- package/README.md +178 -0
- package/dist/api/apiRouter.d.ts +65 -0
- package/dist/api/apiRouter.js +1548 -0
- package/dist/database/DatabaseAdapter.d.ts +14 -0
- package/dist/database/DatabaseAdapter.js +2 -0
- package/dist/database/InMemoryAdapter.d.ts +15 -0
- package/dist/database/InMemoryAdapter.js +71 -0
- package/dist/database/MongoAdapter.d.ts +52 -0
- package/dist/database/MongoAdapter.js +409 -0
- package/dist/files/FileStorageAdapter.d.ts +35 -0
- package/dist/files/FileStorageAdapter.js +2 -0
- package/dist/files/S3FileStorageAdapter.d.ts +30 -0
- package/dist/files/S3FileStorageAdapter.js +84 -0
- package/dist/files/filename.d.ts +5 -0
- package/dist/files/filename.js +40 -0
- package/dist/index.d.ts +13 -0
- package/dist/index.js +87 -0
- package/dist/models/BaseModel.d.ts +44 -0
- package/dist/models/BaseModel.js +31 -0
- package/dist/policy/authorize.d.ts +25 -0
- package/dist/policy/authorize.js +305 -0
- package/dist/policy/conditions.d.ts +14 -0
- package/dist/policy/conditions.js +30 -0
- package/dist/policy/types.d.ts +53 -0
- package/dist/policy/types.js +2 -0
- package/dist/policy/utils.d.ts +9 -0
- package/dist/policy/utils.js +118 -0
- package/dist/schemas/Roles.json +64 -0
- package/dist/schemas/Settings.json +62 -0
- package/dist/schemas/Users.json +123 -0
- package/dist/services/SchemaService.d.ts +10 -0
- package/dist/services/SchemaService.js +46 -0
- package/dist/utils/DefaultDataInitializer.d.ts +21 -0
- package/dist/utils/DefaultDataInitializer.js +269 -0
- package/dist/utils/Logger.d.ts +12 -0
- package/dist/utils/Logger.js +79 -0
- package/dist/utils/SchemaLoader.d.ts +51 -0
- package/dist/utils/SchemaLoader.js +323 -0
- package/dist/utils/apiKey.d.ts +5 -0
- package/dist/utils/apiKey.js +14 -0
- package/dist/utils/fieldCodecs.d.ts +13 -0
- package/dist/utils/fieldCodecs.js +133 -0
- package/package.json +45 -0
- package/tsconfig.json +8 -0
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { ReadOptions, Schema } from '../models/BaseModel';
|
|
2
|
+
export type IndexDir = 1 | -1;
|
|
3
|
+
export type FieldIndexSpec = Record<string, IndexDir>;
|
|
4
|
+
export interface DatabaseAdapter {
|
|
5
|
+
connect(): Promise<void>;
|
|
6
|
+
disconnect(): Promise<void>;
|
|
7
|
+
registerSchemas(schemas: Record<string, Schema>): Promise<void> | void;
|
|
8
|
+
create(collection: string, data: any, schemaDefinition: Schema, includePrivateFields?: boolean): Promise<any>;
|
|
9
|
+
read(collection: string, query: any, limit?: number, skip?: number, schemaDefinition?: Schema, includePrivateFields?: boolean, options?: ReadOptions): Promise<any[]>;
|
|
10
|
+
update(collection: string, id: string, data: any, schemaDefinition: Schema, includePrivateFields?: boolean): Promise<any>;
|
|
11
|
+
delete(collection: string, id: string, schemaDefinition: Schema, includePrivateFields?: boolean): Promise<any>;
|
|
12
|
+
count(collection: string, query: any, schemaDefinition: Schema, includePrivateFields?: boolean): Promise<number>;
|
|
13
|
+
syncIndexes?(modelName: string, desired: FieldIndexSpec): Promise<void>;
|
|
14
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { DatabaseAdapter, FieldIndexSpec } from './DatabaseAdapter';
|
|
2
|
+
import { ReadOptions, Schema } from '../models/BaseModel';
|
|
3
|
+
export declare class InMemoryAdapter implements DatabaseAdapter {
|
|
4
|
+
private data;
|
|
5
|
+
connect(): Promise<void>;
|
|
6
|
+
disconnect(): Promise<void>;
|
|
7
|
+
registerSchemas(schemas: Record<string, Schema>): void;
|
|
8
|
+
create(collection: string, data: any, schemaDefinition: Schema): Promise<any>;
|
|
9
|
+
read(collection: string, query: any, limit?: number, skip?: number, schemaDefinition?: Schema, _includePrivateFields?: boolean, _options?: ReadOptions): Promise<any[]>;
|
|
10
|
+
update(collection: string, id: string, data: any, schemaDefinition: Schema): Promise<any>;
|
|
11
|
+
delete(collection: string, id: string, schemaDefinition: Schema): Promise<any>;
|
|
12
|
+
count(collection: string, query: any, schemaDefinition: Schema): Promise<number>;
|
|
13
|
+
syncIndexes(_modelName: string, _desired: FieldIndexSpec): Promise<void>;
|
|
14
|
+
private generateId;
|
|
15
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.InMemoryAdapter = void 0;
|
|
4
|
+
class InMemoryAdapter {
|
|
5
|
+
constructor() {
|
|
6
|
+
this.data = {};
|
|
7
|
+
}
|
|
8
|
+
async connect() {
|
|
9
|
+
// No-op for in-memory
|
|
10
|
+
}
|
|
11
|
+
async disconnect() {
|
|
12
|
+
// No-op for in-memory
|
|
13
|
+
}
|
|
14
|
+
registerSchemas(schemas) {
|
|
15
|
+
// No-op for in-memory, but you could validate schemas here if needed
|
|
16
|
+
}
|
|
17
|
+
async create(collection, data, schemaDefinition) {
|
|
18
|
+
if (!this.data[collection]) {
|
|
19
|
+
this.data[collection] = [];
|
|
20
|
+
}
|
|
21
|
+
// Generate a simple id if not present
|
|
22
|
+
if (!data.id) {
|
|
23
|
+
data.id = `${Date.now()}_${Math.random()}`;
|
|
24
|
+
}
|
|
25
|
+
this.data[collection].push(data);
|
|
26
|
+
return data;
|
|
27
|
+
}
|
|
28
|
+
async read(collection, query, limit, skip, schemaDefinition, _includePrivateFields, _options) {
|
|
29
|
+
const allData = this.data[collection] || [];
|
|
30
|
+
let results = allData.filter((item) => {
|
|
31
|
+
return Object.entries(query).every(([key, value]) => item[key] === value);
|
|
32
|
+
});
|
|
33
|
+
if (limit !== undefined && skip !== undefined) {
|
|
34
|
+
results = results.slice(skip, skip + limit);
|
|
35
|
+
}
|
|
36
|
+
return results;
|
|
37
|
+
}
|
|
38
|
+
async update(collection, id, data, schemaDefinition) {
|
|
39
|
+
const allData = this.data[collection] || [];
|
|
40
|
+
const index = allData.findIndex((item) => item.id === id);
|
|
41
|
+
if (index === -1) {
|
|
42
|
+
throw new Error(`Document with id ${id} not found in collection '${collection}'`);
|
|
43
|
+
}
|
|
44
|
+
this.data[collection][index] = { ...allData[index], ...data };
|
|
45
|
+
return this.data[collection][index];
|
|
46
|
+
}
|
|
47
|
+
async delete(collection, id, schemaDefinition) {
|
|
48
|
+
const allData = this.data[collection] || [];
|
|
49
|
+
const index = allData.findIndex((item) => item.id === id);
|
|
50
|
+
if (index === -1) {
|
|
51
|
+
throw new Error(`Document with id ${id} not found in collection '${collection}'`);
|
|
52
|
+
}
|
|
53
|
+
const deleted = this.data[collection].splice(index, 1)[0];
|
|
54
|
+
return deleted;
|
|
55
|
+
}
|
|
56
|
+
async count(collection, query, schemaDefinition) {
|
|
57
|
+
const allData = this.data[collection] || [];
|
|
58
|
+
// Count documents matching the query
|
|
59
|
+
const results = allData.filter((item) => {
|
|
60
|
+
return Object.entries(query).every(([key, value]) => item[key] === value);
|
|
61
|
+
});
|
|
62
|
+
return results.length;
|
|
63
|
+
}
|
|
64
|
+
async syncIndexes(_modelName, _desired) {
|
|
65
|
+
// no-op for memory adapter
|
|
66
|
+
}
|
|
67
|
+
generateId() {
|
|
68
|
+
return Math.random().toString(36).substr(2, 9);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
exports.InMemoryAdapter = InMemoryAdapter;
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { DatabaseAdapter, FieldIndexSpec } from './DatabaseAdapter';
|
|
2
|
+
import { ReadOptions, Schema as NextMinSchema } from '../models/BaseModel';
|
|
3
|
+
export declare class MongoAdapter implements DatabaseAdapter {
|
|
4
|
+
private url;
|
|
5
|
+
private dbName;
|
|
6
|
+
private connection;
|
|
7
|
+
/** Map keyed by lowercased modelName (e.g., "users", "roles") → Mongoose Model */
|
|
8
|
+
private models;
|
|
9
|
+
constructor(url: string, dbName: string);
|
|
10
|
+
connect(): Promise<void>;
|
|
11
|
+
disconnect(): Promise<void>;
|
|
12
|
+
/**
|
|
13
|
+
* Register (and re-register) all schemas.
|
|
14
|
+
* - Drops compiled models that no longer exist.
|
|
15
|
+
* - Recompiles every current model so validators reflect latest schema.
|
|
16
|
+
*/
|
|
17
|
+
registerSchemas(schemas: Record<string, NextMinSchema>): Promise<void>;
|
|
18
|
+
/** Optional hook used by the router when schemas are removed. */
|
|
19
|
+
unregisterSchemas(names: string[]): Promise<void>;
|
|
20
|
+
/** Optional single-model drop hook. */
|
|
21
|
+
dropModel(nameRaw: string): Promise<void>;
|
|
22
|
+
/**
|
|
23
|
+
* Delete a compiled model by case-insensitive match.
|
|
24
|
+
* Works whether we compiled it as "Users" or "users".
|
|
25
|
+
*/
|
|
26
|
+
private safeDeleteModel;
|
|
27
|
+
private safeDropCollection;
|
|
28
|
+
/** Build a fresh Mongoose schema from our NextMin schema definition. */
|
|
29
|
+
private buildMongooseSchema;
|
|
30
|
+
private mapAttribute;
|
|
31
|
+
private mapScalar;
|
|
32
|
+
/**
|
|
33
|
+
* Resolve (or lazily create) the compiled model for a schema.
|
|
34
|
+
* Uses schema.modelName as the Mongoose model name and
|
|
35
|
+
* (schema.collection ?? modelName).toLowerCase() as the collection.
|
|
36
|
+
*/
|
|
37
|
+
private getModel;
|
|
38
|
+
private getSchema;
|
|
39
|
+
private convertQueryFieldsToObjectId;
|
|
40
|
+
private toAppObject;
|
|
41
|
+
create(collection: string, data: any, schemaDefinition: NextMinSchema, includePrivateFields?: boolean): Promise<any>;
|
|
42
|
+
read(collection: string, query: any, limit: number | undefined, skip: number | undefined, schemaDefinition: NextMinSchema, includePrivateFields?: boolean, options?: ReadOptions): Promise<any[]>;
|
|
43
|
+
count(collection: string, query: any, schemaDefinition: NextMinSchema): Promise<number>;
|
|
44
|
+
update(collection: string, id: string, data: any, schemaDefinition: NextMinSchema, includePrivateFields?: boolean): Promise<any>;
|
|
45
|
+
delete(collection: string, id: string, schemaDefinition: NextMinSchema, includePrivateFields?: boolean): Promise<any>;
|
|
46
|
+
private getNativeCollectionByModelName;
|
|
47
|
+
/** Create/drop single-field indexes based on schema plan.
|
|
48
|
+
* Only touches indexes named with the prefix below (won't touch user indexes).
|
|
49
|
+
*/
|
|
50
|
+
private managedIndexName;
|
|
51
|
+
syncIndexes(modelName: string, desired: FieldIndexSpec): Promise<void>;
|
|
52
|
+
}
|
|
@@ -0,0 +1,409 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
35
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
36
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
37
|
+
};
|
|
38
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
39
|
+
exports.MongoAdapter = void 0;
|
|
40
|
+
const mongoose_1 = __importStar(require("mongoose"));
|
|
41
|
+
const mongoose_autopopulate_1 = __importDefault(require("mongoose-autopopulate"));
|
|
42
|
+
const Logger_1 = __importDefault(require("../utils/Logger"));
|
|
43
|
+
const SchemaLoader_1 = require("../utils/SchemaLoader");
|
|
44
|
+
// Prevent Mongoose from pluralizing model names automatically
|
|
45
|
+
mongoose_1.default.pluralize(null);
|
|
46
|
+
class MongoAdapter {
|
|
47
|
+
constructor(url, dbName) {
|
|
48
|
+
this.url = url;
|
|
49
|
+
this.dbName = dbName;
|
|
50
|
+
this.connection = mongoose_1.default.createConnection(`${this.url}/${this.dbName}`);
|
|
51
|
+
this.models = new Map();
|
|
52
|
+
}
|
|
53
|
+
async connect() {
|
|
54
|
+
await this.connection.asPromise();
|
|
55
|
+
Logger_1.default.info('MongoAdapter', `Connected to MongoDB database: ${this.dbName}`);
|
|
56
|
+
}
|
|
57
|
+
async disconnect() {
|
|
58
|
+
await this.connection.close();
|
|
59
|
+
Logger_1.default.warn('MongoAdapter', 'Disconnected from MongoDB database');
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Register (and re-register) all schemas.
|
|
63
|
+
* - Drops compiled models that no longer exist.
|
|
64
|
+
* - Recompiles every current model so validators reflect latest schema.
|
|
65
|
+
*/
|
|
66
|
+
async registerSchemas(schemas) {
|
|
67
|
+
// Build the incoming set from schema.modelName (not object keys!)
|
|
68
|
+
const incoming = new Set(Object.values(schemas).map((s) => s.modelName.toLowerCase()));
|
|
69
|
+
// 1) Remove compiled models that are no longer present
|
|
70
|
+
for (const name of Array.from(this.models.keys())) {
|
|
71
|
+
if (!incoming.has(name)) {
|
|
72
|
+
this.safeDeleteModel(name);
|
|
73
|
+
this.models.delete(name);
|
|
74
|
+
// Optional: also drop the collection (disabled by default)
|
|
75
|
+
// await this.safeDropCollection(name);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
// 2) (Re)compile every current model so validators and fields are fresh
|
|
79
|
+
for (const def of Object.values(schemas)) {
|
|
80
|
+
const modelKey = def.modelName; // e.g. "Users"
|
|
81
|
+
const modelKeyLc = modelKey.toLowerCase();
|
|
82
|
+
const collection = def.collection?.toLowerCase() ?? modelKeyLc; // e.g. "users"
|
|
83
|
+
// always delete previous compiled model to avoid OverwriteModelError and stale validators
|
|
84
|
+
this.safeDeleteModel(modelKey);
|
|
85
|
+
const mSchema = this.buildMongooseSchema(def);
|
|
86
|
+
const model = this.connection.model(modelKey, mSchema, collection);
|
|
87
|
+
this.models.set(modelKeyLc, model);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
/** Optional hook used by the router when schemas are removed. */
|
|
91
|
+
async unregisterSchemas(names) {
|
|
92
|
+
for (const raw of names) {
|
|
93
|
+
const lc = raw.toLowerCase();
|
|
94
|
+
this.safeDeleteModel(lc);
|
|
95
|
+
this.models.delete(lc);
|
|
96
|
+
// Optional: await this.safeDropCollection(lc);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
/** Optional single-model drop hook. */
|
|
100
|
+
async dropModel(nameRaw) {
|
|
101
|
+
const lc = nameRaw.toLowerCase();
|
|
102
|
+
this.safeDeleteModel(lc);
|
|
103
|
+
this.models.delete(lc);
|
|
104
|
+
// Optional: await this.safeDropCollection(lc);
|
|
105
|
+
}
|
|
106
|
+
// ---------------- Internals ----------------
|
|
107
|
+
/**
|
|
108
|
+
* Delete a compiled model by case-insensitive match.
|
|
109
|
+
* Works whether we compiled it as "Users" or "users".
|
|
110
|
+
*/
|
|
111
|
+
safeDeleteModel(nameOrLc) {
|
|
112
|
+
try {
|
|
113
|
+
const targetLc = nameOrLc.toLowerCase();
|
|
114
|
+
const keys = Object.keys(this.connection.models);
|
|
115
|
+
for (const k of keys) {
|
|
116
|
+
if (k.toLowerCase() === targetLc) {
|
|
117
|
+
this.connection.deleteModel(k);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
catch {
|
|
122
|
+
/* ignore */
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
async safeDropCollection(name) {
|
|
126
|
+
try {
|
|
127
|
+
await this.connection.dropCollection(name);
|
|
128
|
+
}
|
|
129
|
+
catch (e) {
|
|
130
|
+
// ignore "ns not found" etc.
|
|
131
|
+
if (e?.codeName !== 'NamespaceNotFound') {
|
|
132
|
+
Logger_1.default.warn('MongoAdapter', `dropCollection(${name}) warning:`, e?.message || e);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
/** Build a fresh Mongoose schema from our NextMin schema definition. */
|
|
137
|
+
buildMongooseSchema(def) {
|
|
138
|
+
const shape = {};
|
|
139
|
+
for (const [key, attr] of Object.entries(def.attributes)) {
|
|
140
|
+
shape[key] = this.mapAttribute(attr);
|
|
141
|
+
}
|
|
142
|
+
const s = new mongoose_1.Schema(shape, { timestamps: true });
|
|
143
|
+
// Hide private fields by default when toObject() is used (unless overridden)
|
|
144
|
+
s.set('toObject', {
|
|
145
|
+
transform: (_doc, ret) => {
|
|
146
|
+
for (const [key, attr] of Object.entries(def.attributes)) {
|
|
147
|
+
if (!Array.isArray(attr) && attr.private) {
|
|
148
|
+
delete ret[key];
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
return ret;
|
|
152
|
+
},
|
|
153
|
+
});
|
|
154
|
+
s.plugin(mongoose_autopopulate_1.default);
|
|
155
|
+
return s;
|
|
156
|
+
}
|
|
157
|
+
mapAttribute(attribute) {
|
|
158
|
+
// Arrays
|
|
159
|
+
if (Array.isArray(attribute)) {
|
|
160
|
+
const elem = attribute[0] || { type: 'string' };
|
|
161
|
+
const mapped = this.mapScalar(elem);
|
|
162
|
+
// Mongoose "array of refs" works best as [{ type: X, ref: 'Y', autopopulate: true }]
|
|
163
|
+
const arrItem = { ...mapped };
|
|
164
|
+
// required belongs on the path, not the inner type
|
|
165
|
+
const required = !!elem.required;
|
|
166
|
+
delete arrItem.required;
|
|
167
|
+
return { type: [arrItem], required };
|
|
168
|
+
}
|
|
169
|
+
// Single
|
|
170
|
+
return this.mapScalar(attribute);
|
|
171
|
+
}
|
|
172
|
+
mapScalar(attr) {
|
|
173
|
+
const typeMapping = {
|
|
174
|
+
string: String,
|
|
175
|
+
number: Number,
|
|
176
|
+
boolean: Boolean,
|
|
177
|
+
date: Date,
|
|
178
|
+
array: Array,
|
|
179
|
+
object: Object,
|
|
180
|
+
objectid: mongoose_1.default.Schema.Types.ObjectId,
|
|
181
|
+
};
|
|
182
|
+
const out = {
|
|
183
|
+
type: typeMapping[String(attr?.type || 'string').toLowerCase()] || String,
|
|
184
|
+
};
|
|
185
|
+
// Only set when truthy to avoid forcing behavior when unset
|
|
186
|
+
if (attr?.required === true)
|
|
187
|
+
out.required = true;
|
|
188
|
+
if (attr?.unique === true)
|
|
189
|
+
out.unique = true;
|
|
190
|
+
if (attr?.ref) {
|
|
191
|
+
out.ref = attr.ref;
|
|
192
|
+
out.autopopulate = true;
|
|
193
|
+
}
|
|
194
|
+
if (attr?.default !== undefined) {
|
|
195
|
+
if (String(attr.type).toLowerCase() === 'date' &&
|
|
196
|
+
attr.default === 'now') {
|
|
197
|
+
out.default = Date.now;
|
|
198
|
+
}
|
|
199
|
+
else {
|
|
200
|
+
out.default = attr.default;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
// Optional common validators
|
|
204
|
+
if (attr?.enum)
|
|
205
|
+
out.enum = attr.enum;
|
|
206
|
+
if (attr?.min !== undefined)
|
|
207
|
+
out.min = attr.min;
|
|
208
|
+
if (attr?.max !== undefined)
|
|
209
|
+
out.max = attr.max;
|
|
210
|
+
if (attr?.minLength !== undefined)
|
|
211
|
+
out.minLength = attr.minLength;
|
|
212
|
+
if (attr?.maxLength !== undefined)
|
|
213
|
+
out.maxLength = attr.maxLength;
|
|
214
|
+
if (attr?.match)
|
|
215
|
+
out.match = new RegExp(attr.match);
|
|
216
|
+
return out;
|
|
217
|
+
}
|
|
218
|
+
/**
|
|
219
|
+
* Resolve (or lazily create) the compiled model for a schema.
|
|
220
|
+
* Uses schema.modelName as the Mongoose model name and
|
|
221
|
+
* (schema.collection ?? modelName).toLowerCase() as the collection.
|
|
222
|
+
*/
|
|
223
|
+
getModel(collection, schemaDefinition) {
|
|
224
|
+
const modelKey = schemaDefinition.modelName; // e.g. "Users"
|
|
225
|
+
const modelKeyLc = modelKey.toLowerCase();
|
|
226
|
+
const collectionName = schemaDefinition.collection?.toLowerCase() ??
|
|
227
|
+
collection.toLowerCase();
|
|
228
|
+
// Prefer cached by lowercased model key
|
|
229
|
+
const cached = this.models.get(modelKeyLc);
|
|
230
|
+
if (cached)
|
|
231
|
+
return cached;
|
|
232
|
+
// Ensure any dependency (rare) – preserved from your original example
|
|
233
|
+
if (schemaDefinition.attributes.author?.ref === 'Users') {
|
|
234
|
+
const userSchema = this.getSchema('Users');
|
|
235
|
+
if (!userSchema)
|
|
236
|
+
throw new Error(`Missing schema for Users`);
|
|
237
|
+
if (!this.models.has('users')) {
|
|
238
|
+
const uModel = this.connection.model(userSchema.modelName, this.buildMongooseSchema(userSchema), userSchema.collection?.toLowerCase() ??
|
|
239
|
+
userSchema.modelName.toLowerCase());
|
|
240
|
+
this.models.set('users', uModel);
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
// Remove any stale compiled model(s) with same name (case-insensitive), then (re)create
|
|
244
|
+
this.safeDeleteModel(modelKey);
|
|
245
|
+
const model = this.connection.model(modelKey, this.buildMongooseSchema(schemaDefinition), collectionName);
|
|
246
|
+
this.models.set(modelKeyLc, model);
|
|
247
|
+
return model;
|
|
248
|
+
}
|
|
249
|
+
getSchema(modelName) {
|
|
250
|
+
// Use the singleton loader so we read the same live map as the router
|
|
251
|
+
const loader = SchemaLoader_1.SchemaLoader.getInstance?.() ?? new SchemaLoader_1.SchemaLoader();
|
|
252
|
+
const schemas = loader.getSchemas();
|
|
253
|
+
// The loader returns a map keyed by modelName; prefer lookup by value
|
|
254
|
+
const byName = Object.values(schemas).find((s) => String(s.modelName).toLowerCase() === modelName.toLowerCase());
|
|
255
|
+
return byName;
|
|
256
|
+
}
|
|
257
|
+
convertQueryFieldsToObjectId(query, schemaDefinition) {
|
|
258
|
+
const newQuery = { ...query };
|
|
259
|
+
// Remap 'id' to '_id'
|
|
260
|
+
if (newQuery.id !== undefined) {
|
|
261
|
+
try {
|
|
262
|
+
newQuery._id = new mongoose_1.default.Types.ObjectId(String(newQuery.id));
|
|
263
|
+
}
|
|
264
|
+
catch {
|
|
265
|
+
// leave as-is if not a valid ObjectId
|
|
266
|
+
newQuery._id = newQuery.id;
|
|
267
|
+
}
|
|
268
|
+
delete newQuery.id;
|
|
269
|
+
}
|
|
270
|
+
// Convert any fields defined as objectid
|
|
271
|
+
for (const [key, attribute] of Object.entries(schemaDefinition.attributes)) {
|
|
272
|
+
if (!Array.isArray(attribute) &&
|
|
273
|
+
String(attribute.type || '').toLowerCase() === 'objectid') {
|
|
274
|
+
const val = newQuery[key];
|
|
275
|
+
if (val === undefined || val === null)
|
|
276
|
+
continue;
|
|
277
|
+
const toObjectId = (v) => {
|
|
278
|
+
try {
|
|
279
|
+
return new mongoose_1.default.Types.ObjectId(String(v));
|
|
280
|
+
}
|
|
281
|
+
catch {
|
|
282
|
+
Logger_1.default.warn('MongoAdapter', `Invalid ObjectId for '${key}': ${v}`);
|
|
283
|
+
return v;
|
|
284
|
+
}
|
|
285
|
+
};
|
|
286
|
+
if (Array.isArray(val)) {
|
|
287
|
+
newQuery[key] = val.map(toObjectId);
|
|
288
|
+
}
|
|
289
|
+
else {
|
|
290
|
+
newQuery[key] = toObjectId(val);
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
return newQuery;
|
|
295
|
+
}
|
|
296
|
+
toAppObject(doc, includePrivateFields) {
|
|
297
|
+
if (!doc)
|
|
298
|
+
return doc;
|
|
299
|
+
const obj = doc.toObject(includePrivateFields
|
|
300
|
+
? { transform: (_doc, ret) => ret } // override to keep private fields
|
|
301
|
+
: undefined);
|
|
302
|
+
if (obj?._id !== undefined) {
|
|
303
|
+
obj.id = String(obj._id);
|
|
304
|
+
delete obj._id;
|
|
305
|
+
}
|
|
306
|
+
if ('__v' in obj)
|
|
307
|
+
delete obj.__v;
|
|
308
|
+
return obj;
|
|
309
|
+
}
|
|
310
|
+
// ---------------- CRUD ----------------
|
|
311
|
+
async create(collection, data, schemaDefinition, includePrivateFields = false) {
|
|
312
|
+
const model = this.getModel(collection, schemaDefinition);
|
|
313
|
+
const doc = new model(data);
|
|
314
|
+
const saved = await doc.save();
|
|
315
|
+
return this.toAppObject(saved, includePrivateFields);
|
|
316
|
+
}
|
|
317
|
+
async read(collection, query, limit, skip, schemaDefinition, includePrivateFields, options) {
|
|
318
|
+
const model = this.getModel(collection, schemaDefinition);
|
|
319
|
+
const q = this.convertQueryFieldsToObjectId(query, schemaDefinition);
|
|
320
|
+
let cursor = model.find(q, options?.projection); // ✅ projection support (optional)
|
|
321
|
+
if (options?.sort && Object.keys(options.sort).length) {
|
|
322
|
+
cursor = cursor.sort(options.sort); // ✅ apply sort
|
|
323
|
+
}
|
|
324
|
+
if (limit !== undefined && skip !== undefined) {
|
|
325
|
+
cursor = cursor.limit(limit).skip(skip);
|
|
326
|
+
}
|
|
327
|
+
const results = await cursor.exec();
|
|
328
|
+
return results.map((doc) => this.toAppObject(doc, includePrivateFields));
|
|
329
|
+
}
|
|
330
|
+
async count(collection, query, schemaDefinition) {
|
|
331
|
+
const model = this.getModel(collection, schemaDefinition);
|
|
332
|
+
const q = this.convertQueryFieldsToObjectId(query, schemaDefinition);
|
|
333
|
+
return model.countDocuments(q).exec();
|
|
334
|
+
}
|
|
335
|
+
async update(collection, id, data, schemaDefinition, includePrivateFields) {
|
|
336
|
+
const model = this.getModel(collection, schemaDefinition);
|
|
337
|
+
const { _id } = this.convertQueryFieldsToObjectId({ id }, schemaDefinition);
|
|
338
|
+
const updated = await model
|
|
339
|
+
.findByIdAndUpdate(_id, data, { new: true, runValidators: true })
|
|
340
|
+
.exec();
|
|
341
|
+
if (!updated) {
|
|
342
|
+
throw new Error(`Document with id ${id} not found in collection '${collection}'`);
|
|
343
|
+
}
|
|
344
|
+
return this.toAppObject(updated, includePrivateFields);
|
|
345
|
+
}
|
|
346
|
+
async delete(collection, id, schemaDefinition, includePrivateFields = false) {
|
|
347
|
+
const model = this.getModel(collection, schemaDefinition);
|
|
348
|
+
const { _id } = this.convertQueryFieldsToObjectId({ id }, schemaDefinition);
|
|
349
|
+
const deleted = await model.findByIdAndDelete(_id).exec();
|
|
350
|
+
if (!deleted) {
|
|
351
|
+
throw new Error(`Document with id ${id} not found in collection '${collection}'`);
|
|
352
|
+
}
|
|
353
|
+
return this.toAppObject(deleted, includePrivateFields);
|
|
354
|
+
}
|
|
355
|
+
getNativeCollectionByModelName(modelName) {
|
|
356
|
+
const s = this.getSchema(modelName);
|
|
357
|
+
if (!s)
|
|
358
|
+
throw new Error(`Schema not found for ${modelName}`);
|
|
359
|
+
const collName = s.collection?.toLowerCase?.() ?? s.modelName.toLowerCase();
|
|
360
|
+
// returns the MongoDB driver's Collection (not a Mongoose Model)
|
|
361
|
+
return this.connection.collection(collName);
|
|
362
|
+
}
|
|
363
|
+
/** Create/drop single-field indexes based on schema plan.
|
|
364
|
+
* Only touches indexes named with the prefix below (won't touch user indexes).
|
|
365
|
+
*/
|
|
366
|
+
managedIndexName(field) {
|
|
367
|
+
return `nextmin_idx_${field}`;
|
|
368
|
+
}
|
|
369
|
+
async syncIndexes(modelName, desired) {
|
|
370
|
+
const col = this.getNativeCollectionByModelName(modelName);
|
|
371
|
+
// list existing and pick only the ones we manage
|
|
372
|
+
const existing = await col.indexes();
|
|
373
|
+
const managed = new Map();
|
|
374
|
+
for (const ix of existing) {
|
|
375
|
+
const name = String(ix.name || '');
|
|
376
|
+
if (name.startsWith('nextmin_idx_')) {
|
|
377
|
+
managed.set(name, { key: ix.key });
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
// desired names from spec
|
|
381
|
+
const desiredEntries = Object.entries(desired);
|
|
382
|
+
const desiredNames = new Set(desiredEntries.map(([field]) => this.managedIndexName(field)));
|
|
383
|
+
// create / recreate if needed
|
|
384
|
+
for (const [field, dir] of desiredEntries) {
|
|
385
|
+
const name = this.managedIndexName(field);
|
|
386
|
+
const have = managed.get(name);
|
|
387
|
+
const wantKey = { [field]: dir };
|
|
388
|
+
if (!have || JSON.stringify(have.key) !== JSON.stringify(wantKey)) {
|
|
389
|
+
if (have) {
|
|
390
|
+
try {
|
|
391
|
+
await col.dropIndex(name);
|
|
392
|
+
}
|
|
393
|
+
catch { }
|
|
394
|
+
}
|
|
395
|
+
await col.createIndex(wantKey, { name, background: true });
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
// drop stale managed indexes
|
|
399
|
+
for (const name of managed.keys()) {
|
|
400
|
+
if (!desiredNames.has(name)) {
|
|
401
|
+
try {
|
|
402
|
+
await col.dropIndex(name);
|
|
403
|
+
}
|
|
404
|
+
catch { }
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
exports.MongoAdapter = MongoAdapter;
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
export type FileProvider = 's3' | 'gcs' | 'local' | (string & {});
|
|
2
|
+
export interface UploadPayload {
|
|
3
|
+
/** Path-like key inside bucket/provider, e.g. "uploads/userId/2025/08/18/file.png" */
|
|
4
|
+
key?: string;
|
|
5
|
+
/** Raw file content */
|
|
6
|
+
body: Buffer | Uint8Array | ArrayBuffer;
|
|
7
|
+
/** MIME type */
|
|
8
|
+
contentType?: string;
|
|
9
|
+
/** Arbitrary metadata */
|
|
10
|
+
metadata?: Record<string, string>;
|
|
11
|
+
}
|
|
12
|
+
export interface UploadResult {
|
|
13
|
+
provider: FileProvider;
|
|
14
|
+
/** Provider bucket/container if applicable */
|
|
15
|
+
bucket?: string;
|
|
16
|
+
/** Final key used for storage */
|
|
17
|
+
key: string;
|
|
18
|
+
/** Public (or CDN) URL if available; otherwise a standard provider URL */
|
|
19
|
+
url: string;
|
|
20
|
+
etag?: string;
|
|
21
|
+
contentType?: string;
|
|
22
|
+
size?: number;
|
|
23
|
+
metadata?: Record<string, string>;
|
|
24
|
+
}
|
|
25
|
+
/** Pluggable storage provider interface (S3 now; GCS/local later) */
|
|
26
|
+
export interface FileStorageAdapter {
|
|
27
|
+
readonly name: FileProvider;
|
|
28
|
+
upload(input: UploadPayload): Promise<UploadResult>;
|
|
29
|
+
delete(key: string): Promise<{
|
|
30
|
+
deleted: boolean;
|
|
31
|
+
}>;
|
|
32
|
+
/** Optional helpers */
|
|
33
|
+
getPublicUrl?(key: string): string | null;
|
|
34
|
+
getSignedUrl?(key: string, expiresInSeconds?: number): Promise<string>;
|
|
35
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { FileStorageAdapter, UploadPayload, UploadResult } from './FileStorageAdapter';
|
|
2
|
+
export interface S3AdapterOptions {
|
|
3
|
+
bucket: string;
|
|
4
|
+
region: string;
|
|
5
|
+
credentials?: {
|
|
6
|
+
accessKeyId: string;
|
|
7
|
+
secretAccessKey: string;
|
|
8
|
+
};
|
|
9
|
+
endpoint?: string;
|
|
10
|
+
forcePathStyle?: boolean;
|
|
11
|
+
defaultACL?: 'private' | 'public-read';
|
|
12
|
+
publicBaseUrl?: string;
|
|
13
|
+
}
|
|
14
|
+
export declare class S3FileStorageAdapter implements FileStorageAdapter {
|
|
15
|
+
readonly name: "s3";
|
|
16
|
+
private readonly s3;
|
|
17
|
+
private readonly bucket;
|
|
18
|
+
private readonly regionStr;
|
|
19
|
+
private readonly endpoint?;
|
|
20
|
+
private readonly forcePathStyle;
|
|
21
|
+
private readonly defaultACL?;
|
|
22
|
+
private readonly publicBaseUrl?;
|
|
23
|
+
constructor(opts: S3AdapterOptions);
|
|
24
|
+
upload(input: UploadPayload): Promise<UploadResult>;
|
|
25
|
+
delete(key: string): Promise<{
|
|
26
|
+
deleted: boolean;
|
|
27
|
+
}>;
|
|
28
|
+
getPublicUrl(key: string): string;
|
|
29
|
+
private randomKey;
|
|
30
|
+
}
|