@arken/seer-protocol 0.1.8 → 0.1.9
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/.rush/temp/shrinkwrap-deps.json +1 -1
- package/area/area.models.ts +1 -1
- package/area/area.router.ts +1 -1
- package/area/area.types.ts +1 -1
- package/asset/asset.models.ts +1 -1
- package/asset/asset.router.ts +1 -1
- package/asset/asset.types.ts +1 -1
- package/build/area/area.models.d.ts +1 -1
- package/build/area/area.models.js +1 -1
- package/build/area/area.models.js.map +1 -1
- package/build/area/area.router.d.ts +5 -5
- package/build/area/area.router.js +1 -1
- package/build/area/area.router.js.map +1 -1
- package/build/area/area.schema.d.ts +8 -8
- package/build/area/area.types.d.ts +1 -1
- package/build/asset/asset.models.d.ts +1 -1
- package/build/asset/asset.models.js +1 -1
- package/build/asset/asset.models.js.map +1 -1
- package/build/asset/asset.router.d.ts +6 -6
- package/build/asset/asset.router.js +1 -1
- package/build/asset/asset.router.js.map +1 -1
- package/build/asset/asset.schema.d.ts +8 -8
- package/build/asset/asset.types.d.ts +1 -1
- package/build/chain/chain.models.d.ts +1 -1
- package/build/chain/chain.models.js +1 -1
- package/build/chain/chain.models.js.map +1 -1
- package/build/chain/chain.router.d.ts +5 -5
- package/build/chain/chain.router.js +1 -1
- package/build/chain/chain.router.js.map +1 -1
- package/build/chain/chain.schema.d.ts +10 -10
- package/build/chain/chain.types.d.ts +1 -1
- package/build/character/character.models.d.ts +1 -1
- package/build/character/character.models.js +1 -1
- package/build/character/character.models.js.map +1 -1
- package/build/character/character.router.d.ts +13 -13
- package/build/character/character.router.js +1 -1
- package/build/character/character.router.js.map +1 -1
- package/build/character/character.schema.d.ts +36 -36
- package/build/character/character.types.d.ts +1 -1
- package/build/chat/chat.models.d.ts +1 -1
- package/build/chat/chat.models.js +1 -1
- package/build/chat/chat.models.js.map +1 -1
- package/build/chat/chat.router.d.ts +4 -4
- package/build/chat/chat.router.js +1 -1
- package/build/chat/chat.router.js.map +1 -1
- package/build/chat/chat.schema.d.ts +4 -4
- package/build/chat/chat.types.d.ts +1 -1
- package/build/collection/collection.models.d.ts +1 -1
- package/build/collection/collection.models.js +1 -1
- package/build/collection/collection.models.js.map +1 -1
- package/build/collection/collection.router.d.ts +10 -10
- package/build/collection/collection.router.js +1 -1
- package/build/collection/collection.router.js.map +1 -1
- package/build/collection/collection.schema.d.ts +20 -20
- package/build/collection/collection.types.d.ts +1 -1
- package/build/core/core.models.d.ts +3 -3
- package/build/core/core.models.js +1 -1
- package/build/core/core.models.js.map +1 -1
- package/build/core/core.router.d.ts +85 -85
- package/build/core/core.router.js +1 -1
- package/build/core/core.router.js.map +1 -1
- package/build/core/core.schema.d.ts +182 -182
- package/build/core/core.types.d.ts +1 -1
- package/build/evolution/evolution.models.js +1 -1
- package/build/evolution/evolution.models.js.map +1 -1
- package/build/evolution/evolution.router.d.ts +2 -2
- package/build/evolution/evolution.router.js +2 -2
- package/build/evolution/evolution.router.js.map +1 -1
- package/build/evolution/evolution.schema.d.ts +2 -1
- package/build/evolution/evolution.schema.js +1 -1
- package/build/evolution/evolution.schema.js.map +1 -1
- package/build/game/game.models.d.ts +1 -1
- package/build/game/game.models.js +1 -1
- package/build/game/game.models.js.map +1 -1
- package/build/game/game.router.d.ts +8 -8
- package/build/game/game.router.js +1 -1
- package/build/game/game.router.js.map +1 -1
- package/build/game/game.schema.d.ts +12 -12
- package/build/game/game.types.d.ts +1 -1
- package/build/index.d.ts +1 -1
- package/build/infinite/infinite.models.js +1 -1
- package/build/infinite/infinite.models.js.map +1 -1
- package/build/infinite/infinite.router.d.ts +1 -1
- package/build/infinite/infinite.router.js +1 -1
- package/build/infinite/infinite.router.js.map +1 -1
- package/build/infinite/infinite.schema.d.ts +2 -1
- package/build/infinite/infinite.schema.js +1 -1
- package/build/infinite/infinite.schema.js.map +1 -1
- package/build/interface/interface.models.d.ts +1 -1
- package/build/interface/interface.models.js +1 -1
- package/build/interface/interface.models.js.map +1 -1
- package/build/interface/interface.router.d.ts +9 -9
- package/build/interface/interface.router.js +1 -1
- package/build/interface/interface.router.js.map +1 -1
- package/build/interface/interface.schema.d.ts +14 -14
- package/build/interface/interface.types.d.ts +1 -1
- package/build/isles/isles.models.js +1 -1
- package/build/isles/isles.models.js.map +1 -1
- package/build/isles/isles.router.d.ts +1 -1
- package/build/isles/isles.router.js +1 -1
- package/build/isles/isles.router.js.map +1 -1
- package/build/item/item.models.d.ts +1 -1
- package/build/item/item.models.js +1 -1
- package/build/item/item.models.js.map +1 -1
- package/build/item/item.router.d.ts +2 -2
- package/build/item/item.router.js +1 -1
- package/build/item/item.router.js.map +1 -1
- package/build/item/item.schema.d.ts +28 -28
- package/build/item/item.types.d.ts +1 -1
- package/build/job/job.models.d.ts +1 -1
- package/build/job/job.models.js +1 -1
- package/build/job/job.models.js.map +1 -1
- package/build/job/job.router.d.ts +2 -2
- package/build/job/job.router.js +1 -1
- package/build/job/job.router.js.map +1 -1
- package/build/job/job.schema.d.ts +2 -2
- package/build/job/job.types.d.ts +1 -1
- package/build/market/market.models.d.ts +1 -1
- package/build/market/market.models.js +1 -1
- package/build/market/market.models.js.map +1 -1
- package/build/market/market.router.d.ts +12 -12
- package/build/market/market.router.js +1 -1
- package/build/market/market.router.js.map +1 -1
- package/build/market/market.schema.d.ts +30 -30
- package/build/market/market.types.d.ts +1 -1
- package/build/oasis/oasis.models.js +1 -1
- package/build/oasis/oasis.models.js.map +1 -1
- package/build/oasis/oasis.router.d.ts +1 -1
- package/build/oasis/oasis.router.js +1 -1
- package/build/oasis/oasis.router.js.map +1 -1
- package/build/package.json +3 -2
- package/build/product/product.models.d.ts +1 -1
- package/build/product/product.models.js +1 -1
- package/build/product/product.models.js.map +1 -1
- package/build/product/product.router.d.ts +14 -14
- package/build/product/product.router.js +1 -1
- package/build/product/product.router.js.map +1 -1
- package/build/product/product.schema.d.ts +22 -22
- package/build/product/product.types.d.ts +1 -1
- package/build/profile/profile.models.d.ts +1 -1
- package/build/profile/profile.models.js +1 -1
- package/build/profile/profile.models.js.map +1 -1
- package/build/profile/profile.router.js +1 -1
- package/build/profile/profile.router.js.map +1 -1
- package/build/profile/profile.types.d.ts +1 -1
- package/build/raffle/raffle.models.d.ts +1 -1
- package/build/raffle/raffle.models.js +1 -1
- package/build/raffle/raffle.models.js.map +1 -1
- package/build/raffle/raffle.router.d.ts +8 -8
- package/build/raffle/raffle.router.js +1 -1
- package/build/raffle/raffle.router.js.map +1 -1
- package/build/raffle/raffle.schema.d.ts +8 -8
- package/build/raffle/raffle.types.d.ts +1 -1
- package/build/router.d.ts +211 -211
- package/build/schema.d.ts +2 -2
- package/build/skill/skill.models.d.ts +1 -1
- package/build/skill/skill.models.js +1 -1
- package/build/skill/skill.models.js.map +1 -1
- package/build/skill/skill.router.d.ts +14 -14
- package/build/skill/skill.router.js +1 -1
- package/build/skill/skill.router.js.map +1 -1
- package/build/skill/skill.schema.d.ts +18 -18
- package/build/skill/skill.types.d.ts +1 -1
- package/build/trek/trek.router.js +1 -1
- package/build/trek/trek.router.js.map +1 -1
- package/build/util/mongo.d.ts +163 -0
- package/build/util/mongo.js +1128 -0
- package/build/util/mongo.js.map +1 -0
- package/build/util/rpc.d.ts +59 -0
- package/build/util/rpc.js +311 -0
- package/build/util/rpc.js.map +1 -0
- package/build/util/schema.d.ts +279 -0
- package/build/util/schema.js +157 -0
- package/build/util/schema.js.map +1 -0
- package/build/video/video.models.d.ts +1 -1
- package/build/video/video.models.js +1 -1
- package/build/video/video.models.js.map +1 -1
- package/build/video/video.router.d.ts +12 -12
- package/build/video/video.router.js +1 -1
- package/build/video/video.router.js.map +1 -1
- package/build/video/video.schema.d.ts +14 -14
- package/build/video/video.types.d.ts +1 -1
- package/chain/chain.models.ts +1 -1
- package/chain/chain.router.ts +1 -1
- package/chain/chain.types.ts +1 -1
- package/character/character.models.ts +1 -1
- package/character/character.router.ts +1 -1
- package/character/character.types.ts +1 -1
- package/chat/chat.models.ts +1 -1
- package/chat/chat.router.ts +1 -1
- package/chat/chat.types.ts +1 -1
- package/collection/collection.models.ts +1 -1
- package/collection/collection.router.ts +1 -1
- package/collection/collection.types.ts +1 -1
- package/core/core.models.ts +1 -1
- package/core/core.router.ts +1 -2
- package/core/core.types.ts +1 -1
- package/evolution/evolution.models.ts +1 -1
- package/evolution/evolution.router.ts +2 -3
- package/evolution/evolution.schema.ts +1 -1
- package/evolution/evolution.types.ts +1 -1
- package/game/game.models.ts +1 -1
- package/game/game.router.ts +1 -1
- package/game/game.types.ts +1 -1
- package/index.ts +1 -1
- package/infinite/infinite.models.ts +1 -1
- package/infinite/infinite.router.ts +2 -3
- package/infinite/infinite.schema.ts +1 -1
- package/infinite/infinite.types.ts +0 -2
- package/interface/interface.models.ts +1 -1
- package/interface/interface.router.ts +1 -1
- package/interface/interface.types.ts +1 -1
- package/isles/isles.models.ts +1 -1
- package/isles/isles.router.ts +2 -3
- package/isles/isles.schema.ts +1 -1
- package/isles/isles.types.ts +1 -1
- package/item/item.models.ts +1 -1
- package/item/item.router.ts +1 -1
- package/item/item.types.ts +1 -1
- package/job/job.models.ts +1 -1
- package/job/job.router.ts +1 -1
- package/job/job.types.ts +1 -1
- package/market/market.models.ts +1 -1
- package/market/market.router.ts +1 -1
- package/market/market.types.ts +1 -1
- package/oasis/oasis.models.ts +1 -1
- package/oasis/oasis.router.ts +2 -2
- package/oasis/oasis.schema.ts +1 -1
- package/oasis/oasis.types.ts +1 -1
- package/package.json +3 -2
- package/product/product.models.ts +1 -1
- package/product/product.router.ts +1 -1
- package/product/product.types.ts +1 -1
- package/profile/profile.models.ts +1 -1
- package/profile/profile.router.ts +1 -1
- package/profile/profile.types.ts +1 -1
- package/raffle/raffle.models.ts +1 -1
- package/raffle/raffle.router.ts +1 -1
- package/raffle/raffle.types.ts +1 -1
- package/router.ts +1 -1
- package/skill/skill.models.ts +1 -1
- package/skill/skill.router.ts +1 -1
- package/skill/skill.types.ts +1 -1
- package/trek/trek.models.ts +1 -1
- package/trek/trek.router.ts +1 -1
- package/trek/trek.schema.ts +1 -1
- package/trek/trek.types.ts +1 -1
- package/tsconfig.json +31 -2
- package/util/mongo.ts +1735 -0
- package/util/rpc.ts +550 -0
- package/util/schema.ts +321 -0
- package/video/video.models.ts +1 -1
- package/video/video.router.ts +1 -1
- package/video/video.types.ts +1 -1
package/util/mongo.ts
ADDED
|
@@ -0,0 +1,1735 @@
|
|
|
1
|
+
// mongo.ts
|
|
2
|
+
|
|
3
|
+
import mongoose, {
|
|
4
|
+
Types,
|
|
5
|
+
Model as MongooseModel,
|
|
6
|
+
Schema,
|
|
7
|
+
SchemaDefinition,
|
|
8
|
+
SchemaOptions,
|
|
9
|
+
Document,
|
|
10
|
+
Query,
|
|
11
|
+
UpdateWriteOpResult,
|
|
12
|
+
QueryOptions,
|
|
13
|
+
FilterQuery,
|
|
14
|
+
UpdateQuery,
|
|
15
|
+
UpdateWithAggregationPipeline,
|
|
16
|
+
ProjectionType,
|
|
17
|
+
Collection,
|
|
18
|
+
VirtualType,
|
|
19
|
+
HydratedDocument,
|
|
20
|
+
} from 'mongoose';
|
|
21
|
+
import crypto from 'crypto';
|
|
22
|
+
export type { Mixed, ObjectIdSchemaDefinition, AnyArray, StringSchemaDefinition } from 'mongoose'; // Mixed type
|
|
23
|
+
import pluralize from 'pluralize';
|
|
24
|
+
export { z } from 'zod';
|
|
25
|
+
|
|
26
|
+
export const toCamelCase = (str: string): string => {
|
|
27
|
+
if (!str) return str;
|
|
28
|
+
|
|
29
|
+
return str
|
|
30
|
+
.replace(/(?:^\w|[A-Z]|\b\w)/g, (word, index) => (index === 0 ? word.toLowerCase() : word.toUpperCase()))
|
|
31
|
+
.replace(/[\s:,()&/\-\+]+/g, '');
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
type PreHookMethod = keyof Query<any, any> | 'save' | 'validate';
|
|
35
|
+
|
|
36
|
+
interface VirtualOptions<T = any> {
|
|
37
|
+
name: string;
|
|
38
|
+
ref?: string;
|
|
39
|
+
refPath?: string;
|
|
40
|
+
localField?: string;
|
|
41
|
+
foreignField?: string;
|
|
42
|
+
justOne?: boolean;
|
|
43
|
+
get?: (this: HydratedDocument<T>, value: any, virtual: VirtualType<T>) => any;
|
|
44
|
+
set?: (this: HydratedDocument<T>, value: any, virtual: VirtualType<T>) => void;
|
|
45
|
+
match?: any;
|
|
46
|
+
options?: any;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function isObjectId(val: any): boolean {
|
|
50
|
+
return val instanceof mongoose.Types.ObjectId || (val && typeof val === 'object' && val._bsontype === 'ObjectID');
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function isPlainObject(val: any): boolean {
|
|
54
|
+
return (
|
|
55
|
+
val !== null && typeof val === 'object' && !Array.isArray(val) && Object.getPrototypeOf(val) === Object.prototype
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
// ---------------------------------------------------------------------------
|
|
59
|
+
// Hash helper for zk / event batching
|
|
60
|
+
// ---------------------------------------------------------------------------
|
|
61
|
+
export function hashEvents(events: any[]): string {
|
|
62
|
+
const raw = JSON.stringify(events);
|
|
63
|
+
return crypto.createHash('sha256').update(raw).digest('hex');
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export { Document, Schema } from 'mongoose';
|
|
67
|
+
|
|
68
|
+
// ---------------------------------------------------------------------------
|
|
69
|
+
// Simple global sequence generator for SeerEvent.seq
|
|
70
|
+
// ---------------------------------------------------------------------------
|
|
71
|
+
interface CounterDocument extends Document {
|
|
72
|
+
key: string;
|
|
73
|
+
value: number;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const CounterSchema = new Schema<CounterDocument>({
|
|
77
|
+
key: { type: String, required: true, unique: true },
|
|
78
|
+
value: { type: Number, required: true, default: 0 },
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
const CounterModel = mongoose.model<CounterDocument>('SeerCounter', CounterSchema);
|
|
82
|
+
|
|
83
|
+
export async function getNextSeq(key: string = 'seerEvent'): Promise<number> {
|
|
84
|
+
const doc = await CounterModel.findOneAndUpdate({ key }, { $inc: { value: 1 } }, { upsert: true, new: true }).exec();
|
|
85
|
+
return doc.value;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Returns a *new* object / array, does not mutate the input.
|
|
90
|
+
* - Root: `_id` (ObjectId) → `id: string`, and `_id` is removed
|
|
91
|
+
* - Nested objects: if they have `_id` as ObjectId, it becomes a string; `id` is also added
|
|
92
|
+
* - Any field that is an ObjectId anywhere in the tree becomes a string
|
|
93
|
+
*/
|
|
94
|
+
function deepNormalizeIds(value: any, isRoot = false): any {
|
|
95
|
+
// Primitive or null → return as-is
|
|
96
|
+
if (value === null || typeof value !== 'object') {
|
|
97
|
+
return value;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// ObjectId → string
|
|
101
|
+
if (isObjectId(value)) {
|
|
102
|
+
return value.toString();
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Arrays → map
|
|
106
|
+
if (Array.isArray(value)) {
|
|
107
|
+
return value.map((item) => deepNormalizeIds(item, false));
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Non-plain objects (Date, Map, mongoose docs, etc.) → leave as-is
|
|
111
|
+
if (!isPlainObject(value)) {
|
|
112
|
+
return value;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Plain object: build a new object
|
|
116
|
+
const out: any = {};
|
|
117
|
+
|
|
118
|
+
for (const [key, val] of Object.entries(value)) {
|
|
119
|
+
out[key] = deepNormalizeIds(val, false);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Handle _id on this object (after children are normalized)
|
|
123
|
+
if ('_id' in out) {
|
|
124
|
+
const idStr = out._id.toString();
|
|
125
|
+
|
|
126
|
+
if (!out.id) {
|
|
127
|
+
out.id = idStr;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
delete out._id;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
return out;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export function addIdTransformHelpers<T>(schema: Schema<T>) {
|
|
137
|
+
// toJSON / toObject for non-lean docs
|
|
138
|
+
schema.set('toJSON', {
|
|
139
|
+
virtuals: true,
|
|
140
|
+
versionKey: false,
|
|
141
|
+
transform: (_doc, ret) => {
|
|
142
|
+
return deepNormalizeIds(ret, true);
|
|
143
|
+
},
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
schema.set('toObject', {
|
|
147
|
+
virtuals: true,
|
|
148
|
+
versionKey: false,
|
|
149
|
+
transform: (_doc, ret) => {
|
|
150
|
+
return deepNormalizeIds(ret, true);
|
|
151
|
+
},
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
// Query helper for lean() use-cases
|
|
155
|
+
(schema.query as any).asJSON = async function () {
|
|
156
|
+
const res = await (this as mongoose.Query<any, any>).lean().exec();
|
|
157
|
+
|
|
158
|
+
if (Array.isArray(res)) {
|
|
159
|
+
return res.map((doc) => deepNormalizeIds(doc, true));
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
return deepNormalizeIds(res, true);
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function applyJsonValueFromSchema(schemaPath: any, value: any) {
|
|
167
|
+
if (!schemaPath) return value;
|
|
168
|
+
|
|
169
|
+
// Simple scalar ObjectId field
|
|
170
|
+
if (schemaPath.instance === 'ObjectId' && typeof value === 'string') {
|
|
171
|
+
// @ts-ignore
|
|
172
|
+
return new mongoose.Types.ObjectId(value);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// Array of ObjectId (or array of subdocs) – naive handling
|
|
176
|
+
if (schemaPath.$embeddedSchemaType && schemaPath.$embeddedSchemaType.instance === 'ObjectId') {
|
|
177
|
+
if (Array.isArray(value)) {
|
|
178
|
+
// @ts-ignore
|
|
179
|
+
return value.map((v) => (typeof v === 'string' ? new mongoose.Types.ObjectId(v) : v));
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
return value;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Mutates `target` (Mongoose doc or plain object) in-place based on `json`,
|
|
188
|
+
* using `schema` to convert string IDs back into ObjectIds where needed.
|
|
189
|
+
*/
|
|
190
|
+
function applyJsonToTarget(schema: Schema, target: any, json: any, pathPrefix = '', isRoot = false) {
|
|
191
|
+
if (!json || typeof json !== 'object') return;
|
|
192
|
+
|
|
193
|
+
for (const [key, value] of Object.entries(json)) {
|
|
194
|
+
// we treat top-level `id` as synthetic; don't reassign _id on existing docs
|
|
195
|
+
if (isRoot && key === 'id') {
|
|
196
|
+
continue;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const fullPath = pathPrefix ? `${pathPrefix}.${key}` : key;
|
|
200
|
+
const schemaPath: any = schema.path(fullPath);
|
|
201
|
+
|
|
202
|
+
if (Array.isArray(value)) {
|
|
203
|
+
const arr: any[] = [];
|
|
204
|
+
for (const item of value) {
|
|
205
|
+
if (item && typeof item === 'object' && !Array.isArray(item)) {
|
|
206
|
+
const child: any = {};
|
|
207
|
+
applyJsonToTarget(schema, child, item, fullPath, false);
|
|
208
|
+
arr.push(child);
|
|
209
|
+
} else {
|
|
210
|
+
arr.push(applyJsonValueFromSchema(schemaPath, item));
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
target[key] = arr;
|
|
214
|
+
} else if (value && typeof value === 'object') {
|
|
215
|
+
const child = target[key] ?? {};
|
|
216
|
+
target[key] = child;
|
|
217
|
+
applyJsonToTarget(schema, child, value, fullPath, false);
|
|
218
|
+
} else {
|
|
219
|
+
target[key] = applyJsonValueFromSchema(schemaPath, value);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Public helper: apply JSON (from .asJSON/.toJSON) back onto a doc or plain object.
|
|
226
|
+
* - Will NOT change _id on existing docs (we skip root `id`)
|
|
227
|
+
* - Converts strings → ObjectId for fields whose schema type is ObjectId
|
|
228
|
+
*/
|
|
229
|
+
export function applyJsonToDoc<T = any>(schema: Schema, target: T, json: any): T {
|
|
230
|
+
applyJsonToTarget(schema, target as any, json, '', true);
|
|
231
|
+
return target;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// ---------------------------------------------------------------------------
|
|
235
|
+
// Ontology / Cluster layer
|
|
236
|
+
// ---------------------------------------------------------------------------
|
|
237
|
+
|
|
238
|
+
const MIN_CLUSTER_TAG_SCORE = 0.3; // below this = low-confidence match (with tags)
|
|
239
|
+
const CLUSTER_AMBIGUITY_DELTA = 0.2; // if top-2 scores are too close, log ambiguity
|
|
240
|
+
|
|
241
|
+
export interface WeightedTag {
|
|
242
|
+
key: string;
|
|
243
|
+
weight: number; // 0..1, 1 = strongest
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
export interface PkEntry {
|
|
247
|
+
field: string; // e.g. 'token', 'characterId', 'assetId'
|
|
248
|
+
type: 'string' | 'number' | 'objectId' | 'boolean';
|
|
249
|
+
s?: string; // string-ish
|
|
250
|
+
n?: number; // numeric
|
|
251
|
+
o?: Types.ObjectId; // ObjectId
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
export interface ClusterDoc extends Document {
|
|
255
|
+
kind: string; // modelName, e.g. 'Item'
|
|
256
|
+
applicationId?: Types.ObjectId;
|
|
257
|
+
|
|
258
|
+
keys: string[];
|
|
259
|
+
primaryKey?: string;
|
|
260
|
+
|
|
261
|
+
tags: WeightedTag[];
|
|
262
|
+
|
|
263
|
+
currentId?: Types.ObjectId;
|
|
264
|
+
currentRevision?: number;
|
|
265
|
+
|
|
266
|
+
pk: PkEntry[];
|
|
267
|
+
|
|
268
|
+
createdDate: Date;
|
|
269
|
+
updatedDate: Date;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
const ClusterSchema = new Schema<ClusterDoc>(
|
|
273
|
+
{
|
|
274
|
+
kind: {
|
|
275
|
+
type: String,
|
|
276
|
+
required: true,
|
|
277
|
+
index: true,
|
|
278
|
+
trim: true,
|
|
279
|
+
},
|
|
280
|
+
|
|
281
|
+
applicationId: { type: Schema.Types.ObjectId, ref: 'Application', index: true },
|
|
282
|
+
|
|
283
|
+
keys: {
|
|
284
|
+
type: [String],
|
|
285
|
+
default: [],
|
|
286
|
+
index: true,
|
|
287
|
+
} as any,
|
|
288
|
+
|
|
289
|
+
primaryKey: { type: String, index: true },
|
|
290
|
+
|
|
291
|
+
tags: {
|
|
292
|
+
type: [
|
|
293
|
+
{
|
|
294
|
+
key: { type: String, required: true },
|
|
295
|
+
weight: {
|
|
296
|
+
type: Number,
|
|
297
|
+
min: 0,
|
|
298
|
+
max: 1,
|
|
299
|
+
default: 1, // 1.0 = strong / core tag
|
|
300
|
+
},
|
|
301
|
+
},
|
|
302
|
+
],
|
|
303
|
+
default: [],
|
|
304
|
+
} as any,
|
|
305
|
+
|
|
306
|
+
currentId: { type: Schema.Types.ObjectId },
|
|
307
|
+
currentRevision: { type: Number, default: 0 },
|
|
308
|
+
|
|
309
|
+
pk: {
|
|
310
|
+
type: [
|
|
311
|
+
{
|
|
312
|
+
field: { type: String, required: true },
|
|
313
|
+
type: {
|
|
314
|
+
type: String,
|
|
315
|
+
enum: ['string', 'number', 'objectId', 'boolean'],
|
|
316
|
+
required: true,
|
|
317
|
+
},
|
|
318
|
+
s: { type: String },
|
|
319
|
+
n: { type: Number },
|
|
320
|
+
o: { type: Schema.Types.ObjectId },
|
|
321
|
+
},
|
|
322
|
+
],
|
|
323
|
+
default: [],
|
|
324
|
+
} as any,
|
|
325
|
+
|
|
326
|
+
createdDate: { type: Date, default: Date.now },
|
|
327
|
+
updatedDate: { type: Date, default: Date.now },
|
|
328
|
+
},
|
|
329
|
+
{
|
|
330
|
+
collection: 'Cluster',
|
|
331
|
+
}
|
|
332
|
+
);
|
|
333
|
+
|
|
334
|
+
// Unique: one cluster per (kind, applicationId, primaryKey)
|
|
335
|
+
ClusterSchema.index({ kind: 1, applicationId: 1, primaryKey: 1 }, { unique: true, sparse: true });
|
|
336
|
+
|
|
337
|
+
// Multikey indexes over pk entries
|
|
338
|
+
ClusterSchema.index({ kind: 1, applicationId: 1, 'pk.field': 1, 'pk.s': 1 });
|
|
339
|
+
ClusterSchema.index({ kind: 1, applicationId: 1, 'pk.field': 1, 'pk.n': 1 });
|
|
340
|
+
ClusterSchema.index({ kind: 1, applicationId: 1, 'pk.field': 1, 'pk.o': 1 });
|
|
341
|
+
|
|
342
|
+
addIdTransformHelpers(ClusterSchema as any);
|
|
343
|
+
|
|
344
|
+
export const ClusterModel = mongoose.model<ClusterDoc>('Cluster', ClusterSchema);
|
|
345
|
+
|
|
346
|
+
// ---------- ontology helpers -----------------------------------------------
|
|
347
|
+
|
|
348
|
+
function normalizeAppId(appId?: any): Types.ObjectId | undefined {
|
|
349
|
+
if (!appId) return undefined;
|
|
350
|
+
if (isObjectId(appId)) return appId as Types.ObjectId;
|
|
351
|
+
|
|
352
|
+
try {
|
|
353
|
+
// Force the "hex string" overload, not the deprecated number overload
|
|
354
|
+
const str = String(appId);
|
|
355
|
+
return new (mongoose.Types.ObjectId as any)(str);
|
|
356
|
+
} catch {
|
|
357
|
+
return undefined;
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
function getPkFields(schema: Schema): string[] {
|
|
362
|
+
const schemaAny = schema as any;
|
|
363
|
+
const fromOptions = schemaAny.options?.pkFields as string[] | undefined;
|
|
364
|
+
if (fromOptions && fromOptions.length) return fromOptions;
|
|
365
|
+
return ['key', 'name', 'token'];
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
function getKeyFields(schema: Schema): string[] {
|
|
369
|
+
const schemaAny = schema as any;
|
|
370
|
+
const fromOptions = schemaAny.options?.keyFields as string[] | undefined;
|
|
371
|
+
if (fromOptions && fromOptions.length) return fromOptions;
|
|
372
|
+
return ['key', 'name', 'token'];
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
// Build pk entries from a doc using pkFields + schema types
|
|
376
|
+
function toPkEntriesFromDoc(doc: any, schema: Schema): PkEntry[] {
|
|
377
|
+
const pkEntries: PkEntry[] = [];
|
|
378
|
+
const pkFields = getPkFields(schema);
|
|
379
|
+
|
|
380
|
+
for (const field of pkFields) {
|
|
381
|
+
const value = doc[field];
|
|
382
|
+
if (value === undefined || value === null) continue;
|
|
383
|
+
|
|
384
|
+
const schemaPath: any = schema.path(field);
|
|
385
|
+
const entry: PkEntry = { field, type: 'string' };
|
|
386
|
+
|
|
387
|
+
if (!schemaPath) {
|
|
388
|
+
entry.s = String(value);
|
|
389
|
+
} else if (schemaPath.instance === 'ObjectId') {
|
|
390
|
+
entry.type = 'objectId';
|
|
391
|
+
const oid = isObjectId(value) ? (value as Types.ObjectId) : new (mongoose.Types.ObjectId as any)(value as any);
|
|
392
|
+
entry.o = oid;
|
|
393
|
+
entry.s = oid.toString();
|
|
394
|
+
} else if (schemaPath.instance === 'Number') {
|
|
395
|
+
entry.type = 'number';
|
|
396
|
+
entry.n = Number(value);
|
|
397
|
+
entry.s = String(entry.n);
|
|
398
|
+
} else if (schemaPath.instance === 'Boolean') {
|
|
399
|
+
entry.type = 'boolean';
|
|
400
|
+
entry.s = value ? 'true' : 'false';
|
|
401
|
+
} else {
|
|
402
|
+
entry.type = 'string';
|
|
403
|
+
entry.s = String(value);
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
pkEntries.push(entry);
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
return pkEntries;
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
// Build keys (aliases) from doc using keyFields
|
|
413
|
+
function buildKeysFromDoc(doc: any, schema: Schema): string[] {
|
|
414
|
+
const keys: string[] = [];
|
|
415
|
+
const keyFields = getKeyFields(schema);
|
|
416
|
+
|
|
417
|
+
for (const field of keyFields) {
|
|
418
|
+
const v = doc[field];
|
|
419
|
+
if (typeof v === 'string' && v.trim()) {
|
|
420
|
+
if (!keys.includes(v)) keys.push(v);
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
return keys;
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
// Extract pk conditions (pk.elemMatch) from a filter
|
|
428
|
+
function buildPkConditionsFromFilter(filter: any, schema: Schema): { pkConditions: any[]; rawTags: any[] } {
|
|
429
|
+
const pkConditions: any[] = [];
|
|
430
|
+
const pkFields = getPkFields(schema);
|
|
431
|
+
|
|
432
|
+
const rawTags = (filter.tags || []) as any[];
|
|
433
|
+
|
|
434
|
+
for (const [field, value] of Object.entries(filter)) {
|
|
435
|
+
if (field === '_id' || field === 'applicationId' || field === 'tags') continue;
|
|
436
|
+
if (!pkFields.includes(field)) continue;
|
|
437
|
+
|
|
438
|
+
const schemaPath: any = schema.path(field);
|
|
439
|
+
const cond: any = { field };
|
|
440
|
+
|
|
441
|
+
if (!schemaPath) {
|
|
442
|
+
cond.type = 'string';
|
|
443
|
+
cond.s = String(value);
|
|
444
|
+
} else if (schemaPath.instance === 'ObjectId') {
|
|
445
|
+
cond.type = 'objectId';
|
|
446
|
+
if (isObjectId(value)) {
|
|
447
|
+
cond.o = value as Types.ObjectId;
|
|
448
|
+
} else {
|
|
449
|
+
const str = String(value);
|
|
450
|
+
cond.o = new (mongoose.Types.ObjectId as any)(str);
|
|
451
|
+
}
|
|
452
|
+
} else if (schemaPath.instance === 'Number') {
|
|
453
|
+
cond.type = 'number';
|
|
454
|
+
cond.n = Number(value);
|
|
455
|
+
} else if (schemaPath.instance === 'Boolean') {
|
|
456
|
+
cond.type = 'boolean';
|
|
457
|
+
cond.s = value ? 'true' : 'false';
|
|
458
|
+
} else {
|
|
459
|
+
cond.type = 'string';
|
|
460
|
+
cond.s = String(value);
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
pkConditions.push(cond);
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
return { pkConditions, rawTags };
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
// Upsert or update a cluster for an entity doc.
|
|
470
|
+
// - Respects revision: cluster.currentRevision only moves forward.
|
|
471
|
+
export async function upsertClusterForEntity(kind: string, schema: Schema, doc: any): Promise<ClusterDoc> {
|
|
472
|
+
const appId = normalizeAppId(doc.applicationId);
|
|
473
|
+
const keys = buildKeysFromDoc(doc, schema);
|
|
474
|
+
const primaryKey = keys[0];
|
|
475
|
+
|
|
476
|
+
const pkEntries = toPkEntriesFromDoc(doc, schema);
|
|
477
|
+
|
|
478
|
+
const query: any = { kind };
|
|
479
|
+
if (appId) query.applicationId = appId;
|
|
480
|
+
if (primaryKey) query.primaryKey = primaryKey;
|
|
481
|
+
|
|
482
|
+
let cluster = await ClusterModel.findOne(query).exec();
|
|
483
|
+
|
|
484
|
+
const docRevision = typeof doc.revision === 'number' && !Number.isNaN(doc.revision) ? doc.revision : 1;
|
|
485
|
+
|
|
486
|
+
if (!cluster) {
|
|
487
|
+
cluster = new ClusterModel({
|
|
488
|
+
kind,
|
|
489
|
+
applicationId: appId,
|
|
490
|
+
keys,
|
|
491
|
+
primaryKey,
|
|
492
|
+
tags: [], // will merge from doc.tags below
|
|
493
|
+
pk: pkEntries,
|
|
494
|
+
currentId: doc._id,
|
|
495
|
+
currentRevision: docRevision,
|
|
496
|
+
});
|
|
497
|
+
} else {
|
|
498
|
+
// merge keys
|
|
499
|
+
const keySet = new Set(cluster.keys || []);
|
|
500
|
+
for (const k of keys) keySet.add(k);
|
|
501
|
+
cluster.keys = Array.from(keySet);
|
|
502
|
+
|
|
503
|
+
// merge pk entries by (field,type)
|
|
504
|
+
const pkMap = new Map<string, PkEntry>();
|
|
505
|
+
for (const entry of cluster.pk || []) {
|
|
506
|
+
pkMap.set(`${entry.field}:${entry.type}`, entry);
|
|
507
|
+
}
|
|
508
|
+
for (const entry of pkEntries) {
|
|
509
|
+
pkMap.set(`${entry.field}:${entry.type}`, entry);
|
|
510
|
+
}
|
|
511
|
+
cluster.pk = Array.from(pkMap.values());
|
|
512
|
+
|
|
513
|
+
// only advance currentId if revision is newer
|
|
514
|
+
if (!cluster.currentRevision || docRevision > cluster.currentRevision) {
|
|
515
|
+
cluster.currentId = doc._id;
|
|
516
|
+
cluster.currentRevision = docRevision;
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
if (primaryKey) cluster.primaryKey = primaryKey;
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
// merge tags (WeightedTag) from doc.tags / doc.meta.tags
|
|
523
|
+
const existingTagMap = new Map<string, number>();
|
|
524
|
+
for (const t of cluster.tags || []) {
|
|
525
|
+
const w = typeof t.weight === 'number' ? t.weight : 1;
|
|
526
|
+
existingTagMap.set(t.key, Math.max(0, Math.min(1, w)));
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
const rawTags = (doc.tags || doc.meta?.tags || []) as any[];
|
|
530
|
+
for (const t of rawTags) {
|
|
531
|
+
let key: string;
|
|
532
|
+
let weight = 1;
|
|
533
|
+
if (typeof t === 'string') {
|
|
534
|
+
key = t;
|
|
535
|
+
} else if (t && typeof t === 'object') {
|
|
536
|
+
key = t.key ?? t.name ?? String(t);
|
|
537
|
+
if (typeof t.weight === 'number') {
|
|
538
|
+
weight = Math.max(0, Math.min(1, t.weight));
|
|
539
|
+
}
|
|
540
|
+
} else {
|
|
541
|
+
key = String(t);
|
|
542
|
+
}
|
|
543
|
+
const existing = existingTagMap.get(key) ?? 0;
|
|
544
|
+
existingTagMap.set(key, Math.max(existing, weight));
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
cluster.tags = Array.from(existingTagMap.entries()).map(([key, weight]) => ({
|
|
548
|
+
key,
|
|
549
|
+
weight,
|
|
550
|
+
}));
|
|
551
|
+
|
|
552
|
+
cluster.updatedDate = new Date();
|
|
553
|
+
await cluster.save();
|
|
554
|
+
|
|
555
|
+
// link back to entity if it supports clusterId
|
|
556
|
+
if ('clusterId' in doc && !doc.clusterId) {
|
|
557
|
+
doc.clusterId = cluster._id;
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
return cluster;
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
// Resolve clusters for a filter (PK + tags), with scores.
|
|
564
|
+
export async function resolveClustersForFilter(
|
|
565
|
+
kind: string,
|
|
566
|
+
schema: Schema,
|
|
567
|
+
applicationId: Types.ObjectId | string | undefined,
|
|
568
|
+
filter: any
|
|
569
|
+
): Promise<(ClusterDoc & { score?: number })[]> {
|
|
570
|
+
const appId = normalizeAppId(applicationId);
|
|
571
|
+
|
|
572
|
+
const { pkConditions, rawTags } = buildPkConditionsFromFilter(filter, schema);
|
|
573
|
+
const tagKeys = rawTags.map((t: any) => (typeof t === 'string' ? t : (t.key ?? t.name ?? String(t))));
|
|
574
|
+
|
|
575
|
+
const baseMatch: any = { kind };
|
|
576
|
+
if (appId) baseMatch.applicationId = appId;
|
|
577
|
+
|
|
578
|
+
const and: any[] = [];
|
|
579
|
+
|
|
580
|
+
for (const cond of pkConditions) {
|
|
581
|
+
and.push({
|
|
582
|
+
pk: { $elemMatch: cond },
|
|
583
|
+
});
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
if (and.length) {
|
|
587
|
+
baseMatch.$and = and;
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
const pipeline: any[] = [{ $match: baseMatch }];
|
|
591
|
+
|
|
592
|
+
const hasTags = tagKeys.length > 0;
|
|
593
|
+
if (hasTags) {
|
|
594
|
+
// clusters must share at least one tag
|
|
595
|
+
pipeline[0].$match['tags.key'] = { $in: tagKeys };
|
|
596
|
+
pipeline.push({
|
|
597
|
+
$addFields: {
|
|
598
|
+
score: {
|
|
599
|
+
$sum: {
|
|
600
|
+
$map: {
|
|
601
|
+
input: '$tags',
|
|
602
|
+
as: 't',
|
|
603
|
+
in: {
|
|
604
|
+
$cond: [{ $in: ['$$t.key', tagKeys] }, '$$t.weight', 0],
|
|
605
|
+
},
|
|
606
|
+
},
|
|
607
|
+
},
|
|
608
|
+
},
|
|
609
|
+
},
|
|
610
|
+
});
|
|
611
|
+
pipeline.push({ $sort: { score: -1, updatedDate: -1 } });
|
|
612
|
+
} else {
|
|
613
|
+
pipeline.push({ $sort: { updatedDate: -1 } });
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
const clusters = await ClusterModel.aggregate(pipeline).exec();
|
|
617
|
+
return clusters as any;
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
// ---------------------------------------------------------------------------
|
|
621
|
+
// Schema & model helpers
|
|
622
|
+
// ---------------------------------------------------------------------------
|
|
623
|
+
|
|
624
|
+
const CommonFields = {
|
|
625
|
+
// Ontology / cluster linkage + revision
|
|
626
|
+
clusterId: { type: Schema.Types.ObjectId, ref: 'Cluster', index: true },
|
|
627
|
+
revision: { type: Number, default: 1 },
|
|
628
|
+
|
|
629
|
+
key: { type: String, minlength: 1, maxlength: 200, trim: true },
|
|
630
|
+
name: { type: String },
|
|
631
|
+
description: { type: String },
|
|
632
|
+
status: {
|
|
633
|
+
type: String,
|
|
634
|
+
default: 'Active', // Default value set here
|
|
635
|
+
enum: ['Paused', 'Pending', 'Active', 'Archived'],
|
|
636
|
+
},
|
|
637
|
+
data: { type: Object, default: {} }, // Default value set here
|
|
638
|
+
meta: { type: Object, default: {} }, // Default value set here
|
|
639
|
+
merkleLeaf: { type: String },
|
|
640
|
+
merkleIndex: { type: Number },
|
|
641
|
+
createdById: { type: Schema.Types.ObjectId, ref: 'Profile' },
|
|
642
|
+
editedById: { type: Schema.Types.ObjectId, ref: 'Profile' },
|
|
643
|
+
deletedById: { type: Schema.Types.ObjectId, ref: 'Profile' },
|
|
644
|
+
createdDate: { type: Date, default: Date.now },
|
|
645
|
+
updatedDate: { type: Date },
|
|
646
|
+
deletedDate: { type: Date },
|
|
647
|
+
};
|
|
648
|
+
|
|
649
|
+
const EntityFields = {
|
|
650
|
+
applicationId: { type: Schema.Types.ObjectId, ref: 'Application', required: true },
|
|
651
|
+
ownerId: { type: Schema.Types.ObjectId, ref: 'Profile' },
|
|
652
|
+
};
|
|
653
|
+
|
|
654
|
+
interface CacheConfig {
|
|
655
|
+
enabled?: boolean;
|
|
656
|
+
ttlMs?: number;
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
interface CustomSchemaOptions extends SchemaOptions {
|
|
660
|
+
extend?: 'EntityFields' | 'CommonFields';
|
|
661
|
+
indexes?: { [field: string]: any }[];
|
|
662
|
+
virtuals?: VirtualOptions[];
|
|
663
|
+
pre?: { method: PreHookMethod | RegExp; handler: (this: Document, next: any) => void }[];
|
|
664
|
+
|
|
665
|
+
// Ontology config
|
|
666
|
+
pkFields?: string[];
|
|
667
|
+
keyFields?: string[];
|
|
668
|
+
|
|
669
|
+
// Per-model cache configuration
|
|
670
|
+
cache?: CacheConfig;
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
export function createSchema<T>(
|
|
674
|
+
name: string,
|
|
675
|
+
customFields: SchemaDefinition<T> = {} as SchemaDefinition<T>,
|
|
676
|
+
options: CustomSchemaOptions = {}
|
|
677
|
+
): Schema<T> {
|
|
678
|
+
const extend = options.extend !== undefined ? options.extend : 'EntityFields';
|
|
679
|
+
const collectionName = options.collection || name;
|
|
680
|
+
|
|
681
|
+
let schema: Schema<T>;
|
|
682
|
+
|
|
683
|
+
const schemaOptions: any = {
|
|
684
|
+
minimize: false,
|
|
685
|
+
timestamps: { createdAt: 'createdDate', updatedAt: 'updatedDate' },
|
|
686
|
+
collection: collectionName,
|
|
687
|
+
};
|
|
688
|
+
|
|
689
|
+
// propagate ontology config into schema.options
|
|
690
|
+
if (options.pkFields) schemaOptions.pkFields = options.pkFields;
|
|
691
|
+
if (options.keyFields) schemaOptions.keyFields = options.keyFields;
|
|
692
|
+
|
|
693
|
+
if (extend === 'EntityFields') {
|
|
694
|
+
schema = new Schema<T>(
|
|
695
|
+
{
|
|
696
|
+
...CommonFields,
|
|
697
|
+
...EntityFields,
|
|
698
|
+
...customFields,
|
|
699
|
+
} as SchemaDefinition<T>,
|
|
700
|
+
schemaOptions
|
|
701
|
+
);
|
|
702
|
+
} else {
|
|
703
|
+
schema = new Schema<T>(
|
|
704
|
+
{
|
|
705
|
+
...CommonFields,
|
|
706
|
+
...customFields,
|
|
707
|
+
} as SchemaDefinition<T>,
|
|
708
|
+
schemaOptions
|
|
709
|
+
);
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
// schema.plugin(require('mongoose-autopopulate'));
|
|
713
|
+
|
|
714
|
+
schema.set('toJSON', {
|
|
715
|
+
virtuals: true, // Include virtual fields
|
|
716
|
+
versionKey: false, // Remove the __v version field
|
|
717
|
+
transform: (doc, ret) => {
|
|
718
|
+
// @ts-ignore
|
|
719
|
+
ret.id = ret._id.toString(); // Assign _id to id
|
|
720
|
+
delete ret._id; // Remove _id from the output
|
|
721
|
+
return ret;
|
|
722
|
+
},
|
|
723
|
+
});
|
|
724
|
+
|
|
725
|
+
schema.set('toObject', {
|
|
726
|
+
virtuals: true, // Include virtual fields
|
|
727
|
+
versionKey: false, // Remove the __v version field
|
|
728
|
+
transform: (doc, ret) => {
|
|
729
|
+
// @ts-ignore
|
|
730
|
+
ret.id = ret._id.toString(); // Assign _id to id
|
|
731
|
+
delete ret._id; // Remove _id from the output
|
|
732
|
+
return ret;
|
|
733
|
+
},
|
|
734
|
+
});
|
|
735
|
+
|
|
736
|
+
addIdTransformHelpers(schema);
|
|
737
|
+
|
|
738
|
+
// Apply indexes
|
|
739
|
+
if (options.indexes) {
|
|
740
|
+
options.indexes.forEach((index) => schema.index(index));
|
|
741
|
+
} else {
|
|
742
|
+
schema.index({ key: 1 });
|
|
743
|
+
schema.index({ name: 1 });
|
|
744
|
+
schema.index({ status: 1 });
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
// Apply virtuals
|
|
748
|
+
if (options.virtuals) {
|
|
749
|
+
options.virtuals.forEach((virtual) => {
|
|
750
|
+
const virtualOptions: any = {
|
|
751
|
+
localField: virtual.localField || `${toCamelCase(virtual.name)}Id`,
|
|
752
|
+
foreignField: virtual.foreignField || '_id',
|
|
753
|
+
justOne: virtual.justOne !== undefined ? virtual.justOne : !pluralize.isPlural(virtual.name),
|
|
754
|
+
};
|
|
755
|
+
|
|
756
|
+
if (virtual.refPath) {
|
|
757
|
+
virtualOptions.refPath = virtual.refPath;
|
|
758
|
+
} else if (virtual.ref) {
|
|
759
|
+
virtualOptions.ref = virtual.ref;
|
|
760
|
+
} else if (schema.path(virtual.name + 'Id')) {
|
|
761
|
+
virtualOptions.ref = pluralize.singular(schema.path(virtual.name + 'Id').options.ref);
|
|
762
|
+
} else {
|
|
763
|
+
virtualOptions.ref = pluralize.singular(virtual.name.charAt(0).toUpperCase() + virtual.name.slice(1));
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
if (virtual.options) {
|
|
767
|
+
virtualOptions.options = virtual.options;
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
if (virtual.match) {
|
|
771
|
+
virtualOptions.match = virtual.match;
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
const schemaVirtual = schema.virtual(virtual.name, virtualOptions);
|
|
775
|
+
|
|
776
|
+
if (virtual.get) {
|
|
777
|
+
schemaVirtual.get(virtual.get);
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
if (virtual.set) {
|
|
781
|
+
schemaVirtual.set(virtual.set);
|
|
782
|
+
}
|
|
783
|
+
});
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
// Apply pre middleware
|
|
787
|
+
if (options.pre) {
|
|
788
|
+
options.pre.forEach((preHook) => {
|
|
789
|
+
schema.pre(preHook.method as any, preHook.handler); // Casting to 'any' for compatibility with Mongoose types
|
|
790
|
+
});
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
return schema;
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
const modelMap: Record<string, Model<any>> = {};
|
|
797
|
+
|
|
798
|
+
export function createModel<T extends Document>(
|
|
799
|
+
key: string,
|
|
800
|
+
schemaFields: SchemaDefinition<T> = {} as SchemaDefinition<T>,
|
|
801
|
+
options: CustomSchemaOptions = {}
|
|
802
|
+
) {
|
|
803
|
+
if (modelMap[key]) return modelMap[key];
|
|
804
|
+
|
|
805
|
+
const schema = createSchema<T>(key, schemaFields, options);
|
|
806
|
+
|
|
807
|
+
// NEW: pass cache config to Model
|
|
808
|
+
const res = new Model<T>(mongoose.model<T>(key, schema), { cache: options.cache });
|
|
809
|
+
|
|
810
|
+
modelMap[key] = res;
|
|
811
|
+
|
|
812
|
+
return res;
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
// ---------------------------------------------------------------------------
|
|
816
|
+
// zkSNARK verification hook (pluggable)
|
|
817
|
+
// ---------------------------------------------------------------------------
|
|
818
|
+
|
|
819
|
+
export type ZkProofPayload = {
|
|
820
|
+
walletAddress: string; // address that "owns" this operation
|
|
821
|
+
proof: any; // zk proof blob
|
|
822
|
+
publicSignals?: any; // optional public inputs from the circuit
|
|
823
|
+
};
|
|
824
|
+
|
|
825
|
+
export type ZkVerifyContext = {
|
|
826
|
+
kind: string; // model name, e.g. 'Item'
|
|
827
|
+
operation: 'create' | 'update';
|
|
828
|
+
filter?: any;
|
|
829
|
+
update?: any;
|
|
830
|
+
doc?: any; // doc(s) being created, for createWithProof
|
|
831
|
+
};
|
|
832
|
+
|
|
833
|
+
export type ZkVerifier = (payload: ZkProofPayload, ctx: ZkVerifyContext) => Promise<boolean> | boolean;
|
|
834
|
+
|
|
835
|
+
let globalZkVerifier: ZkVerifier | null = null;
|
|
836
|
+
|
|
837
|
+
// Call this during app bootstrap to plug in your real zk verifier.
|
|
838
|
+
export function setZkVerifier(verifier: ZkVerifier) {
|
|
839
|
+
globalZkVerifier = verifier;
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
async function verifyZkOrThrow(payload: ZkProofPayload, ctx: ZkVerifyContext): Promise<void> {
|
|
843
|
+
if (!globalZkVerifier) {
|
|
844
|
+
// If no verifier is registered, treat as "no zk enforcement" (or throw if you want hard fail).
|
|
845
|
+
return;
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
const ok = await globalZkVerifier(payload, ctx);
|
|
849
|
+
if (!ok) {
|
|
850
|
+
throw new Error('Invalid zk proof for operation');
|
|
851
|
+
}
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
// ---------------------------------------------------------------------------
|
|
855
|
+
// Model wrapper
|
|
856
|
+
// ---------------------------------------------------------------------------
|
|
857
|
+
|
|
858
|
+
type ModelConfig = {
|
|
859
|
+
cache?: CacheConfig;
|
|
860
|
+
};
|
|
861
|
+
|
|
862
|
+
export class Model<T extends Document> {
|
|
863
|
+
protected model: MongooseModel<T>;
|
|
864
|
+
protected schema: Schema;
|
|
865
|
+
public filters: Record<string, any> = {};
|
|
866
|
+
public filterOmitModels: string[] = ['Omniverse', 'Metaverse', 'Application'];
|
|
867
|
+
public collection: Collection;
|
|
868
|
+
|
|
869
|
+
private docSaveQueue = new WeakMap<Document, Promise<T>>();
|
|
870
|
+
|
|
871
|
+
// NEW: cache config + store
|
|
872
|
+
private cacheConfig: { enabled: boolean; ttlMs: number };
|
|
873
|
+
private cache = new Map<string, { doc: T; fetchedAt: number }>();
|
|
874
|
+
|
|
875
|
+
constructor(model: MongooseModel<T>, config: ModelConfig = {}) {
|
|
876
|
+
this.model = model;
|
|
877
|
+
this.collection = model.collection;
|
|
878
|
+
this.schema = model.schema;
|
|
879
|
+
|
|
880
|
+
this.cacheConfig = {
|
|
881
|
+
enabled: config.cache?.enabled ?? false,
|
|
882
|
+
ttlMs: config.cache?.ttlMs ?? 60_000, // default 60s
|
|
883
|
+
};
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
private get kind(): string {
|
|
887
|
+
return this.model.modelName;
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
private buildCacheKey(id: Types.ObjectId | string, applicationId?: any): string {
|
|
891
|
+
const appId = normalizeAppId(applicationId);
|
|
892
|
+
const appKey = appId ? appId.toString() : 'global';
|
|
893
|
+
const idStr = typeof id === 'string' ? id : id.toString();
|
|
894
|
+
return `${appKey}:${idStr}`;
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
private getFromCache(key: string): T | null {
|
|
898
|
+
if (!this.cacheConfig.enabled) return null;
|
|
899
|
+
const entry = this.cache.get(key);
|
|
900
|
+
if (!entry) return null;
|
|
901
|
+
|
|
902
|
+
if (Date.now() - entry.fetchedAt > this.cacheConfig.ttlMs) {
|
|
903
|
+
this.cache.delete(key);
|
|
904
|
+
return null;
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
return entry.doc;
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
private setCache(key: string, doc: T): void {
|
|
911
|
+
if (!this.cacheConfig.enabled) return;
|
|
912
|
+
this.cache.set(key, { doc, fetchedAt: Date.now() });
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
private isClusterEnabled(): boolean {
|
|
916
|
+
const name = this.model.modelName;
|
|
917
|
+
if (name === 'Cluster') return false;
|
|
918
|
+
return !this.filterOmitModels.includes(name);
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
// // Cluster-aware find
|
|
922
|
+
// find(
|
|
923
|
+
// filter: FilterQuery<T> = {},
|
|
924
|
+
// projection?: ProjectionType<T> | null,
|
|
925
|
+
// options?: mongoose.QueryOptions
|
|
926
|
+
// ): Query<T[], T> {
|
|
927
|
+
// const finalFilter = this.applyDefaultFilters(filter);
|
|
928
|
+
// const q = this.model.find(finalFilter, projection, options);
|
|
929
|
+
// return this.wrapQueryWithCluster(q as any, false) as any;
|
|
930
|
+
// }
|
|
931
|
+
|
|
932
|
+
// // Cluster-aware findOne
|
|
933
|
+
// findOne(
|
|
934
|
+
// filter: FilterQuery<T> = {},
|
|
935
|
+
// projection?: ProjectionType<T> | null,
|
|
936
|
+
// options?: QueryOptions
|
|
937
|
+
// ): Query<T | null, T> {
|
|
938
|
+
// const finalFilter = this.applyDefaultFilters(filter);
|
|
939
|
+
// const q = this.model.findOne(finalFilter, projection, options);
|
|
940
|
+
// return this.wrapQueryWithCluster(q as any, true) as any;
|
|
941
|
+
// }
|
|
942
|
+
|
|
943
|
+
// // Cluster-aware findById (implemented as findOne({_id}))
|
|
944
|
+
// findById(
|
|
945
|
+
// id: Types.ObjectId | string,
|
|
946
|
+
// projection?: ProjectionType<T> | null,
|
|
947
|
+
// options?: QueryOptions
|
|
948
|
+
// ): Query<T | null, T> {
|
|
949
|
+
// const filter: any = { _id: id };
|
|
950
|
+
// const finalFilter = this.applyDefaultFilters(filter);
|
|
951
|
+
// const q = this.model.findOne(finalFilter, projection, options);
|
|
952
|
+
// return this.wrapQueryWithCluster(q as any, true) as any;
|
|
953
|
+
// }
|
|
954
|
+
|
|
955
|
+
// Wrap a query so that exec() does ontology resolution for find/findOne
|
|
956
|
+
private wrapQueryWithCluster(q: Query<any, T>, isFindOne: boolean): Query<any, T> {
|
|
957
|
+
if (!this.isClusterEnabled()) return q;
|
|
958
|
+
|
|
959
|
+
const wrapper = this;
|
|
960
|
+
const rawExec = q.exec;
|
|
961
|
+
|
|
962
|
+
q.exec = async function execWithCluster(this: any, ...args: any[]) {
|
|
963
|
+
const op = this.op; // 'find', 'findOne', etc.
|
|
964
|
+
if (op !== 'find' && op !== 'findOne') {
|
|
965
|
+
return rawExec.apply(this, args);
|
|
966
|
+
}
|
|
967
|
+
|
|
968
|
+
let filter = this.getFilter ? this.getFilter() : this._conditions || {};
|
|
969
|
+
filter = { ...filter }; // clone
|
|
970
|
+
|
|
971
|
+
// direct _id-only with no tags: try cache first, skip cluster
|
|
972
|
+
const hasIdOnly =
|
|
973
|
+
filter &&
|
|
974
|
+
Object.keys(filter).length === 1 &&
|
|
975
|
+
Object.prototype.hasOwnProperty.call(filter, '_id') &&
|
|
976
|
+
!Object.prototype.hasOwnProperty.call(filter, 'tags');
|
|
977
|
+
|
|
978
|
+
if (hasIdOnly) {
|
|
979
|
+
const appId =
|
|
980
|
+
filter.applicationId ??
|
|
981
|
+
(wrapper.filterOmitModels.includes(wrapper.kind) ? undefined : wrapper.filters.applicationId);
|
|
982
|
+
|
|
983
|
+
const idCond = filter._id;
|
|
984
|
+
|
|
985
|
+
// Attempt cache lookup
|
|
986
|
+
if (wrapper.cacheConfig.enabled && idCond) {
|
|
987
|
+
if (op === 'findOne') {
|
|
988
|
+
if (!idCond.$in && !idCond.$nin && typeof idCond !== 'object') {
|
|
989
|
+
const key = wrapper.buildCacheKey(idCond, appId);
|
|
990
|
+
const cached = wrapper.getFromCache(key);
|
|
991
|
+
if (cached) return cached;
|
|
992
|
+
}
|
|
993
|
+
} else if (op === 'find') {
|
|
994
|
+
if (idCond && typeof idCond === 'object' && '$in' in idCond) {
|
|
995
|
+
const ids: any[] = idCond.$in || [];
|
|
996
|
+
const results: any[] = [];
|
|
997
|
+
let allHit = true;
|
|
998
|
+
|
|
999
|
+
for (const id of ids) {
|
|
1000
|
+
const key = wrapper.buildCacheKey(id, appId);
|
|
1001
|
+
const cached = wrapper.getFromCache(key);
|
|
1002
|
+
if (!cached) {
|
|
1003
|
+
allHit = false;
|
|
1004
|
+
break;
|
|
1005
|
+
}
|
|
1006
|
+
results.push(cached);
|
|
1007
|
+
}
|
|
1008
|
+
|
|
1009
|
+
if (allHit) {
|
|
1010
|
+
return results;
|
|
1011
|
+
}
|
|
1012
|
+
}
|
|
1013
|
+
}
|
|
1014
|
+
}
|
|
1015
|
+
|
|
1016
|
+
// No cache hit, fallback to DB and then populate cache (for simple cases)
|
|
1017
|
+
const res = await rawExec.apply(this, args);
|
|
1018
|
+
|
|
1019
|
+
if (wrapper.cacheConfig.enabled && res) {
|
|
1020
|
+
if (op === 'findOne' && res && !Array.isArray(res)) {
|
|
1021
|
+
const key = wrapper.buildCacheKey(res._id, appId);
|
|
1022
|
+
wrapper.setCache(key, res);
|
|
1023
|
+
} else if (op === 'find' && Array.isArray(res)) {
|
|
1024
|
+
for (const d of res) {
|
|
1025
|
+
if (!d || !d._id) continue;
|
|
1026
|
+
const key = wrapper.buildCacheKey(d._id, appId);
|
|
1027
|
+
wrapper.setCache(key, d);
|
|
1028
|
+
}
|
|
1029
|
+
}
|
|
1030
|
+
}
|
|
1031
|
+
|
|
1032
|
+
return res;
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
// get pk/tags presence
|
|
1036
|
+
const { pkConditions, rawTags } = buildPkConditionsFromFilter(filter, wrapper.schema);
|
|
1037
|
+
const hasPk = pkConditions.length > 0;
|
|
1038
|
+
const hasTags = rawTags.length > 0;
|
|
1039
|
+
|
|
1040
|
+
// If no pk fields and no tags, don't do ontology resolution
|
|
1041
|
+
if (!hasPk && !hasTags) {
|
|
1042
|
+
return rawExec.apply(this, args);
|
|
1043
|
+
}
|
|
1044
|
+
|
|
1045
|
+
// Figure out applicationId
|
|
1046
|
+
const applicationId =
|
|
1047
|
+
filter.applicationId ??
|
|
1048
|
+
(wrapper.filterOmitModels.includes(wrapper.kind) ? undefined : wrapper.filters.applicationId);
|
|
1049
|
+
|
|
1050
|
+
try {
|
|
1051
|
+
const clusters = await resolveClustersForFilter(wrapper.kind, wrapper.schema, applicationId as any, filter);
|
|
1052
|
+
|
|
1053
|
+
if (!clusters.length) {
|
|
1054
|
+
// Fallback: raw query, then backfill Cluster
|
|
1055
|
+
const res = await rawExec.apply(this, args);
|
|
1056
|
+
if (res) {
|
|
1057
|
+
if (Array.isArray(res)) {
|
|
1058
|
+
for (const doc of res) {
|
|
1059
|
+
if (doc && wrapper.isClusterEnabled()) {
|
|
1060
|
+
await upsertClusterForEntity(wrapper.kind, wrapper.schema, doc);
|
|
1061
|
+
}
|
|
1062
|
+
}
|
|
1063
|
+
} else if (wrapper.isClusterEnabled()) {
|
|
1064
|
+
await upsertClusterForEntity(wrapper.kind, wrapper.schema, res);
|
|
1065
|
+
}
|
|
1066
|
+
}
|
|
1067
|
+
return res;
|
|
1068
|
+
}
|
|
1069
|
+
|
|
1070
|
+
const best = clusters[0];
|
|
1071
|
+
const bestScore = typeof best.score === 'number' ? best.score : 0;
|
|
1072
|
+
const secondScore =
|
|
1073
|
+
clusters.length > 1 && typeof clusters[1].score === 'number' ? (clusters[1].score as number) : 0;
|
|
1074
|
+
|
|
1075
|
+
if (hasTags && bestScore < MIN_CLUSTER_TAG_SCORE) {
|
|
1076
|
+
console.warn('[Cluster] low-confidence resolution', {
|
|
1077
|
+
model: wrapper.kind,
|
|
1078
|
+
filter,
|
|
1079
|
+
bestClusterId: best._id?.toString(),
|
|
1080
|
+
bestScore,
|
|
1081
|
+
});
|
|
1082
|
+
|
|
1083
|
+
const res = await rawExec.apply(this, args);
|
|
1084
|
+
if (res) {
|
|
1085
|
+
if (Array.isArray(res)) {
|
|
1086
|
+
for (const doc of res) {
|
|
1087
|
+
if (doc && wrapper.isClusterEnabled()) {
|
|
1088
|
+
await upsertClusterForEntity(wrapper.kind, wrapper.schema, doc);
|
|
1089
|
+
}
|
|
1090
|
+
}
|
|
1091
|
+
} else if (wrapper.isClusterEnabled()) {
|
|
1092
|
+
await upsertClusterForEntity(wrapper.kind, wrapper.schema, res);
|
|
1093
|
+
}
|
|
1094
|
+
}
|
|
1095
|
+
return res;
|
|
1096
|
+
}
|
|
1097
|
+
|
|
1098
|
+
if (hasTags && Math.abs(bestScore - secondScore) < CLUSTER_AMBIGUITY_DELTA && clusters.length > 1) {
|
|
1099
|
+
console.warn('[Cluster] ambiguous resolution', {
|
|
1100
|
+
model: wrapper.kind,
|
|
1101
|
+
filter,
|
|
1102
|
+
bestClusterId: best._id?.toString(),
|
|
1103
|
+
bestScore,
|
|
1104
|
+
secondScore,
|
|
1105
|
+
candidateIds: clusters.slice(0, 3).map((c) => c._id?.toString()),
|
|
1106
|
+
});
|
|
1107
|
+
}
|
|
1108
|
+
|
|
1109
|
+
if (isFindOne) {
|
|
1110
|
+
if (!best.currentId) {
|
|
1111
|
+
const res = await rawExec.apply(this, args);
|
|
1112
|
+
if (res && wrapper.isClusterEnabled()) {
|
|
1113
|
+
await upsertClusterForEntity(wrapper.kind, wrapper.schema, res);
|
|
1114
|
+
}
|
|
1115
|
+
return res;
|
|
1116
|
+
}
|
|
1117
|
+
|
|
1118
|
+
const newFilter: any = { _id: best.currentId };
|
|
1119
|
+
if (applicationId) newFilter.applicationId = applicationId;
|
|
1120
|
+
this._conditions = newFilter;
|
|
1121
|
+
|
|
1122
|
+
// Try cache with resolved _id
|
|
1123
|
+
if (wrapper.cacheConfig.enabled) {
|
|
1124
|
+
const key = wrapper.buildCacheKey(best.currentId, applicationId);
|
|
1125
|
+
const cached = wrapper.getFromCache(key);
|
|
1126
|
+
if (cached) return cached;
|
|
1127
|
+
}
|
|
1128
|
+
|
|
1129
|
+
const res = await rawExec.apply(this, args);
|
|
1130
|
+
|
|
1131
|
+
if (res && wrapper.cacheConfig.enabled) {
|
|
1132
|
+
const key = wrapper.buildCacheKey(best.currentId, applicationId);
|
|
1133
|
+
wrapper.setCache(key, res);
|
|
1134
|
+
}
|
|
1135
|
+
|
|
1136
|
+
return res;
|
|
1137
|
+
} else {
|
|
1138
|
+
const ids = clusters.map((c) => c.currentId).filter((id): id is Types.ObjectId => !!id);
|
|
1139
|
+
|
|
1140
|
+
if (!ids.length) {
|
|
1141
|
+
const res = await rawExec.apply(this, args);
|
|
1142
|
+
if (res && Array.isArray(res) && wrapper.isClusterEnabled()) {
|
|
1143
|
+
for (const doc of res) {
|
|
1144
|
+
await upsertClusterForEntity(wrapper.kind, wrapper.schema, doc);
|
|
1145
|
+
}
|
|
1146
|
+
}
|
|
1147
|
+
return res;
|
|
1148
|
+
}
|
|
1149
|
+
|
|
1150
|
+
const newFilter: any = { _id: { $in: ids } };
|
|
1151
|
+
if (applicationId) newFilter.applicationId = applicationId;
|
|
1152
|
+
this._conditions = newFilter;
|
|
1153
|
+
|
|
1154
|
+
// Optional: serve from cache if we have all ids cached
|
|
1155
|
+
if (wrapper.cacheConfig.enabled) {
|
|
1156
|
+
const results: T[] = [];
|
|
1157
|
+
let allHit = true;
|
|
1158
|
+
|
|
1159
|
+
for (const id of ids) {
|
|
1160
|
+
const key = wrapper.buildCacheKey(id, applicationId);
|
|
1161
|
+
const cached = wrapper.getFromCache(key);
|
|
1162
|
+
if (!cached) {
|
|
1163
|
+
allHit = false;
|
|
1164
|
+
break;
|
|
1165
|
+
}
|
|
1166
|
+
results.push(cached);
|
|
1167
|
+
}
|
|
1168
|
+
|
|
1169
|
+
if (allHit) return results;
|
|
1170
|
+
}
|
|
1171
|
+
|
|
1172
|
+
const res = await rawExec.apply(this, args);
|
|
1173
|
+
|
|
1174
|
+
if (res && Array.isArray(res) && wrapper.cacheConfig.enabled) {
|
|
1175
|
+
for (const d of res) {
|
|
1176
|
+
if (!d || !d._id) continue;
|
|
1177
|
+
const key = wrapper.buildCacheKey(d._id, applicationId);
|
|
1178
|
+
wrapper.setCache(key, d);
|
|
1179
|
+
}
|
|
1180
|
+
}
|
|
1181
|
+
|
|
1182
|
+
return res;
|
|
1183
|
+
}
|
|
1184
|
+
} catch (err) {
|
|
1185
|
+
console.warn('[Cluster] error during ontology resolution, falling back', {
|
|
1186
|
+
model: wrapper.kind,
|
|
1187
|
+
filter,
|
|
1188
|
+
error: (err as Error).message,
|
|
1189
|
+
});
|
|
1190
|
+
return rawExec.apply(this, args);
|
|
1191
|
+
}
|
|
1192
|
+
};
|
|
1193
|
+
|
|
1194
|
+
return q;
|
|
1195
|
+
}
|
|
1196
|
+
|
|
1197
|
+
populate(
|
|
1198
|
+
docs: T | T[],
|
|
1199
|
+
options: string | mongoose.PopulateOptions | string[] | mongoose.PopulateOptions[]
|
|
1200
|
+
): Promise<T | T[]> {
|
|
1201
|
+
// If options is an array of strings, convert it to an array of PopulateOptions
|
|
1202
|
+
if (Array.isArray(options) && typeof options[0] === 'string') {
|
|
1203
|
+
options = (options as string[]).map((path) => ({ path }));
|
|
1204
|
+
}
|
|
1205
|
+
|
|
1206
|
+
return this.model.populate(docs, options as string | mongoose.PopulateOptions | mongoose.PopulateOptions[]);
|
|
1207
|
+
}
|
|
1208
|
+
|
|
1209
|
+
// New method to directly access a related model
|
|
1210
|
+
related(name: string) {
|
|
1211
|
+
return mongoose.model(name);
|
|
1212
|
+
}
|
|
1213
|
+
|
|
1214
|
+
// New method to get related documents via virtuals
|
|
1215
|
+
findWithRelations(filter: FilterQuery<T> = {}, relations: string[] = [], options?: QueryOptions): Query<T[], T> {
|
|
1216
|
+
return this.find(filter, null, options).populate(relations.join(' '));
|
|
1217
|
+
}
|
|
1218
|
+
|
|
1219
|
+
findOneWithRelations(
|
|
1220
|
+
filter: FilterQuery<T> = {},
|
|
1221
|
+
relations: string[] = [],
|
|
1222
|
+
options?: QueryOptions
|
|
1223
|
+
): Query<T | null, T> {
|
|
1224
|
+
return this.findOne(filter, null, options).populate(relations.join(' '));
|
|
1225
|
+
}
|
|
1226
|
+
|
|
1227
|
+
// Overridden exec method (raw)
|
|
1228
|
+
async exec(query: Query<any, T>): Promise<any> {
|
|
1229
|
+
return query.exec();
|
|
1230
|
+
}
|
|
1231
|
+
|
|
1232
|
+
private applyDefaultFilters(filter: FilterQuery<T> = {}): FilterQuery<T> {
|
|
1233
|
+
const f: any = { ...filter };
|
|
1234
|
+
|
|
1235
|
+
// applicationId scoping for most models
|
|
1236
|
+
if (this.filters.applicationId && !this.filterOmitModels.includes(this.model.modelName)) {
|
|
1237
|
+
if (f.applicationId === undefined) {
|
|
1238
|
+
f.applicationId = this.filters.applicationId;
|
|
1239
|
+
}
|
|
1240
|
+
}
|
|
1241
|
+
|
|
1242
|
+
// Default: ignore archived records, unless caller explicitly set status
|
|
1243
|
+
if (this.schema.path('status') && f.status === undefined) {
|
|
1244
|
+
f.status = { $ne: 'Archived' } as any;
|
|
1245
|
+
}
|
|
1246
|
+
|
|
1247
|
+
return f;
|
|
1248
|
+
}
|
|
1249
|
+
|
|
1250
|
+
// Cluster-aware find: returns Query, ontology resolution happens in exec()
|
|
1251
|
+
// Override the find method to include filters
|
|
1252
|
+
find(
|
|
1253
|
+
filter: FilterQuery<T> = {},
|
|
1254
|
+
projection?: ProjectionType<T> | null,
|
|
1255
|
+
options?: mongoose.QueryOptions
|
|
1256
|
+
): Query<T[], T> {
|
|
1257
|
+
const finalFilter = this.applyDefaultFilters(filter);
|
|
1258
|
+
console.log('find', finalFilter);
|
|
1259
|
+
return this.model.find(finalFilter, projection, options);
|
|
1260
|
+
}
|
|
1261
|
+
|
|
1262
|
+
// Override the findOne method to include filters
|
|
1263
|
+
findOne(
|
|
1264
|
+
filter: FilterQuery<T> = {},
|
|
1265
|
+
projection?: ProjectionType<T> | null,
|
|
1266
|
+
options?: QueryOptions
|
|
1267
|
+
): Query<T | null, T> {
|
|
1268
|
+
const finalFilter = this.applyDefaultFilters(filter);
|
|
1269
|
+
console.log('findOne', finalFilter);
|
|
1270
|
+
return this.model.findOne(finalFilter, projection, options);
|
|
1271
|
+
}
|
|
1272
|
+
|
|
1273
|
+
// Override the findById method so it also ignores Archived by default
|
|
1274
|
+
findById(
|
|
1275
|
+
id: Types.ObjectId | string,
|
|
1276
|
+
projection?: ProjectionType<T> | null,
|
|
1277
|
+
options?: QueryOptions
|
|
1278
|
+
): Query<T | null, T> {
|
|
1279
|
+
const filter: any = { _id: id };
|
|
1280
|
+
const finalFilter = this.applyDefaultFilters(filter);
|
|
1281
|
+
return this.model.findOne(finalFilter, projection, options);
|
|
1282
|
+
}
|
|
1283
|
+
|
|
1284
|
+
// Raw find (no cluster) if you ever need it
|
|
1285
|
+
findRaw(
|
|
1286
|
+
filter: FilterQuery<T> = {},
|
|
1287
|
+
projection?: ProjectionType<T> | null,
|
|
1288
|
+
options?: mongoose.QueryOptions
|
|
1289
|
+
): Query<T[], T> {
|
|
1290
|
+
if (this.filters.applicationId && !this.filterOmitModels.includes(this.model.modelName)) {
|
|
1291
|
+
// @ts-ignore
|
|
1292
|
+
filter.applicationId = this.filters.applicationId;
|
|
1293
|
+
}
|
|
1294
|
+
return this.model.find(filter, projection, options);
|
|
1295
|
+
}
|
|
1296
|
+
|
|
1297
|
+
// Override the findOneAndUpdate method to include filters (raw)
|
|
1298
|
+
findOneAndUpdate(
|
|
1299
|
+
filter: FilterQuery<T>,
|
|
1300
|
+
update: UpdateQuery<T> | mongoose.UpdateWithAggregationPipeline,
|
|
1301
|
+
options?: QueryOptions & { new?: boolean }
|
|
1302
|
+
): Query<T | null, T> {
|
|
1303
|
+
if (this.filters.applicationId && !this.filterOmitModels.includes(this.model.modelName)) {
|
|
1304
|
+
// @ts-ignore
|
|
1305
|
+
filter.applicationId = this.filters.applicationId;
|
|
1306
|
+
}
|
|
1307
|
+
|
|
1308
|
+
return this.model.findOneAndUpdate(filter, update, options);
|
|
1309
|
+
}
|
|
1310
|
+
|
|
1311
|
+
// Override the findOneAndDelete method to include filters (raw)
|
|
1312
|
+
findOneAndDelete(filter: FilterQuery<T>, options?: QueryOptions): Query<T | null, T> {
|
|
1313
|
+
if (this.filters.applicationId && !this.filterOmitModels.includes(this.model.modelName)) {
|
|
1314
|
+
// @ts-ignore
|
|
1315
|
+
filter.applicationId = this.filters.applicationId;
|
|
1316
|
+
}
|
|
1317
|
+
|
|
1318
|
+
return this.model.findOneAndDelete(filter, options);
|
|
1319
|
+
}
|
|
1320
|
+
|
|
1321
|
+
// Override the findByIdAndUpdate method to include filters (raw)
|
|
1322
|
+
findByIdAndUpdate(
|
|
1323
|
+
id: Types.ObjectId | string,
|
|
1324
|
+
update: UpdateQuery<T> | mongoose.UpdateWithAggregationPipeline,
|
|
1325
|
+
options?: QueryOptions & { new?: boolean }
|
|
1326
|
+
): Query<T | null, T> {
|
|
1327
|
+
const filter: FilterQuery<T> = { _id: id } as FilterQuery<T>;
|
|
1328
|
+
|
|
1329
|
+
if (!this.filterOmitModels.includes(this.model.modelName)) {
|
|
1330
|
+
// @ts-ignore
|
|
1331
|
+
filter.applicationId = this.filters.applicationId;
|
|
1332
|
+
}
|
|
1333
|
+
|
|
1334
|
+
return this.model.findOneAndUpdate(filter, update, options);
|
|
1335
|
+
}
|
|
1336
|
+
|
|
1337
|
+
// Override the findByIdAndDelete method to include filters (raw)
|
|
1338
|
+
findByIdAndDelete(id: Types.ObjectId | string, options?: QueryOptions): Query<T | null, T> {
|
|
1339
|
+
const filter: FilterQuery<T> = { _id: id } as FilterQuery<T>;
|
|
1340
|
+
|
|
1341
|
+
if (!this.filterOmitModels.includes(this.model.modelName)) {
|
|
1342
|
+
// @ts-ignore
|
|
1343
|
+
filter.applicationId = this.filters.applicationId;
|
|
1344
|
+
}
|
|
1345
|
+
|
|
1346
|
+
return this.model.findOneAndDelete(filter, options);
|
|
1347
|
+
}
|
|
1348
|
+
|
|
1349
|
+
create(doc: Partial<T>): Promise<T>;
|
|
1350
|
+
create(doc: Partial<T>[]): Promise<T[]>;
|
|
1351
|
+
create(doc: Partial<T> | Partial<T>[]): Promise<T | T[]> {
|
|
1352
|
+
console.log('create', this.filters.applicationId);
|
|
1353
|
+
if (this.filters.applicationId && !this.filterOmitModels.includes(this.model.modelName)) {
|
|
1354
|
+
if (Array.isArray(doc)) {
|
|
1355
|
+
// @ts-ignore
|
|
1356
|
+
doc.forEach((d) => (d.applicationId = this.filters.applicationId));
|
|
1357
|
+
} else {
|
|
1358
|
+
// @ts-ignore
|
|
1359
|
+
doc.applicationId = this.filters.applicationId;
|
|
1360
|
+
}
|
|
1361
|
+
}
|
|
1362
|
+
|
|
1363
|
+
const p = this.model.create(doc as any) as Promise<T | T[]>;
|
|
1364
|
+
|
|
1365
|
+
if (!this.isClusterEnabled()) {
|
|
1366
|
+
return p;
|
|
1367
|
+
}
|
|
1368
|
+
|
|
1369
|
+
return p.then(async (res: any) => {
|
|
1370
|
+
const docsArray = Array.isArray(res) ? res : [res];
|
|
1371
|
+
for (const d of docsArray) {
|
|
1372
|
+
// Pass the raw Mongoose document so _id, applicationId, revision, etc. are intact
|
|
1373
|
+
const cluster = await upsertClusterForEntity(this.kind, this.schema, d);
|
|
1374
|
+
if ('clusterId' in d && !d.clusterId) {
|
|
1375
|
+
d.clusterId = cluster._id;
|
|
1376
|
+
await d.save();
|
|
1377
|
+
}
|
|
1378
|
+
|
|
1379
|
+
// NEW: seed cache
|
|
1380
|
+
if (this.cacheConfig.enabled && d._id) {
|
|
1381
|
+
const appId = (d as any).applicationId ?? this.filters.applicationId;
|
|
1382
|
+
const key = this.buildCacheKey(d._id, appId);
|
|
1383
|
+
this.setCache(key, d);
|
|
1384
|
+
}
|
|
1385
|
+
}
|
|
1386
|
+
return res;
|
|
1387
|
+
});
|
|
1388
|
+
}
|
|
1389
|
+
|
|
1390
|
+
// Override the upsert method to include filters
|
|
1391
|
+
async upsert(
|
|
1392
|
+
filter: FilterQuery<T> = {},
|
|
1393
|
+
create: Partial<T> = {},
|
|
1394
|
+
update: UpdateQuery<T> = {},
|
|
1395
|
+
options: QueryOptions = {}
|
|
1396
|
+
): Promise<T> {
|
|
1397
|
+
const existing = await this.findOne(filter, null, options).exec();
|
|
1398
|
+
if (existing) {
|
|
1399
|
+
await this.updateOne(filter, update, options).exec();
|
|
1400
|
+
return (await this.findOne(filter, null, options).exec()) as T;
|
|
1401
|
+
} else {
|
|
1402
|
+
return this.create(create) as Promise<T>;
|
|
1403
|
+
}
|
|
1404
|
+
}
|
|
1405
|
+
|
|
1406
|
+
// Override the updateOne method to include filters (raw)
|
|
1407
|
+
updateOne(
|
|
1408
|
+
filter: FilterQuery<T>,
|
|
1409
|
+
update: UpdateQuery<T> | UpdateWithAggregationPipeline,
|
|
1410
|
+
options?: any
|
|
1411
|
+
): Query<UpdateWriteOpResult, T> {
|
|
1412
|
+
if (this.filters.applicationId && !this.filterOmitModels.includes(this.model.modelName)) {
|
|
1413
|
+
// @ts-ignore
|
|
1414
|
+
filter.applicationId = this.filters.applicationId;
|
|
1415
|
+
// @ts-ignore
|
|
1416
|
+
update.applicationId = this.filters.applicationId;
|
|
1417
|
+
}
|
|
1418
|
+
|
|
1419
|
+
return this.model.updateOne(filter, update, options);
|
|
1420
|
+
}
|
|
1421
|
+
|
|
1422
|
+
// Override the updateMany method to include filters (raw)
|
|
1423
|
+
updateMany(
|
|
1424
|
+
filter: FilterQuery<T>,
|
|
1425
|
+
update: UpdateQuery<T> | UpdateWithAggregationPipeline,
|
|
1426
|
+
options?: any
|
|
1427
|
+
): Query<UpdateWriteOpResult, T> {
|
|
1428
|
+
if (this.filters.applicationId && !this.filterOmitModels.includes(this.model.modelName)) {
|
|
1429
|
+
// @ts-ignore
|
|
1430
|
+
filter.applicationId = this.filters.applicationId;
|
|
1431
|
+
}
|
|
1432
|
+
|
|
1433
|
+
return this.model.updateMany(filter, update, options);
|
|
1434
|
+
}
|
|
1435
|
+
|
|
1436
|
+
// Count documents method
|
|
1437
|
+
countDocuments(): any {
|
|
1438
|
+
return this.model.countDocuments();
|
|
1439
|
+
}
|
|
1440
|
+
|
|
1441
|
+
// Method for handling aggregate (raw)
|
|
1442
|
+
aggregate(...props: any[]): any {
|
|
1443
|
+
return this.model.aggregate(...props);
|
|
1444
|
+
}
|
|
1445
|
+
|
|
1446
|
+
// Method for handling where conditions (raw)
|
|
1447
|
+
where(arg1: string, arg2?: any): Query<T[], T>;
|
|
1448
|
+
where(arg1: object): Query<T[], T>;
|
|
1449
|
+
where(arg1: string | object, arg2?: any): Query<T[], T> {
|
|
1450
|
+
return this.model.where(arg1 as any, arg2);
|
|
1451
|
+
}
|
|
1452
|
+
|
|
1453
|
+
// Find all documents method (raw)
|
|
1454
|
+
findAll(): Query<T[], T> {
|
|
1455
|
+
return this.model.find();
|
|
1456
|
+
}
|
|
1457
|
+
|
|
1458
|
+
async findOneProxy(
|
|
1459
|
+
filter: FilterQuery<T> = {},
|
|
1460
|
+
projection?: ProjectionType<T> | null,
|
|
1461
|
+
options?: QueryOptions
|
|
1462
|
+
): Promise<(T & Record<string, any>) | null> {
|
|
1463
|
+
const doc = await this.findOne(filter, projection, options).exec();
|
|
1464
|
+
if (!doc) return null;
|
|
1465
|
+
|
|
1466
|
+
// Return a doc+model merged proxy
|
|
1467
|
+
return createDocProxy(doc, this);
|
|
1468
|
+
}
|
|
1469
|
+
|
|
1470
|
+
/**
|
|
1471
|
+
* saveQueued(doc):
|
|
1472
|
+
* - If there's an existing in-flight save promise for the same doc, wait until that finishes.
|
|
1473
|
+
* - Then call doc.save().
|
|
1474
|
+
* - Store this new save promise in the WeakMap so future calls chain onto it.
|
|
1475
|
+
*/
|
|
1476
|
+
public async saveQueued(doc: T): Promise<T> {
|
|
1477
|
+
const existingPromise = this.docSaveQueue.get(doc) ?? Promise.resolve<T>(doc);
|
|
1478
|
+
|
|
1479
|
+
// Chain a new promise onto the existing one
|
|
1480
|
+
const newSavePromise = existingPromise
|
|
1481
|
+
.then(async () => {
|
|
1482
|
+
// By the time we reach here, previous saves are done.
|
|
1483
|
+
return await doc.save(); // Mongoose's normal doc.save()
|
|
1484
|
+
})
|
|
1485
|
+
.catch((err) => {
|
|
1486
|
+
// If the previous promise was rejected, propagate the error
|
|
1487
|
+
// but remove from the queue so a later call can try again
|
|
1488
|
+
this.docSaveQueue.delete(doc);
|
|
1489
|
+
throw err;
|
|
1490
|
+
});
|
|
1491
|
+
|
|
1492
|
+
// Store the new promise in the queue
|
|
1493
|
+
this.docSaveQueue.set(doc, newSavePromise);
|
|
1494
|
+
|
|
1495
|
+
// Return the doc once the new promise completes
|
|
1496
|
+
return newSavePromise;
|
|
1497
|
+
}
|
|
1498
|
+
|
|
1499
|
+
// -------------------------------------------------------------------------
|
|
1500
|
+
// zkSNARK-aware helpers
|
|
1501
|
+
// -------------------------------------------------------------------------
|
|
1502
|
+
|
|
1503
|
+
async createWithProof(doc: Partial<T>, proof: ZkProofPayload): Promise<T>;
|
|
1504
|
+
async createWithProof(docs: Partial<T>[], proof: ZkProofPayload): Promise<T[]>;
|
|
1505
|
+
async createWithProof(docOrDocs: Partial<T> | Partial<T>[], proof: ZkProofPayload): Promise<T | T[]> {
|
|
1506
|
+
await verifyZkOrThrow(proof, {
|
|
1507
|
+
kind: this.kind,
|
|
1508
|
+
operation: 'create',
|
|
1509
|
+
doc: docOrDocs,
|
|
1510
|
+
});
|
|
1511
|
+
|
|
1512
|
+
return this.create(docOrDocs as any);
|
|
1513
|
+
}
|
|
1514
|
+
|
|
1515
|
+
async updateOneWithProof(
|
|
1516
|
+
filter: FilterQuery<T>,
|
|
1517
|
+
update: UpdateQuery<T> | UpdateWithAggregationPipeline,
|
|
1518
|
+
proof: ZkProofPayload,
|
|
1519
|
+
options?: any
|
|
1520
|
+
): Promise<UpdateWriteOpResult> {
|
|
1521
|
+
await verifyZkOrThrow(proof, {
|
|
1522
|
+
kind: this.kind,
|
|
1523
|
+
operation: 'update',
|
|
1524
|
+
filter,
|
|
1525
|
+
update,
|
|
1526
|
+
});
|
|
1527
|
+
|
|
1528
|
+
return this.updateOne(filter, update, options).exec();
|
|
1529
|
+
}
|
|
1530
|
+
|
|
1531
|
+
// ---------------------------------------------------------------------------
|
|
1532
|
+
// JSON helpers (normalize ids, hide asJSON typings from callers)
|
|
1533
|
+
// ---------------------------------------------------------------------------
|
|
1534
|
+
|
|
1535
|
+
/**
|
|
1536
|
+
* Find many docs and return normalized JSON (id instead of _id, ObjectIds → string).
|
|
1537
|
+
* This hides the mongoose Query/asJSON typing from callers.
|
|
1538
|
+
*/
|
|
1539
|
+
async findJSON(
|
|
1540
|
+
filter: FilterQuery<T> = {},
|
|
1541
|
+
projection?: ProjectionType<T> | null,
|
|
1542
|
+
options?: QueryOptions
|
|
1543
|
+
): Promise<any[]> {
|
|
1544
|
+
// Use the same default filter logic (applicationId, status != Archived)
|
|
1545
|
+
const q = this.find(filter, projection, options) as any;
|
|
1546
|
+
|
|
1547
|
+
// If query helper asJSON exists, use it
|
|
1548
|
+
if (typeof q.asJSON === 'function') {
|
|
1549
|
+
return q.asJSON();
|
|
1550
|
+
}
|
|
1551
|
+
|
|
1552
|
+
// Fallback: lean() + deepNormalizeIds
|
|
1553
|
+
const res = await q.lean().exec();
|
|
1554
|
+
if (Array.isArray(res)) {
|
|
1555
|
+
return res.map((doc: any) => deepNormalizeIds(doc, true));
|
|
1556
|
+
}
|
|
1557
|
+
return [];
|
|
1558
|
+
}
|
|
1559
|
+
|
|
1560
|
+
/**
|
|
1561
|
+
* Find one doc and return normalized JSON (or null).
|
|
1562
|
+
*/
|
|
1563
|
+
async findOneJSON(
|
|
1564
|
+
filter: FilterQuery<T> = {},
|
|
1565
|
+
projection?: ProjectionType<T> | null,
|
|
1566
|
+
options?: QueryOptions
|
|
1567
|
+
): Promise<any | null> {
|
|
1568
|
+
const q = this.findOne(filter, projection, options) as any;
|
|
1569
|
+
|
|
1570
|
+
if (typeof q.asJSON === 'function') {
|
|
1571
|
+
return q.asJSON();
|
|
1572
|
+
}
|
|
1573
|
+
|
|
1574
|
+
const res = await q.lean().exec();
|
|
1575
|
+
if (!res) return null;
|
|
1576
|
+
return deepNormalizeIds(res, true);
|
|
1577
|
+
}
|
|
1578
|
+
|
|
1579
|
+
/**
|
|
1580
|
+
* Count documents with the same default filter behavior (applicationId, status != Archived).
|
|
1581
|
+
*/
|
|
1582
|
+
async countDocumentsFiltered(filter: FilterQuery<T> = {}): Promise<number> {
|
|
1583
|
+
const finalFilter = this.applyDefaultFilters(filter);
|
|
1584
|
+
return this.model.countDocuments(finalFilter).exec();
|
|
1585
|
+
}
|
|
1586
|
+
insertMany(docs: any[], options?: any): Promise<any[]> {
|
|
1587
|
+
if (!Array.isArray(docs) || docs.length === 0) return Promise.resolve([]);
|
|
1588
|
+
|
|
1589
|
+
// auto-inject applicationId like create() does
|
|
1590
|
+
if (this.filters.applicationId && !this.filterOmitModels.includes(this.model.modelName)) {
|
|
1591
|
+
for (const d of docs) {
|
|
1592
|
+
if (d.applicationId === undefined) d.applicationId = this.filters.applicationId;
|
|
1593
|
+
}
|
|
1594
|
+
}
|
|
1595
|
+
|
|
1596
|
+
return (this.model as any).insertMany(docs, options);
|
|
1597
|
+
}
|
|
1598
|
+
/**
|
|
1599
|
+
* bulkWrite proxy:
|
|
1600
|
+
* - Applies default filters (applicationId scoping + status != Archived) to each op.filter
|
|
1601
|
+
* - Ensures applicationId is set on $setOnInsert for upserts (like create())
|
|
1602
|
+
*/
|
|
1603
|
+
bulkWrite(
|
|
1604
|
+
operations: any[], // you can type as AnyBulkWriteOperation<T>[] if you want
|
|
1605
|
+
options: any = {}
|
|
1606
|
+
): Promise<any> {
|
|
1607
|
+
const ops = (operations || []).map((op) => {
|
|
1608
|
+
// updateOne / updateMany
|
|
1609
|
+
if (op.updateOne?.filter) {
|
|
1610
|
+
op.updateOne.filter = this.applyDefaultFilters(op.updateOne.filter);
|
|
1611
|
+
|
|
1612
|
+
// ensure $setOnInsert.applicationId for upserts
|
|
1613
|
+
const upsert = !!op.updateOne.upsert;
|
|
1614
|
+
if (upsert && this.filters.applicationId && !this.filterOmitModels.includes(this.model.modelName)) {
|
|
1615
|
+
const u = op.updateOne.update || {};
|
|
1616
|
+
op.updateOne.update = u;
|
|
1617
|
+
|
|
1618
|
+
// If using aggregation pipeline updates, skip (hard to inject safely)
|
|
1619
|
+
if (!Array.isArray(u)) {
|
|
1620
|
+
u.$setOnInsert = u.$setOnInsert || {};
|
|
1621
|
+
if (u.$setOnInsert.applicationId === undefined) {
|
|
1622
|
+
u.$setOnInsert.applicationId = this.filters.applicationId;
|
|
1623
|
+
}
|
|
1624
|
+
}
|
|
1625
|
+
}
|
|
1626
|
+
|
|
1627
|
+
return op;
|
|
1628
|
+
}
|
|
1629
|
+
|
|
1630
|
+
if (op.updateMany?.filter) {
|
|
1631
|
+
op.updateMany.filter = this.applyDefaultFilters(op.updateMany.filter);
|
|
1632
|
+
return op;
|
|
1633
|
+
}
|
|
1634
|
+
|
|
1635
|
+
// deleteOne / deleteMany
|
|
1636
|
+
if (op.deleteOne?.filter) {
|
|
1637
|
+
op.deleteOne.filter = this.applyDefaultFilters(op.deleteOne.filter);
|
|
1638
|
+
return op;
|
|
1639
|
+
}
|
|
1640
|
+
if (op.deleteMany?.filter) {
|
|
1641
|
+
op.deleteMany.filter = this.applyDefaultFilters(op.deleteMany.filter);
|
|
1642
|
+
return op;
|
|
1643
|
+
}
|
|
1644
|
+
|
|
1645
|
+
// replaceOne (rare)
|
|
1646
|
+
if (op.replaceOne?.filter) {
|
|
1647
|
+
op.replaceOne.filter = this.applyDefaultFilters(op.replaceOne.filter);
|
|
1648
|
+
|
|
1649
|
+
const upsert = !!op.replaceOne.upsert;
|
|
1650
|
+
if (upsert && this.filters.applicationId && !this.filterOmitModels.includes(this.model.modelName)) {
|
|
1651
|
+
// replacement doc must include applicationId
|
|
1652
|
+
if (op.replaceOne.replacement && op.replaceOne.replacement.applicationId === undefined) {
|
|
1653
|
+
op.replaceOne.replacement.applicationId = this.filters.applicationId;
|
|
1654
|
+
}
|
|
1655
|
+
}
|
|
1656
|
+
|
|
1657
|
+
return op;
|
|
1658
|
+
}
|
|
1659
|
+
|
|
1660
|
+
// insertOne
|
|
1661
|
+
if (op.insertOne?.document) {
|
|
1662
|
+
if (this.filters.applicationId && !this.filterOmitModels.includes(this.model.modelName)) {
|
|
1663
|
+
if (op.insertOne.document.applicationId === undefined) {
|
|
1664
|
+
op.insertOne.document.applicationId = this.filters.applicationId;
|
|
1665
|
+
}
|
|
1666
|
+
}
|
|
1667
|
+
return op;
|
|
1668
|
+
}
|
|
1669
|
+
|
|
1670
|
+
return op;
|
|
1671
|
+
});
|
|
1672
|
+
|
|
1673
|
+
// Delegate to real mongoose model bulkWrite
|
|
1674
|
+
return (this.model as any).bulkWrite(ops, options);
|
|
1675
|
+
}
|
|
1676
|
+
}
|
|
1677
|
+
|
|
1678
|
+
// ---------------------------------------------------------------------------
|
|
1679
|
+
// Virtual helpers & proxies
|
|
1680
|
+
// ---------------------------------------------------------------------------
|
|
1681
|
+
|
|
1682
|
+
export const addTagVirtuals = (modelName: string) => [
|
|
1683
|
+
{
|
|
1684
|
+
name: 'tags',
|
|
1685
|
+
ref: 'Node',
|
|
1686
|
+
localField: '_id',
|
|
1687
|
+
foreignField: 'from',
|
|
1688
|
+
justOne: false,
|
|
1689
|
+
match: { relationKey: 'tag', fromModel: modelName },
|
|
1690
|
+
},
|
|
1691
|
+
];
|
|
1692
|
+
|
|
1693
|
+
export const addApplicationVirtual = () => [
|
|
1694
|
+
{
|
|
1695
|
+
name: 'application',
|
|
1696
|
+
ref: 'Application',
|
|
1697
|
+
localField: 'applicationId',
|
|
1698
|
+
foreignField: '_id',
|
|
1699
|
+
justOne: true,
|
|
1700
|
+
},
|
|
1701
|
+
];
|
|
1702
|
+
|
|
1703
|
+
export function createDocProxy<T extends Document>(doc: T, modelWrapper: Model<T>) {
|
|
1704
|
+
return new Proxy(doc as T & Record<string, any>, {
|
|
1705
|
+
get(target, prop, receiver) {
|
|
1706
|
+
// 1. If the property exists on the doc itself (fields, doc methods, etc.)
|
|
1707
|
+
if (prop in target) {
|
|
1708
|
+
return Reflect.get(target, prop, receiver);
|
|
1709
|
+
}
|
|
1710
|
+
|
|
1711
|
+
// 2. Otherwise, if the property is in your Model wrapper, return that
|
|
1712
|
+
if (prop in modelWrapper) {
|
|
1713
|
+
const val = Reflect.get(modelWrapper, prop, modelWrapper);
|
|
1714
|
+
// If it's a function (like saveQueued), bind it or adapt it
|
|
1715
|
+
if (typeof val === 'function') {
|
|
1716
|
+
// For example, if we want `proxyDoc.saveQueued()` to automatically
|
|
1717
|
+
// call modelWrapper.saveQueued(doc), we can do:
|
|
1718
|
+
if (prop === 'saveQueued') {
|
|
1719
|
+
return function (...args: any[]) {
|
|
1720
|
+
// automatically pass `doc` as the first arg
|
|
1721
|
+
// @ts-ignore
|
|
1722
|
+
return modelWrapper.saveQueued(doc, ...args);
|
|
1723
|
+
};
|
|
1724
|
+
}
|
|
1725
|
+
// Otherwise just bind the method normally
|
|
1726
|
+
return val.bind(modelWrapper);
|
|
1727
|
+
}
|
|
1728
|
+
return val;
|
|
1729
|
+
}
|
|
1730
|
+
|
|
1731
|
+
// 3. If not on doc or modelWrapper, return undefined
|
|
1732
|
+
return undefined;
|
|
1733
|
+
},
|
|
1734
|
+
});
|
|
1735
|
+
}
|