@airoom/nextmin-node 0.1.2 → 0.1.4
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 +18 -46
- package/dist/api/apiRouter.js +297 -6
- package/dist/database/MongoAdapter.js +12 -0
- package/dist/models/BaseModel.d.ts +1 -0
- package/dist/utils/SchemaLoader.d.ts +1 -0
- package/dist/utils/SchemaLoader.js +30 -6
- package/package.json +2 -2
package/LICENSE
CHANGED
|
@@ -1,49 +1,21 @@
|
|
|
1
|
-
|
|
1
|
+
MIT License
|
|
2
2
|
|
|
3
3
|
Copyright (c) 2025 GSCodes
|
|
4
|
-
All rights reserved.
|
|
5
4
|
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
4. Ownership
|
|
25
|
-
The Software is licensed, not sold. Licensor retains all right, title, and interest in and to the Software, including all intellectual property rights and all copies.
|
|
26
|
-
|
|
27
|
-
5. Confidentiality
|
|
28
|
-
The Software and any non-public information provided by Licensor shall be treated as confidential and not disclosed to any third party without Licensor’s prior written consent, except as required by law.
|
|
29
|
-
|
|
30
|
-
6. Term and Termination
|
|
31
|
-
This License is effective until terminated. Licensor may terminate this License immediately upon notice if Licensee breaches any term. Upon termination, Licensee must cease all use and destroy all copies of the Software. Sections 3–10 survive termination.
|
|
32
|
-
|
|
33
|
-
7. Updates; No Support Obligation
|
|
34
|
-
Licensor may, but is not obligated to, provide updates, bug fixes, or support. Any updates are governed by this License unless accompanied by a different license.
|
|
35
|
-
|
|
36
|
-
8. No Warranty
|
|
37
|
-
THE SOFTWARE IS PROVIDED “AS IS” AND “AS AVAILABLE,” WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, TITLE, AND NON-INFRINGEMENT. USE IS AT LICENSEE’S SOLE RISK.
|
|
38
|
-
|
|
39
|
-
9. Limitation of Liability
|
|
40
|
-
TO THE MAXIMUM EXTENT PERMITTED BY LAW, IN NO EVENT SHALL LICENSOR BE LIABLE FOR ANY INDIRECT, INCIDENTAL, SPECIAL, CONSEQUENTIAL, EXEMPLARY, OR PUNITIVE DAMAGES, OR FOR ANY LOSS OF PROFITS, REVENUE, DATA, GOODWILL, OR BUSINESS INTERRUPTION, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. LICENSOR’S TOTAL LIABILITY FOR ALL CLAIMS SHALL NOT EXCEED THE AMOUNT PAID BY LICENSEE FOR THE SOFTWARE (IF ANY) IN THE TWELVE (12) MONTHS PRECEDING THE CLAIM.
|
|
41
|
-
|
|
42
|
-
10. Governing Law; Venue
|
|
43
|
-
This License is governed by the laws of State of California, USA, without regard to its conflict of laws principles. The parties submit to the exclusive jurisdiction and venue of the courts located in San Francisco County, California, USA.
|
|
44
|
-
|
|
45
|
-
11. Commercial Licensing
|
|
46
|
-
For commercial use, redistribution, or other rights not expressly granted, contact: tareqaziz0065@gmail.com.
|
|
47
|
-
|
|
48
|
-
12. Entire Agreement
|
|
49
|
-
This License constitutes the entire agreement with respect to the Software and supersedes all prior or contemporaneous agreements or understandings on the subject matter.
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/dist/api/apiRouter.js
CHANGED
|
@@ -375,6 +375,77 @@ class APIRouter {
|
|
|
375
375
|
const salt = await bcrypt_1.default.genSalt(10);
|
|
376
376
|
payload.password = await bcrypt_1.default.hash(String(payload.password) + this.jwtSecret, salt);
|
|
377
377
|
}
|
|
378
|
+
// Extended schema handling: split into base + child
|
|
379
|
+
if (schema.extends) {
|
|
380
|
+
const baseName = String(schema.extends);
|
|
381
|
+
const baseLC = baseName.toLowerCase();
|
|
382
|
+
const baseSchema = this.getSchema(baseLC);
|
|
383
|
+
const baseModel = this.getModel(baseLC);
|
|
384
|
+
// Partition payload by base attributes
|
|
385
|
+
const basePayload = {};
|
|
386
|
+
const childPayload = {};
|
|
387
|
+
for (const [k, v] of Object.entries(payload)) {
|
|
388
|
+
if (baseSchema.attributes[k])
|
|
389
|
+
basePayload[k] = v;
|
|
390
|
+
else
|
|
391
|
+
childPayload[k] = v;
|
|
392
|
+
}
|
|
393
|
+
// Unique checks per collection
|
|
394
|
+
const baseConflicts = await this.checkUniqueFields(baseSchema, basePayload);
|
|
395
|
+
if (baseConflicts && baseConflicts.length > 0) {
|
|
396
|
+
return res.status(400).json({
|
|
397
|
+
error: true,
|
|
398
|
+
fields: baseConflicts.map((field) => ({
|
|
399
|
+
field,
|
|
400
|
+
error: true,
|
|
401
|
+
message: `You cannot use this ${field}. It's already been used.`,
|
|
402
|
+
})),
|
|
403
|
+
});
|
|
404
|
+
}
|
|
405
|
+
const childConflicts = await this.checkUniqueFields(schema, childPayload);
|
|
406
|
+
if (childConflicts && childConflicts.length > 0) {
|
|
407
|
+
return res.status(400).json({
|
|
408
|
+
error: true,
|
|
409
|
+
fields: childConflicts.map((field) => ({
|
|
410
|
+
field,
|
|
411
|
+
error: true,
|
|
412
|
+
message: `You cannot use this ${field}. It's already been used.`,
|
|
413
|
+
})),
|
|
414
|
+
});
|
|
415
|
+
}
|
|
416
|
+
// Hash password if base is Users
|
|
417
|
+
if (baseLC === 'users' && basePayload.password && this.jwtSecret) {
|
|
418
|
+
const salt = await bcrypt_1.default.genSalt(10);
|
|
419
|
+
basePayload.password = await bcrypt_1.default.hash(String(basePayload.password) + this.jwtSecret, salt);
|
|
420
|
+
}
|
|
421
|
+
// Create base first
|
|
422
|
+
const baseCreated = await baseModel.create(basePayload);
|
|
423
|
+
// Then create child linking to base
|
|
424
|
+
const linkField = 'baseId';
|
|
425
|
+
const childToCreate = { ...childPayload, [linkField]: baseCreated.id };
|
|
426
|
+
const childCreated = await model.create(childToCreate);
|
|
427
|
+
// Merge docs for response (child overrides base on conflicts)
|
|
428
|
+
let resultDoc = { ...baseCreated, ...childCreated };
|
|
429
|
+
delete resultDoc.baseId;
|
|
430
|
+
if (cdec.exposePrivate && childCreated?.id) {
|
|
431
|
+
// Re-fetch both with private if needed
|
|
432
|
+
const [refChild] = await model.read({ id: childCreated.id }, 1, 0, true);
|
|
433
|
+
const [refBase] = await baseModel.read({ id: baseCreated.id }, 1, 0, true);
|
|
434
|
+
if (refChild && refBase) {
|
|
435
|
+
resultDoc = { ...refBase, ...refChild };
|
|
436
|
+
delete resultDoc.baseId;
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
const masked = cdec.exposePrivate
|
|
440
|
+
? (0, authorize_1.applyReadMaskOne)(resultDoc, cdec.sensitiveMask)
|
|
441
|
+
: (0, authorize_1.applyReadMaskOne)(resultDoc, cdec.readMask);
|
|
442
|
+
return res.status(201).json({
|
|
443
|
+
success: true,
|
|
444
|
+
message: `${schema.modelName} has been created successfully.`,
|
|
445
|
+
data: masked,
|
|
446
|
+
});
|
|
447
|
+
}
|
|
448
|
+
// Default (non-extended)
|
|
378
449
|
const created = await model.create(payload);
|
|
379
450
|
let resultDoc = created;
|
|
380
451
|
if (cdec.exposePrivate && created?.id) {
|
|
@@ -577,11 +648,14 @@ class APIRouter {
|
|
|
577
648
|
const totalRows = await model.count(finalFilter);
|
|
578
649
|
// try DB-level sort (preferred). Fallback to in-memory if adapter lacks sort support.
|
|
579
650
|
let rawRows = [];
|
|
651
|
+
// Ensure we can hydrate: baseId is private, so read with private when extended
|
|
652
|
+
const needPrivateForHydrate = !!schema.extends;
|
|
653
|
+
const exposePrivateForRead = needPrivateForHydrate || !!rdec.exposePrivate;
|
|
580
654
|
try {
|
|
581
|
-
rawRows = await model.read(finalFilter, limit + 1, page * limit,
|
|
655
|
+
rawRows = await model.read(finalFilter, limit + 1, page * limit, exposePrivateForRead, { sort });
|
|
582
656
|
}
|
|
583
657
|
catch {
|
|
584
|
-
rawRows = await model.read(finalFilter, limit + 1, page * limit,
|
|
658
|
+
rawRows = await model.read(finalFilter, limit + 1, page * limit, exposePrivateForRead);
|
|
585
659
|
if (sort && Object.keys(sort).length) {
|
|
586
660
|
const orderKeys = Object.keys(sort);
|
|
587
661
|
rawRows.sort((a, b) => {
|
|
@@ -611,6 +685,29 @@ class APIRouter {
|
|
|
611
685
|
.filter((r) => String(r?.id) !== String(currentUserId))
|
|
612
686
|
.slice(0, limit)
|
|
613
687
|
: rawRows.slice(0, limit);
|
|
688
|
+
// If extended, hydrate with base data using baseId
|
|
689
|
+
if (schema.extends) {
|
|
690
|
+
const baseName = String(schema.extends);
|
|
691
|
+
const baseLC = baseName.toLowerCase();
|
|
692
|
+
const baseModel = this.getModel(baseLC);
|
|
693
|
+
const hydrated = [];
|
|
694
|
+
for (const row of rows) {
|
|
695
|
+
const baseId = row?.baseId;
|
|
696
|
+
if (baseId) {
|
|
697
|
+
const [baseDoc] = await baseModel.read({ id: baseId }, 1, 0, !!rdec.exposePrivate);
|
|
698
|
+
if (baseDoc) {
|
|
699
|
+
const merged = { ...baseDoc, ...row };
|
|
700
|
+
delete merged.baseId;
|
|
701
|
+
hydrated.push(merged);
|
|
702
|
+
continue;
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
const fallback = { ...row };
|
|
706
|
+
delete fallback.baseId;
|
|
707
|
+
hydrated.push(fallback);
|
|
708
|
+
}
|
|
709
|
+
rows = hydrated;
|
|
710
|
+
}
|
|
614
711
|
const data = rdec.exposePrivate
|
|
615
712
|
? (0, authorize_1.applyReadMaskMany)(rows, rdec.sensitiveMask)
|
|
616
713
|
: (0, authorize_1.applyReadMaskMany)(rows, rdec.readMask);
|
|
@@ -645,7 +742,10 @@ class APIRouter {
|
|
|
645
742
|
access: schema.access,
|
|
646
743
|
};
|
|
647
744
|
const decForInclude = (0, authorize_1.authorize)(modelNameLC, 'read', schemaPolicy, ctx);
|
|
648
|
-
|
|
745
|
+
// Ensure we can hydrate: baseId is private, so read with private when extended
|
|
746
|
+
const needPrivateForHydrate2 = !!schema.extends;
|
|
747
|
+
const exposePrivateForRead2 = needPrivateForHydrate2 || !!decForInclude.exposePrivate;
|
|
748
|
+
const recordArr = await model.read({ id: req.params.id }, 1, 0, exposePrivateForRead2);
|
|
649
749
|
const doc = recordArr?.[0];
|
|
650
750
|
if (!doc) {
|
|
651
751
|
return res
|
|
@@ -655,9 +755,23 @@ class APIRouter {
|
|
|
655
755
|
const dec = (0, authorize_1.authorize)(modelNameLC, 'read', schemaPolicy, ctx, doc);
|
|
656
756
|
if (!dec.allow)
|
|
657
757
|
return res.status(403).json({ error: true, message: 'forbidden' });
|
|
758
|
+
let toReturn = doc;
|
|
759
|
+
if (schema.extends) {
|
|
760
|
+
const baseName = String(schema.extends);
|
|
761
|
+
const baseLC = baseName.toLowerCase();
|
|
762
|
+
const baseModel = this.getModel(baseLC);
|
|
763
|
+
const baseId = doc?.baseId;
|
|
764
|
+
if (baseId) {
|
|
765
|
+
const [baseDoc] = await baseModel.read({ id: baseId }, 1, 0, !!dec.exposePrivate);
|
|
766
|
+
if (baseDoc) {
|
|
767
|
+
toReturn = { ...baseDoc, ...doc };
|
|
768
|
+
delete toReturn.baseId;
|
|
769
|
+
}
|
|
770
|
+
}
|
|
771
|
+
}
|
|
658
772
|
const data = dec.exposePrivate
|
|
659
|
-
? (0, authorize_1.applyReadMaskOne)(
|
|
660
|
-
: (0, authorize_1.applyReadMaskOne)(
|
|
773
|
+
? (0, authorize_1.applyReadMaskOne)(toReturn, dec.sensitiveMask)
|
|
774
|
+
: (0, authorize_1.applyReadMaskOne)(toReturn, dec.readMask);
|
|
661
775
|
return res.status(200).json({
|
|
662
776
|
success: true,
|
|
663
777
|
message: `${schema.modelName} found`,
|
|
@@ -720,6 +834,83 @@ class APIRouter {
|
|
|
720
834
|
message: `Missing required fields: ${missing.join(', ')}`,
|
|
721
835
|
});
|
|
722
836
|
}
|
|
837
|
+
// Extended schema update handling
|
|
838
|
+
if (schema.extends) {
|
|
839
|
+
const baseName = String(schema.extends);
|
|
840
|
+
const baseLC = baseName.toLowerCase();
|
|
841
|
+
const baseSchema = this.getSchema(baseLC);
|
|
842
|
+
const baseModel = this.getModel(baseLC);
|
|
843
|
+
const baseId = before?.baseId;
|
|
844
|
+
if (!baseId) {
|
|
845
|
+
return res.status(400).json({ error: true, message: 'Invalid extended record: missing baseId' });
|
|
846
|
+
}
|
|
847
|
+
// Partition update
|
|
848
|
+
const baseUpd = {};
|
|
849
|
+
const childUpd = {};
|
|
850
|
+
for (const [k, v] of Object.entries(upd)) {
|
|
851
|
+
if (k === 'baseId')
|
|
852
|
+
continue; // never allow changing link
|
|
853
|
+
if (baseSchema.attributes[k])
|
|
854
|
+
baseUpd[k] = v;
|
|
855
|
+
else
|
|
856
|
+
childUpd[k] = v;
|
|
857
|
+
}
|
|
858
|
+
// Unique checks per collection
|
|
859
|
+
const baseConflicts = await this.checkUniqueFields(baseSchema, baseUpd, String(baseId));
|
|
860
|
+
if (baseConflicts && baseConflicts.length > 0) {
|
|
861
|
+
return res.status(400).json({
|
|
862
|
+
error: true,
|
|
863
|
+
message: 'There are some error while updating record.',
|
|
864
|
+
fields: baseConflicts.map((field) => ({
|
|
865
|
+
field,
|
|
866
|
+
error: true,
|
|
867
|
+
message: `You cannot use this ${field}. It's already been used.`,
|
|
868
|
+
})),
|
|
869
|
+
});
|
|
870
|
+
}
|
|
871
|
+
const childConflicts = await this.checkUniqueFields(schema, childUpd, req.params.id);
|
|
872
|
+
if (childConflicts && childConflicts.length > 0) {
|
|
873
|
+
return res.status(400).json({
|
|
874
|
+
error: true,
|
|
875
|
+
message: 'There are some error while updating record.',
|
|
876
|
+
fields: childConflicts.map((field) => ({
|
|
877
|
+
field,
|
|
878
|
+
error: true,
|
|
879
|
+
message: `You cannot use this ${field}. It's already been used.`,
|
|
880
|
+
})),
|
|
881
|
+
});
|
|
882
|
+
}
|
|
883
|
+
// Hash password if base is Users and provided
|
|
884
|
+
if (baseLC === 'users' &&
|
|
885
|
+
Object.prototype.hasOwnProperty.call(baseUpd, 'password')) {
|
|
886
|
+
if (!baseUpd.password) {
|
|
887
|
+
delete baseUpd.password;
|
|
888
|
+
}
|
|
889
|
+
else if (this.jwtSecret) {
|
|
890
|
+
const salt = await bcrypt_1.default.genSalt(10);
|
|
891
|
+
baseUpd.password = await bcrypt_1.default.hash(String(baseUpd.password) + this.jwtSecret, salt);
|
|
892
|
+
}
|
|
893
|
+
}
|
|
894
|
+
// Perform updates
|
|
895
|
+
let updatedChild = before;
|
|
896
|
+
if (Object.keys(childUpd).length) {
|
|
897
|
+
updatedChild = await model.update(req.params.id, childUpd);
|
|
898
|
+
}
|
|
899
|
+
if (Object.keys(baseUpd).length) {
|
|
900
|
+
await baseModel.update(String(baseId), baseUpd);
|
|
901
|
+
}
|
|
902
|
+
// Re-read and merge
|
|
903
|
+
const [refChild] = await model.read({ id: updatedChild.id }, 1, 0, true);
|
|
904
|
+
const [refBase] = await baseModel.read({ id: String(baseId) }, 1, 0, true);
|
|
905
|
+
let responseDoc = refChild && refBase ? { ...refBase, ...refChild } : updatedChild;
|
|
906
|
+
if (responseDoc)
|
|
907
|
+
delete responseDoc.baseId;
|
|
908
|
+
const masked = udec.exposePrivate
|
|
909
|
+
? (0, authorize_1.applyReadMaskOne)(responseDoc, udec.sensitiveMask)
|
|
910
|
+
: (0, authorize_1.applyReadMaskOne)(responseDoc, udec.readMask);
|
|
911
|
+
return res.json(masked);
|
|
912
|
+
}
|
|
913
|
+
// Default (non-extended)
|
|
723
914
|
const conflicts = await this.checkUniqueFields(schema, upd, req.params.id);
|
|
724
915
|
if (conflicts && conflicts.length > 0) {
|
|
725
916
|
return res.status(400).json({
|
|
@@ -773,6 +964,36 @@ class APIRouter {
|
|
|
773
964
|
const ddec = (0, authorize_1.authorize)(modelNameLC, 'delete', schemaPolicy, ctx, doc);
|
|
774
965
|
if (!ddec.allow)
|
|
775
966
|
return res.status(403).json({ error: true, message: 'forbidden' });
|
|
967
|
+
// Extended delete: remove child then base
|
|
968
|
+
if (schema.extends) {
|
|
969
|
+
const baseName = String(schema.extends);
|
|
970
|
+
const baseLC = baseName.toLowerCase();
|
|
971
|
+
const baseModel = this.getModel(baseLC);
|
|
972
|
+
const baseId = doc?.baseId;
|
|
973
|
+
const deletedChild = await model.delete(req.params.id);
|
|
974
|
+
if (baseId) {
|
|
975
|
+
try {
|
|
976
|
+
await baseModel.delete(String(baseId));
|
|
977
|
+
}
|
|
978
|
+
catch (e) {
|
|
979
|
+
// ignore if already missing
|
|
980
|
+
}
|
|
981
|
+
}
|
|
982
|
+
const merged = baseId
|
|
983
|
+
? (() => {
|
|
984
|
+
// Try to include base snapshot for response if available
|
|
985
|
+
return { ...deletedChild, baseId: undefined };
|
|
986
|
+
})()
|
|
987
|
+
: deletedChild;
|
|
988
|
+
const masked = ddec.exposePrivate
|
|
989
|
+
? (0, authorize_1.applyReadMaskOne)(merged, ddec.sensitiveMask)
|
|
990
|
+
: (0, authorize_1.applyReadMaskOne)(merged, ddec.readMask);
|
|
991
|
+
return res.json({
|
|
992
|
+
success: true,
|
|
993
|
+
message: `We have deleted the record successfully.`,
|
|
994
|
+
data: masked,
|
|
995
|
+
});
|
|
996
|
+
}
|
|
776
997
|
const deletedRecord = await model.delete(req.params.id);
|
|
777
998
|
if (!deletedRecord) {
|
|
778
999
|
return res
|
|
@@ -975,6 +1196,31 @@ class APIRouter {
|
|
|
975
1196
|
// fallback (BaseModel.read); note: no projection/sort
|
|
976
1197
|
items = await this.getModel(targetLC).read({ id: { $in: ids } }, limit, skip, !!tDec.exposePrivate);
|
|
977
1198
|
}
|
|
1199
|
+
// If extended, hydrate with base data using baseId before masking
|
|
1200
|
+
if (targetSchema.extends && Array.isArray(items) && items.length) {
|
|
1201
|
+
const baseName = String(targetSchema.extends);
|
|
1202
|
+
const baseLC = baseName.toLowerCase();
|
|
1203
|
+
const baseModel = this.getModel(baseLC);
|
|
1204
|
+
const baseIds = Array.from(new Set(items.map((it) => it?.baseId).filter(Boolean)));
|
|
1205
|
+
if (baseIds.length) {
|
|
1206
|
+
const baseDocs = await baseModel.read({ id: { $in: baseIds } }, baseIds.length, 0, !!tDec.exposePrivate);
|
|
1207
|
+
const baseMap = new Map(baseDocs.map((b) => [String(b?.id ?? b?._id), b]));
|
|
1208
|
+
items = items.map((it) => {
|
|
1209
|
+
const bid = String(it?.baseId || '');
|
|
1210
|
+
const b = bid ? baseMap.get(bid) : null;
|
|
1211
|
+
const merged = b ? { ...b, ...it } : { ...it };
|
|
1212
|
+
delete merged.baseId;
|
|
1213
|
+
return merged;
|
|
1214
|
+
});
|
|
1215
|
+
}
|
|
1216
|
+
else {
|
|
1217
|
+
items = items.map((it) => {
|
|
1218
|
+
const copy = { ...it };
|
|
1219
|
+
delete copy.baseId;
|
|
1220
|
+
return copy;
|
|
1221
|
+
});
|
|
1222
|
+
}
|
|
1223
|
+
}
|
|
978
1224
|
const masked = tDec.exposePrivate
|
|
979
1225
|
? (0, authorize_1.applyReadMaskMany)(items, tDec.sensitiveMask)
|
|
980
1226
|
: (0, authorize_1.applyReadMaskMany)(items, tDec.readMask);
|
|
@@ -1050,6 +1296,31 @@ class APIRouter {
|
|
|
1050
1296
|
totalRows = all.length;
|
|
1051
1297
|
items = all.slice(skip, skip + limit);
|
|
1052
1298
|
}
|
|
1299
|
+
// If extended, hydrate with base data using baseId before masking
|
|
1300
|
+
if (targetSchema.extends && Array.isArray(items) && items.length) {
|
|
1301
|
+
const baseName = String(targetSchema.extends);
|
|
1302
|
+
const baseLC = baseName.toLowerCase();
|
|
1303
|
+
const baseModel = this.getModel(baseLC);
|
|
1304
|
+
const baseIds = Array.from(new Set(items.map((it) => it?.baseId).filter(Boolean)));
|
|
1305
|
+
if (baseIds.length) {
|
|
1306
|
+
const baseDocs = await baseModel.read({ id: { $in: baseIds } }, baseIds.length, 0, !!tDec.exposePrivate);
|
|
1307
|
+
const baseMap = new Map(baseDocs.map((b) => [String(b?.id ?? b?._id), b]));
|
|
1308
|
+
items = items.map((it) => {
|
|
1309
|
+
const bid = String(it?.baseId || '');
|
|
1310
|
+
const b = bid ? baseMap.get(bid) : null;
|
|
1311
|
+
const merged = b ? { ...b, ...it } : { ...it };
|
|
1312
|
+
delete merged.baseId;
|
|
1313
|
+
return merged;
|
|
1314
|
+
});
|
|
1315
|
+
}
|
|
1316
|
+
else {
|
|
1317
|
+
items = items.map((it) => {
|
|
1318
|
+
const copy = { ...it };
|
|
1319
|
+
delete copy.baseId;
|
|
1320
|
+
return copy;
|
|
1321
|
+
});
|
|
1322
|
+
}
|
|
1323
|
+
}
|
|
1053
1324
|
const masked = tDec.exposePrivate
|
|
1054
1325
|
? (0, authorize_1.applyReadMaskMany)(items, tDec.sensitiveMask)
|
|
1055
1326
|
: (0, authorize_1.applyReadMaskMany)(items, tDec.readMask);
|
|
@@ -1467,8 +1738,28 @@ class APIRouter {
|
|
|
1467
1738
|
const isEmpty = (v) => v === undefined ||
|
|
1468
1739
|
v === null ||
|
|
1469
1740
|
(typeof v === 'string' && v.trim() === '');
|
|
1741
|
+
// If this is an extended schema, skip validating base fields here
|
|
1742
|
+
let baseKeys = null;
|
|
1743
|
+
const baseName = schema?.extends;
|
|
1744
|
+
if (baseName) {
|
|
1745
|
+
try {
|
|
1746
|
+
const baseLC = baseName.toLowerCase();
|
|
1747
|
+
const baseSchema = this.getSchema(baseLC);
|
|
1748
|
+
baseKeys = new Set(Object.keys(baseSchema.attributes || {}));
|
|
1749
|
+
}
|
|
1750
|
+
catch {
|
|
1751
|
+
baseKeys = null;
|
|
1752
|
+
}
|
|
1753
|
+
}
|
|
1470
1754
|
for (const [key, attribute] of Object.entries(schema.attributes)) {
|
|
1471
|
-
|
|
1755
|
+
// Skip array defs here (arrays wrap inner type), and skip private fields entirely from client-required validation
|
|
1756
|
+
if (Array.isArray(attribute))
|
|
1757
|
+
continue;
|
|
1758
|
+
if (attribute?.private)
|
|
1759
|
+
continue;
|
|
1760
|
+
if (baseKeys && key !== 'baseId' && baseKeys.has(key))
|
|
1761
|
+
continue; // do not require base fields on child
|
|
1762
|
+
const req = Boolean(attribute?.required);
|
|
1472
1763
|
if (!req)
|
|
1473
1764
|
continue;
|
|
1474
1765
|
if (mode === 'create') {
|
|
@@ -136,7 +136,19 @@ class MongoAdapter {
|
|
|
136
136
|
/** Build a fresh Mongoose schema from our NextMin schema definition. */
|
|
137
137
|
buildMongooseSchema(def) {
|
|
138
138
|
const shape = {};
|
|
139
|
+
// If this schema extends a base, only include child-own attributes for storage
|
|
140
|
+
let baseKeys = null;
|
|
141
|
+
const baseName = def?.extends;
|
|
142
|
+
if (baseName) {
|
|
143
|
+
const baseSchema = this.getSchema(baseName);
|
|
144
|
+
if (baseSchema) {
|
|
145
|
+
baseKeys = new Set(Object.keys(baseSchema.attributes || {}));
|
|
146
|
+
}
|
|
147
|
+
}
|
|
139
148
|
for (const [key, attr] of Object.entries(def.attributes)) {
|
|
149
|
+
// Exclude base attributes from child storage schema; always allow link field
|
|
150
|
+
if (baseKeys && key !== 'baseId' && baseKeys.has(key))
|
|
151
|
+
continue;
|
|
140
152
|
shape[key] = this.mapAttribute(attr);
|
|
141
153
|
}
|
|
142
154
|
const s = new mongoose_1.Schema(shape, { timestamps: true });
|
|
@@ -3,6 +3,7 @@ import { FieldIndexSpec } from '../database/DatabaseAdapter';
|
|
|
3
3
|
type PublicAttributes = Record<string, any>;
|
|
4
4
|
type PublicSchema = Omit<Schema, 'attributes'> & {
|
|
5
5
|
modelName: string;
|
|
6
|
+
showCount: boolean;
|
|
6
7
|
attributes: PublicAttributes;
|
|
7
8
|
allowedMethods: Schema['allowedMethods'];
|
|
8
9
|
};
|
|
@@ -125,8 +125,10 @@ class SchemaLoader {
|
|
|
125
125
|
}
|
|
126
126
|
for (const schema of Object.values(schemas)) {
|
|
127
127
|
if (schema.extends) {
|
|
128
|
-
const
|
|
128
|
+
const baseName = schema.extends;
|
|
129
|
+
const baseSchema = schemas[baseName];
|
|
129
130
|
if (baseSchema) {
|
|
131
|
+
// Merge attributes and methods for READ surface
|
|
130
132
|
schema.attributes = {
|
|
131
133
|
...baseSchema.attributes,
|
|
132
134
|
...schema.attributes,
|
|
@@ -135,6 +137,17 @@ class SchemaLoader {
|
|
|
135
137
|
...baseSchema.allowedMethods,
|
|
136
138
|
...schema.allowedMethods,
|
|
137
139
|
};
|
|
140
|
+
// Inject hidden link field to base (used for storage join)
|
|
141
|
+
const linkField = 'baseId';
|
|
142
|
+
if (!schema.attributes[linkField]) {
|
|
143
|
+
schema.attributes[linkField] = {
|
|
144
|
+
type: 'ObjectId',
|
|
145
|
+
ref: baseName,
|
|
146
|
+
private: true,
|
|
147
|
+
// Not required on create; server auto-fills for extended creates.
|
|
148
|
+
// Presence is enforced for existing docs on update.
|
|
149
|
+
};
|
|
150
|
+
}
|
|
138
151
|
}
|
|
139
152
|
else {
|
|
140
153
|
throw new Error(`Base schema ${schema.extends} not found for ${schema.modelName}`);
|
|
@@ -237,6 +250,7 @@ class SchemaLoader {
|
|
|
237
250
|
};
|
|
238
251
|
out[name] = {
|
|
239
252
|
modelName: s.modelName,
|
|
253
|
+
showCount: s.showCount,
|
|
240
254
|
allowedMethods: s.allowedMethods,
|
|
241
255
|
attributes: attributesWithTimestamps,
|
|
242
256
|
};
|
|
@@ -261,19 +275,29 @@ class SchemaLoader {
|
|
|
261
275
|
if (Array.isArray(attr)) {
|
|
262
276
|
const elem = attr[0];
|
|
263
277
|
if (elem && typeof elem === 'object') {
|
|
264
|
-
//
|
|
265
|
-
|
|
278
|
+
// If the inner descriptor is private, omit this field entirely from public schema
|
|
279
|
+
if (elem.private) {
|
|
280
|
+
continue;
|
|
281
|
+
}
|
|
282
|
+
// Keep as an array with a single shallow-cloned descriptor (without leaking private flag)
|
|
283
|
+
const { private: _omit, ...rest } = elem;
|
|
284
|
+
out[key] = [{ ...rest }];
|
|
266
285
|
}
|
|
267
286
|
else {
|
|
268
|
-
// Fallback: keep as-is
|
|
287
|
+
// Fallback: keep as-is (no private flag to check)
|
|
269
288
|
out[key] = attr;
|
|
270
289
|
}
|
|
271
290
|
continue;
|
|
272
291
|
}
|
|
273
292
|
// Single attribute object
|
|
274
293
|
if (attr && typeof attr === 'object') {
|
|
275
|
-
//
|
|
276
|
-
|
|
294
|
+
// If marked private, omit from public schema entirely
|
|
295
|
+
if (attr.private) {
|
|
296
|
+
continue;
|
|
297
|
+
}
|
|
298
|
+
// Shallow clone and drop the private flag if present
|
|
299
|
+
const { private: _omit, ...rest } = attr;
|
|
300
|
+
out[key] = { ...rest };
|
|
277
301
|
continue;
|
|
278
302
|
}
|
|
279
303
|
// Unexpected primitives — pass through
|