@elek-io/core 0.16.2 → 0.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -24,6 +24,7 @@ import PQueue from "p-queue";
24
24
  import { createLogger, format, transports } from "winston";
25
25
  import DailyRotateFile from "winston-daily-rotate-file";
26
26
  import Semver from "semver";
27
+ import { isDeepStrictEqual } from "node:util";
27
28
 
28
29
  //#region \0rolldown/runtime.js
29
30
  var __defProp = Object.defineProperty;
@@ -45,7 +46,7 @@ var __exportAll = (all, no_symbols) => {
45
46
  //#region package.json
46
47
  var package_default = {
47
48
  name: "@elek-io/core",
48
- version: "0.16.2",
49
+ version: "0.17.0",
49
50
  description: "Handles core functionality of elek.io Projects like file IO and version control.",
50
51
  homepage: "https://elek.io",
51
52
  repository: "https://github.com/elek-io/core",
@@ -165,7 +166,7 @@ async function exportProjectNested({ projectToExport }) {
165
166
  };
166
167
  collectionContent = {
167
168
  ...collectionContent,
168
- [collection.id]: {
169
+ [collection.slug.plural]: {
169
170
  ...collection,
170
171
  entries: entryContent
171
172
  }
@@ -618,11 +619,7 @@ const supportedLanguageSchema = z.enum([
618
619
  "sv",
619
620
  "zh"
620
621
  ]);
621
- const supportedIconSchema = z.enum([
622
- "home",
623
- "plus",
624
- "foobar"
625
- ]);
622
+ const supportedIconSchema = z.enum(["home", "plus"]);
626
623
  const objectTypeSchema = z.enum([
627
624
  "project",
628
625
  "asset",
@@ -637,7 +634,7 @@ const logLevelSchema = z.enum([
637
634
  "info",
638
635
  "debug"
639
636
  ]);
640
- const versionSchema = z.string();
637
+ const versionSchema = z.string().refine((version) => /^\d+\.\d+\.\d+(?:-[\w.]+)?(?:\+[\w.]+)?$/.test(version), "String must follow the Semantic Versioning format (https://semver.org/)");
641
638
  const uuidSchema = z.uuid();
642
639
  /**
643
640
  * A record that can be used to translate a string value into all supported languages
@@ -654,6 +651,41 @@ const translatableBooleanSchema = z.partialRecord(supportedLanguageSchema, z.boo
654
651
  function translatableArrayOf(schema) {
655
652
  return z.partialRecord(supportedLanguageSchema, z.array(schema));
656
653
  }
654
+ const reservedSlugs = new Set([
655
+ "index",
656
+ "new",
657
+ "create",
658
+ "update",
659
+ "delete",
660
+ "edit",
661
+ "list",
662
+ "count",
663
+ "api",
664
+ "admin",
665
+ "collection",
666
+ "collections",
667
+ "entry",
668
+ "entries",
669
+ "asset",
670
+ "assets",
671
+ "project",
672
+ "projects",
673
+ "null",
674
+ "undefined",
675
+ "true",
676
+ "false",
677
+ "constructor",
678
+ "__proto__",
679
+ "prototype",
680
+ "toString",
681
+ "valueOf",
682
+ "login",
683
+ "logout",
684
+ "auth",
685
+ "settings",
686
+ "config"
687
+ ]);
688
+ const slugSchema = z.string().min(1).max(128).regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/).refine((slug) => !reservedSlugs.has(slug), { message: "This slug is reserved and cannot be used" });
657
689
 
658
690
  //#endregion
659
691
  //#region src/schema/fileSchema.ts
@@ -663,91 +695,36 @@ function translatableArrayOf(schema) {
663
695
  const baseFileSchema = z.object({
664
696
  objectType: objectTypeSchema.readonly(),
665
697
  id: uuidSchema.readonly(),
698
+ coreVersion: versionSchema.readonly(),
666
699
  created: z.string().datetime().readonly(),
667
- updated: z.string().datetime().nullable()
700
+ updated: z.string().datetime().nullable().readonly()
668
701
  });
669
702
  const fileReferenceSchema = z.object({
670
703
  id: uuidSchema,
671
704
  extension: z.string().optional()
672
705
  });
673
-
674
- //#endregion
675
- //#region src/schema/gitSchema.ts
676
706
  /**
677
- * Signature git uses to identify users
707
+ * Schema for the collection index file (collections/index.json).
708
+ * Maps collection UUIDs to their slug.plural values.
709
+ * This is a local performance cache, not git-tracked.
678
710
  */
679
- const gitSignatureSchema = z.object({
680
- name: z.string(),
681
- email: z.string().email()
682
- });
683
- const gitMessageSchema = z.object({
684
- method: z.enum([
685
- "create",
686
- "update",
687
- "delete",
688
- "upgrade"
689
- ]),
690
- reference: z.object({
691
- objectType: objectTypeSchema,
692
- id: uuidSchema,
693
- collectionId: uuidSchema.optional()
694
- })
695
- });
696
- const gitTagSchema = z.object({
697
- id: uuidSchema,
698
- message: z.string(),
699
- author: gitSignatureSchema,
700
- datetime: z.string().datetime()
701
- });
702
- const gitCommitSchema = z.object({
703
- hash: z.string(),
704
- message: gitMessageSchema,
705
- author: gitSignatureSchema,
706
- datetime: z.string().datetime(),
707
- tag: gitTagSchema.nullable()
708
- });
709
- const gitInitOptionsSchema = z.object({ initialBranch: z.string() });
710
- const gitCloneOptionsSchema = z.object({
711
- depth: z.number(),
712
- singleBranch: z.boolean(),
713
- branch: z.string(),
714
- bare: z.boolean()
715
- });
716
- const gitMergeOptionsSchema = z.object({ squash: z.boolean() });
717
- const gitSwitchOptionsSchema = z.object({ isNew: z.boolean().optional() });
718
- const gitLogOptionsSchema = z.object({
719
- limit: z.number().optional(),
720
- between: z.object({
721
- from: z.string(),
722
- to: z.string().optional()
723
- }),
724
- filePath: z.string().optional()
725
- });
726
- const createGitTagSchema = gitTagSchema.pick({ message: true }).extend({
727
- path: z.string(),
728
- hash: z.string().optional()
729
- });
730
- const readGitTagSchema = z.object({
731
- path: z.string(),
732
- id: uuidSchema.readonly()
733
- });
734
- const deleteGitTagSchema = readGitTagSchema.extend({});
735
- const countGitTagsSchema = z.object({ path: z.string() });
711
+ const collectionIndexSchema = z.record(uuidSchema, z.string());
736
712
 
737
713
  //#endregion
738
714
  //#region src/schema/assetSchema.ts
739
715
  const assetFileSchema = baseFileSchema.extend({
740
716
  objectType: z.literal(objectTypeSchema.enum.asset).readonly(),
741
- name: z.string(),
742
- description: z.string(),
717
+ name: z.string().trim().min(1),
718
+ description: z.string().trim().min(1),
743
719
  extension: z.string().readonly(),
744
720
  mimeType: z.string().readonly(),
745
721
  size: z.number().readonly()
746
722
  });
747
- const assetSchema = assetFileSchema.extend({
748
- absolutePath: z.string().readonly(),
749
- history: z.array(gitCommitSchema)
750
- }).openapi("Asset");
723
+ const assetSchema = assetFileSchema.extend({ absolutePath: z.string().readonly() }).openapi("Asset");
724
+ const assetHistorySchema = z.object({
725
+ id: uuidSchema.readonly(),
726
+ projectId: uuidSchema.readonly()
727
+ });
751
728
  const assetExportSchema = assetSchema.extend({});
752
729
  const createAssetSchema = assetFileSchema.pick({
753
730
  name: true,
@@ -777,11 +754,15 @@ const deleteAssetSchema = assetFileSchema.pick({
777
754
  id: true,
778
755
  extension: true
779
756
  }).extend({ projectId: uuidSchema.readonly() });
757
+ const migrateAssetSchema = z.looseObject(assetFileSchema.pick({
758
+ id: true,
759
+ coreVersion: true
760
+ }).shape);
780
761
  const countAssetsSchema = z.object({ projectId: uuidSchema.readonly() });
781
762
 
782
763
  //#endregion
783
764
  //#region src/schema/valueSchema.ts
784
- const ValueTypeSchema = z.enum([
765
+ const valueTypeSchema = z.enum([
785
766
  "string",
786
767
  "number",
787
768
  "boolean",
@@ -796,20 +777,17 @@ const valueContentReferenceSchema = z.union([
796
777
  valueContentReferenceToCollectionSchema,
797
778
  valueContentReferenceToEntrySchema
798
779
  ]);
799
- const directValueBaseSchema = z.object({
800
- objectType: z.literal(objectTypeSchema.enum.value).readonly(),
801
- fieldDefinitionId: uuidSchema.readonly()
802
- });
780
+ const directValueBaseSchema = z.object({ objectType: z.literal(objectTypeSchema.enum.value).readonly() });
803
781
  const directStringValueSchema = directValueBaseSchema.extend({
804
- valueType: z.literal(ValueTypeSchema.enum.string).readonly(),
782
+ valueType: z.literal(valueTypeSchema.enum.string).readonly(),
805
783
  content: translatableStringSchema
806
784
  });
807
785
  const directNumberValueSchema = directValueBaseSchema.extend({
808
- valueType: z.literal(ValueTypeSchema.enum.number).readonly(),
786
+ valueType: z.literal(valueTypeSchema.enum.number).readonly(),
809
787
  content: translatableNumberSchema
810
788
  });
811
789
  const directBooleanValueSchema = directValueBaseSchema.extend({
812
- valueType: z.literal(ValueTypeSchema.enum.boolean).readonly(),
790
+ valueType: z.literal(valueTypeSchema.enum.boolean).readonly(),
813
791
  content: translatableBooleanSchema
814
792
  });
815
793
  const directValueSchema = z.union([
@@ -819,8 +797,7 @@ const directValueSchema = z.union([
819
797
  ]);
820
798
  const referencedValueSchema = z.object({
821
799
  objectType: z.literal(objectTypeSchema.enum.value).readonly(),
822
- fieldDefinitionId: uuidSchema.readonly(),
823
- valueType: z.literal(ValueTypeSchema.enum.reference).readonly(),
800
+ valueType: z.literal(valueTypeSchema.enum.reference).readonly(),
824
801
  content: translatableArrayOf(valueContentReferenceSchema)
825
802
  });
826
803
  const valueSchema = z.union([directValueSchema, referencedValueSchema]);
@@ -835,19 +812,25 @@ const valueSchema = z.union([directValueSchema, referencedValueSchema]);
835
812
  //#region src/schema/entrySchema.ts
836
813
  const entryFileSchema = baseFileSchema.extend({
837
814
  objectType: z.literal(objectTypeSchema.enum.entry).readonly(),
838
- values: z.array(valueSchema)
815
+ values: z.record(slugSchema, valueSchema)
816
+ });
817
+ const entrySchema = entryFileSchema.openapi("Entry");
818
+ const entryHistorySchema = z.object({
819
+ id: uuidSchema.readonly(),
820
+ projectId: uuidSchema.readonly(),
821
+ collectionId: uuidSchema.readonly()
839
822
  });
840
- const entrySchema = entryFileSchema.extend({ history: z.array(gitCommitSchema) }).openapi("Entry");
841
823
  const entryExportSchema = entrySchema.extend({});
842
824
  const createEntrySchema = entryFileSchema.omit({
843
825
  id: true,
844
826
  objectType: true,
827
+ coreVersion: true,
845
828
  created: true,
846
829
  updated: true
847
830
  }).extend({
848
831
  projectId: uuidSchema.readonly(),
849
832
  collectionId: uuidSchema.readonly(),
850
- values: z.array(valueSchema)
833
+ values: z.record(slugSchema, valueSchema)
851
834
  });
852
835
  const readEntrySchema = z.object({
853
836
  id: uuidSchema.readonly(),
@@ -857,6 +840,7 @@ const readEntrySchema = z.object({
857
840
  });
858
841
  const updateEntrySchema = entryFileSchema.omit({
859
842
  objectType: true,
843
+ coreVersion: true,
860
844
  created: true,
861
845
  updated: true
862
846
  }).extend({
@@ -864,6 +848,10 @@ const updateEntrySchema = entryFileSchema.omit({
864
848
  collectionId: uuidSchema.readonly()
865
849
  });
866
850
  const deleteEntrySchema = readEntrySchema.extend({});
851
+ const migrateEntrySchema = z.looseObject(entryFileSchema.pick({
852
+ id: true,
853
+ coreVersion: true
854
+ }).shape);
867
855
  const countEntriesSchema = z.object({
868
856
  projectId: uuidSchema.readonly(),
869
857
  collectionId: uuidSchema.readonly()
@@ -871,7 +859,7 @@ const countEntriesSchema = z.object({
871
859
 
872
860
  //#endregion
873
861
  //#region src/schema/fieldSchema.ts
874
- const FieldTypeSchema = z.enum([
862
+ const fieldTypeSchema = z.enum([
875
863
  "text",
876
864
  "textarea",
877
865
  "email",
@@ -887,64 +875,65 @@ const FieldTypeSchema = z.enum([
887
875
  "asset",
888
876
  "entry"
889
877
  ]);
890
- const FieldWidthSchema = z.enum([
878
+ const fieldWidthSchema = z.enum([
891
879
  "12",
892
880
  "6",
893
881
  "4",
894
882
  "3"
895
883
  ]);
896
- const FieldDefinitionBaseSchema = z.object({
884
+ const fieldDefinitionBaseSchema = z.object({
897
885
  id: uuidSchema.readonly(),
886
+ slug: slugSchema,
898
887
  label: translatableStringSchema,
899
888
  description: translatableStringSchema.nullable(),
900
889
  isRequired: z.boolean(),
901
890
  isDisabled: z.boolean(),
902
891
  isUnique: z.boolean(),
903
- inputWidth: FieldWidthSchema
892
+ inputWidth: fieldWidthSchema
904
893
  });
905
894
  /**
906
895
  * String based Field definitions
907
896
  */
908
- const StringFieldDefinitionBaseSchema = FieldDefinitionBaseSchema.extend({
909
- valueType: z.literal(ValueTypeSchema.enum.string),
897
+ const stringFieldDefinitionBaseSchema = fieldDefinitionBaseSchema.extend({
898
+ valueType: z.literal(valueTypeSchema.enum.string),
910
899
  defaultValue: z.string().nullable()
911
900
  });
912
- const textFieldDefinitionSchema = StringFieldDefinitionBaseSchema.extend({
913
- fieldType: z.literal(FieldTypeSchema.enum.text),
901
+ const textFieldDefinitionSchema = stringFieldDefinitionBaseSchema.extend({
902
+ fieldType: z.literal(fieldTypeSchema.enum.text),
914
903
  min: z.number().nullable(),
915
904
  max: z.number().nullable()
916
905
  });
917
- const textareaFieldDefinitionSchema = StringFieldDefinitionBaseSchema.extend({
918
- fieldType: z.literal(FieldTypeSchema.enum.textarea),
906
+ const textareaFieldDefinitionSchema = stringFieldDefinitionBaseSchema.extend({
907
+ fieldType: z.literal(fieldTypeSchema.enum.textarea),
919
908
  min: z.number().nullable(),
920
909
  max: z.number().nullable()
921
910
  });
922
- const emailFieldDefinitionSchema = StringFieldDefinitionBaseSchema.extend({
923
- fieldType: z.literal(FieldTypeSchema.enum.email),
911
+ const emailFieldDefinitionSchema = stringFieldDefinitionBaseSchema.extend({
912
+ fieldType: z.literal(fieldTypeSchema.enum.email),
924
913
  defaultValue: z.email().nullable()
925
914
  });
926
- const urlFieldDefinitionSchema = StringFieldDefinitionBaseSchema.extend({
927
- fieldType: z.literal(FieldTypeSchema.enum.url),
915
+ const urlFieldDefinitionSchema = stringFieldDefinitionBaseSchema.extend({
916
+ fieldType: z.literal(fieldTypeSchema.enum.url),
928
917
  defaultValue: z.url().nullable()
929
918
  });
930
- const ipv4FieldDefinitionSchema = StringFieldDefinitionBaseSchema.extend({
931
- fieldType: z.literal(FieldTypeSchema.enum.ipv4),
919
+ const ipv4FieldDefinitionSchema = stringFieldDefinitionBaseSchema.extend({
920
+ fieldType: z.literal(fieldTypeSchema.enum.ipv4),
932
921
  defaultValue: z.ipv4().nullable()
933
922
  });
934
- const dateFieldDefinitionSchema = StringFieldDefinitionBaseSchema.extend({
935
- fieldType: z.literal(FieldTypeSchema.enum.date),
923
+ const dateFieldDefinitionSchema = stringFieldDefinitionBaseSchema.extend({
924
+ fieldType: z.literal(fieldTypeSchema.enum.date),
936
925
  defaultValue: z.iso.date().nullable()
937
926
  });
938
- const timeFieldDefinitionSchema = StringFieldDefinitionBaseSchema.extend({
939
- fieldType: z.literal(FieldTypeSchema.enum.time),
927
+ const timeFieldDefinitionSchema = stringFieldDefinitionBaseSchema.extend({
928
+ fieldType: z.literal(fieldTypeSchema.enum.time),
940
929
  defaultValue: z.iso.time().nullable()
941
930
  });
942
- const datetimeFieldDefinitionSchema = StringFieldDefinitionBaseSchema.extend({
943
- fieldType: z.literal(FieldTypeSchema.enum.datetime),
931
+ const datetimeFieldDefinitionSchema = stringFieldDefinitionBaseSchema.extend({
932
+ fieldType: z.literal(fieldTypeSchema.enum.datetime),
944
933
  defaultValue: z.iso.datetime().nullable()
945
934
  });
946
- const telephoneFieldDefinitionSchema = StringFieldDefinitionBaseSchema.extend({
947
- fieldType: z.literal(FieldTypeSchema.enum.telephone),
935
+ const telephoneFieldDefinitionSchema = stringFieldDefinitionBaseSchema.extend({
936
+ fieldType: z.literal(fieldTypeSchema.enum.telephone),
948
937
  defaultValue: z.e164().nullable()
949
938
  });
950
939
  const stringFieldDefinitionSchema = z.union([
@@ -961,16 +950,16 @@ const stringFieldDefinitionSchema = z.union([
961
950
  /**
962
951
  * Number based Field definitions
963
952
  */
964
- const NumberFieldDefinitionBaseSchema = FieldDefinitionBaseSchema.extend({
965
- valueType: z.literal(ValueTypeSchema.enum.number),
953
+ const numberFieldDefinitionBaseSchema = fieldDefinitionBaseSchema.extend({
954
+ valueType: z.literal(valueTypeSchema.enum.number),
966
955
  min: z.number().nullable(),
967
956
  max: z.number().nullable(),
968
957
  isUnique: z.literal(false),
969
958
  defaultValue: z.number().nullable()
970
959
  });
971
- const numberFieldDefinitionSchema = NumberFieldDefinitionBaseSchema.extend({ fieldType: z.literal(FieldTypeSchema.enum.number) });
972
- const rangeFieldDefinitionSchema = NumberFieldDefinitionBaseSchema.extend({
973
- fieldType: z.literal(FieldTypeSchema.enum.range),
960
+ const numberFieldDefinitionSchema = numberFieldDefinitionBaseSchema.extend({ fieldType: z.literal(fieldTypeSchema.enum.number) });
961
+ const rangeFieldDefinitionSchema = numberFieldDefinitionBaseSchema.extend({
962
+ fieldType: z.literal(fieldTypeSchema.enum.range),
974
963
  isRequired: z.literal(true),
975
964
  min: z.number(),
976
965
  max: z.number(),
@@ -979,24 +968,27 @@ const rangeFieldDefinitionSchema = NumberFieldDefinitionBaseSchema.extend({
979
968
  /**
980
969
  * Boolean based Field definitions
981
970
  */
982
- const BooleanFieldDefinitionBaseSchema = FieldDefinitionBaseSchema.extend({
983
- valueType: z.literal(ValueTypeSchema.enum.boolean),
971
+ const booleanFieldDefinitionBaseSchema = fieldDefinitionBaseSchema.extend({
972
+ valueType: z.literal(valueTypeSchema.enum.boolean),
984
973
  isRequired: z.literal(true),
985
974
  defaultValue: z.boolean(),
986
975
  isUnique: z.literal(false)
987
976
  });
988
- const toggleFieldDefinitionSchema = BooleanFieldDefinitionBaseSchema.extend({ fieldType: z.literal(FieldTypeSchema.enum.toggle) });
977
+ const toggleFieldDefinitionSchema = booleanFieldDefinitionBaseSchema.extend({ fieldType: z.literal(fieldTypeSchema.enum.toggle) });
989
978
  /**
990
979
  * Reference based Field definitions
991
980
  */
992
- const ReferenceFieldDefinitionBaseSchema = FieldDefinitionBaseSchema.extend({ valueType: z.literal(ValueTypeSchema.enum.reference) });
993
- const assetFieldDefinitionSchema = ReferenceFieldDefinitionBaseSchema.extend({
994
- fieldType: z.literal(FieldTypeSchema.enum.asset),
981
+ const referenceFieldDefinitionBaseSchema = fieldDefinitionBaseSchema.extend({
982
+ valueType: z.literal(valueTypeSchema.enum.reference),
983
+ isUnique: z.literal(false)
984
+ });
985
+ const assetFieldDefinitionSchema = referenceFieldDefinitionBaseSchema.extend({
986
+ fieldType: z.literal(fieldTypeSchema.enum.asset),
995
987
  min: z.number().nullable(),
996
988
  max: z.number().nullable()
997
989
  });
998
- const entryFieldDefinitionSchema = ReferenceFieldDefinitionBaseSchema.extend({
999
- fieldType: z.literal(FieldTypeSchema.enum.entry),
990
+ const entryFieldDefinitionSchema = referenceFieldDefinitionBaseSchema.extend({
991
+ fieldType: z.literal(fieldTypeSchema.enum.entry),
1000
992
  ofCollections: z.array(uuidSchema),
1001
993
  min: z.number().nullable(),
1002
994
  max: z.number().nullable()
@@ -1019,18 +1011,23 @@ const collectionFileSchema = baseFileSchema.extend({
1019
1011
  plural: translatableStringSchema
1020
1012
  }),
1021
1013
  slug: z.object({
1022
- singular: z.string(),
1023
- plural: z.string()
1014
+ singular: slugSchema,
1015
+ plural: slugSchema
1024
1016
  }),
1025
1017
  description: translatableStringSchema,
1026
1018
  icon: supportedIconSchema,
1027
1019
  fieldDefinitions: z.array(fieldDefinitionSchema)
1028
1020
  });
1029
- const collectionSchema = collectionFileSchema.extend({ history: z.array(gitCommitSchema) }).openapi("Collection");
1021
+ const collectionSchema = collectionFileSchema.openapi("Collection");
1022
+ const collectionHistorySchema = z.object({
1023
+ id: uuidSchema.readonly(),
1024
+ projectId: uuidSchema.readonly()
1025
+ });
1030
1026
  const collectionExportSchema = collectionSchema.extend({ entries: z.array(entryExportSchema) });
1031
1027
  const createCollectionSchema = collectionFileSchema.omit({
1032
1028
  id: true,
1033
1029
  objectType: true,
1030
+ coreVersion: true,
1034
1031
  created: true,
1035
1032
  updated: true
1036
1033
  }).extend({ projectId: uuidSchema.readonly() });
@@ -1039,6 +1036,11 @@ const readCollectionSchema = z.object({
1039
1036
  projectId: uuidSchema.readonly(),
1040
1037
  commitHash: z.string().optional().readonly()
1041
1038
  });
1039
+ const readBySlugCollectionSchema = z.object({
1040
+ slug: slugSchema,
1041
+ projectId: uuidSchema.readonly(),
1042
+ commitHash: z.string().optional().readonly()
1043
+ });
1042
1044
  const updateCollectionSchema = collectionFileSchema.pick({
1043
1045
  id: true,
1044
1046
  name: true,
@@ -1049,6 +1051,14 @@ const updateCollectionSchema = collectionFileSchema.pick({
1049
1051
  }).extend({ projectId: uuidSchema.readonly() });
1050
1052
  const deleteCollectionSchema = readCollectionSchema.extend({});
1051
1053
  const countCollectionsSchema = z.object({ projectId: uuidSchema.readonly() });
1054
+ const migrateCollectionSchema = z.looseObject(collectionFileSchema.pick({
1055
+ id: true,
1056
+ coreVersion: true
1057
+ }).shape);
1058
+ const resolveCollectionIdSchema = z.object({
1059
+ projectId: uuidSchema.readonly(),
1060
+ idOrSlug: z.string()
1061
+ });
1052
1062
 
1053
1063
  //#endregion
1054
1064
  //#region src/schema/coreSchema.ts
@@ -1065,15 +1075,88 @@ const constructorElekIoCoreSchema = elekIoCoreOptionsSchema.partial({
1065
1075
  }).optional();
1066
1076
 
1067
1077
  //#endregion
1068
- //#region src/schema/projectSchema.ts
1069
- const projectStatusSchema = z.enum([
1070
- "foo",
1071
- "bar",
1072
- "todo"
1078
+ //#region src/schema/gitSchema.ts
1079
+ /**
1080
+ * Signature git uses to identify users
1081
+ */
1082
+ const gitSignatureSchema = z.object({
1083
+ name: z.string().regex(/^[^|]+$/, "Name must not contain pipe characters"),
1084
+ email: z.string().email()
1085
+ });
1086
+ const gitMessageSchema = z.object({
1087
+ method: z.enum([
1088
+ "create",
1089
+ "update",
1090
+ "delete",
1091
+ "upgrade",
1092
+ "release"
1093
+ ]),
1094
+ reference: z.object({
1095
+ objectType: objectTypeSchema,
1096
+ id: uuidSchema,
1097
+ collectionId: uuidSchema.optional()
1098
+ })
1099
+ });
1100
+ const gitTagMessageSchema = z.discriminatedUnion("type", [
1101
+ z.object({
1102
+ type: z.literal("release"),
1103
+ version: versionSchema
1104
+ }),
1105
+ z.object({
1106
+ type: z.literal("preview"),
1107
+ version: versionSchema
1108
+ }),
1109
+ z.object({
1110
+ type: z.literal("upgrade"),
1111
+ coreVersion: versionSchema
1112
+ })
1073
1113
  ]);
1114
+ const gitTagSchema = z.object({
1115
+ id: uuidSchema,
1116
+ message: gitTagMessageSchema,
1117
+ author: gitSignatureSchema,
1118
+ datetime: z.iso.datetime()
1119
+ });
1120
+ const gitCommitSchema = z.object({
1121
+ hash: z.hash("sha1"),
1122
+ message: gitMessageSchema,
1123
+ author: gitSignatureSchema,
1124
+ datetime: z.iso.datetime(),
1125
+ tag: gitTagSchema.nullable()
1126
+ });
1127
+ const gitInitOptionsSchema = z.object({ initialBranch: z.string() });
1128
+ const gitCloneOptionsSchema = z.object({
1129
+ depth: z.number(),
1130
+ singleBranch: z.boolean(),
1131
+ branch: z.string(),
1132
+ bare: z.boolean()
1133
+ });
1134
+ const gitMergeOptionsSchema = z.object({ squash: z.boolean() });
1135
+ const gitSwitchOptionsSchema = z.object({ isNew: z.boolean().optional() });
1136
+ const gitLogOptionsSchema = z.object({
1137
+ limit: z.number().optional(),
1138
+ between: z.object({
1139
+ from: z.string(),
1140
+ to: z.string().optional()
1141
+ }),
1142
+ filePath: z.string().optional()
1143
+ });
1144
+ const createGitTagSchema = gitTagSchema.pick({ message: true }).extend({
1145
+ path: z.string(),
1146
+ hash: z.string().optional()
1147
+ });
1148
+ const readGitTagSchema = z.object({
1149
+ path: z.string(),
1150
+ id: uuidSchema.readonly()
1151
+ });
1152
+ const deleteGitTagSchema = readGitTagSchema.extend({});
1153
+ const countGitTagsSchema = z.object({ path: z.string() });
1154
+
1155
+ //#endregion
1156
+ //#region src/schema/projectSchema.ts
1074
1157
  const projectSettingsSchema = z.object({ language: z.object({
1075
1158
  default: supportedLanguageSchema,
1076
- supported: z.array(supportedLanguageSchema)
1159
+ supported: z.array(supportedLanguageSchema).refine((langs) => new Set(langs).size === langs.length, { message: "Supported languages must not contain duplicates" })
1077
1160
  }) });
1078
1161
  const projectFolderSchema = z.enum([
1079
1162
  "assets",
@@ -1084,22 +1167,21 @@ const projectFolderSchema = z.enum([
1084
1167
  const projectBranchSchema = z.enum(["production", "work"]);
1085
1168
  const projectFileSchema = baseFileSchema.extend({
1086
1169
  objectType: z.literal(objectTypeSchema.enum.project).readonly(),
1087
- coreVersion: versionSchema,
1088
1170
  name: z.string().trim().min(1),
1089
1171
  description: z.string().trim().min(1),
1090
1172
  version: versionSchema,
1091
- status: projectStatusSchema,
1092
1173
  settings: projectSettingsSchema
1093
1174
  });
1094
- const projectSchema = projectFileSchema.extend({
1095
- remoteOriginUrl: z.string().nullable().openapi({ description: "URL of the remote Git repository" }),
1175
+ const projectSchema = projectFileSchema.extend({ remoteOriginUrl: z.string().nullable().openapi({ description: "URL of the remote Git repository" }) }).openapi("Project");
1176
+ const projectHistorySchema = z.object({ id: uuidSchema.readonly() });
1177
+ const projectHistoryResultSchema = z.object({
1096
1178
  history: z.array(gitCommitSchema).openapi({ description: "Commit history of this Project" }),
1097
1179
  fullHistory: z.array(gitCommitSchema).openapi({ description: "Full commit history of this Project including all Assets, Collections, Entries and other files" })
1098
- }).openapi("Project");
1099
- const migrateProjectSchema = projectFileSchema.pick({
1180
+ });
1181
+ const migrateProjectSchema = z.looseObject(projectFileSchema.pick({
1100
1182
  id: true,
1101
1183
  coreVersion: true
1102
- }).loose();
1184
+ }).shape);
1103
1185
  const projectExportSchema = projectSchema.extend({
1104
1186
  assets: z.array(assetExportSchema),
1105
1187
  collections: z.array(collectionExportSchema)
@@ -1124,13 +1206,6 @@ const upgradeProjectSchema = z.object({
1124
1206
  force: z.boolean().optional()
1125
1207
  });
1126
1208
  const deleteProjectSchema = readProjectSchema.extend({ force: z.boolean().optional() });
1127
- const projectUpgradeSchema = z.object({
1128
- to: versionSchema.readonly(),
1129
- run: z.function({
1130
- input: [projectFileSchema],
1131
- output: z.promise(z.void())
1132
- })
1133
- });
1134
1209
  const cloneProjectSchema = z.object({ url: z.string() });
1135
1210
  const listBranchesProjectSchema = z.object({ id: uuidSchema.readonly() });
1136
1211
  const currentBranchProjectSchema = z.object({ id: uuidSchema.readonly() });
@@ -1184,29 +1259,29 @@ function getNumberValueContentSchemaFromFieldDefinition(fieldDefinition) {
1184
1259
  function getStringValueContentSchemaFromFieldDefinition(fieldDefinition) {
1185
1260
  let schema = null;
1186
1261
  switch (fieldDefinition.fieldType) {
1187
- case FieldTypeSchema.enum.email:
1262
+ case fieldTypeSchema.enum.email:
1188
1263
  schema = z.email();
1189
1264
  break;
1190
- case FieldTypeSchema.enum.url:
1265
+ case fieldTypeSchema.enum.url:
1191
1266
  schema = z.url();
1192
1267
  break;
1193
- case FieldTypeSchema.enum.ipv4:
1268
+ case fieldTypeSchema.enum.ipv4:
1194
1269
  schema = z.ipv4();
1195
1270
  break;
1196
- case FieldTypeSchema.enum.date:
1271
+ case fieldTypeSchema.enum.date:
1197
1272
  schema = z.iso.date();
1198
1273
  break;
1199
- case FieldTypeSchema.enum.time:
1274
+ case fieldTypeSchema.enum.time:
1200
1275
  schema = z.iso.time();
1201
1276
  break;
1202
- case FieldTypeSchema.enum.datetime:
1277
+ case fieldTypeSchema.enum.datetime:
1203
1278
  schema = z.iso.datetime();
1204
1279
  break;
1205
- case FieldTypeSchema.enum.telephone:
1280
+ case fieldTypeSchema.enum.telephone:
1206
1281
  schema = z.e164();
1207
1282
  break;
1208
- case FieldTypeSchema.enum.text:
1209
- case FieldTypeSchema.enum.textarea:
1283
+ case fieldTypeSchema.enum.text:
1284
+ case fieldTypeSchema.enum.textarea:
1210
1285
  schema = z.string().trim();
1211
1286
  break;
1212
1287
  }
@@ -1222,10 +1297,10 @@ function getStringValueContentSchemaFromFieldDefinition(fieldDefinition) {
1222
1297
  function getReferenceValueContentSchemaFromFieldDefinition(fieldDefinition) {
1223
1298
  let schema;
1224
1299
  switch (fieldDefinition.fieldType) {
1225
- case FieldTypeSchema.enum.asset:
1300
+ case fieldTypeSchema.enum.asset:
1226
1301
  schema = z.array(valueContentReferenceToAssetSchema);
1227
1302
  break;
1228
- case FieldTypeSchema.enum.entry:
1303
+ case fieldTypeSchema.enum.entry:
1229
1304
  schema = z.array(valueContentReferenceToEntrySchema);
1230
1305
  break;
1231
1306
  }
@@ -1251,35 +1326,37 @@ function getTranslatableReferenceValueContentSchemaFromFieldDefinition(fieldDefi
1251
1326
  */
1252
1327
  function getValueSchemaFromFieldDefinition(fieldDefinition) {
1253
1328
  switch (fieldDefinition.valueType) {
1254
- case ValueTypeSchema.enum.boolean: return directBooleanValueSchema.extend({ content: getTranslatableBooleanValueContentSchemaFromFieldDefinition() });
1255
- case ValueTypeSchema.enum.number: return directNumberValueSchema.extend({ content: getTranslatableNumberValueContentSchemaFromFieldDefinition(fieldDefinition) });
1256
- case ValueTypeSchema.enum.string: return directStringValueSchema.extend({ content: getTranslatableStringValueContentSchemaFromFieldDefinition(fieldDefinition) });
1257
- case ValueTypeSchema.enum.reference: return referencedValueSchema.extend({ content: getTranslatableReferenceValueContentSchemaFromFieldDefinition(fieldDefinition) });
1329
+ case valueTypeSchema.enum.boolean: return directBooleanValueSchema.extend({ content: getTranslatableBooleanValueContentSchemaFromFieldDefinition() });
1330
+ case valueTypeSchema.enum.number: return directNumberValueSchema.extend({ content: getTranslatableNumberValueContentSchemaFromFieldDefinition(fieldDefinition) });
1331
+ case valueTypeSchema.enum.string: return directStringValueSchema.extend({ content: getTranslatableStringValueContentSchemaFromFieldDefinition(fieldDefinition) });
1332
+ case valueTypeSchema.enum.reference: return referencedValueSchema.extend({ content: getTranslatableReferenceValueContentSchemaFromFieldDefinition(fieldDefinition) });
1258
1333
  default: throw new Error(`Error generating schema for unsupported ValueType "${fieldDefinition.valueType}"`);
1259
1334
  }
1260
1335
  }
1261
1336
  /**
1337
+ * Builds a z.object shape from field definitions, keyed by slug
1338
+ */
1339
+ function getValuesShapeFromFieldDefinitions(fieldDefinitions) {
1340
+ const shape = {};
1341
+ for (const fieldDef of fieldDefinitions) shape[fieldDef.slug] = getValueSchemaFromFieldDefinition(fieldDef);
1342
+ return shape;
1343
+ }
1344
+ /**
1262
1345
  * Generates a schema for creating a new Entry based on the given Field definitions and Values
1263
1346
  */
1264
1347
  function getCreateEntrySchemaFromFieldDefinitions(fieldDefinitions) {
1265
- const valueSchemas = fieldDefinitions.map((fieldDefinition) => {
1266
- return getValueSchemaFromFieldDefinition(fieldDefinition);
1267
- });
1268
1348
  return z.object({
1269
1349
  ...createEntrySchema.shape,
1270
- values: z.tuple(valueSchemas)
1350
+ values: z.object(getValuesShapeFromFieldDefinitions(fieldDefinitions))
1271
1351
  });
1272
1352
  }
1273
1353
  /**
1274
1354
  * Generates a schema for updating an existing Entry based on the given Field definitions and Values
1275
1355
  */
1276
1356
  function getUpdateEntrySchemaFromFieldDefinitions(fieldDefinitions) {
1277
- const valueSchemas = fieldDefinitions.map((fieldDefinition) => {
1278
- return getValueSchemaFromFieldDefinition(fieldDefinition);
1279
- });
1280
1357
  return z.object({
1281
1358
  ...updateEntrySchema.shape,
1282
- values: z.tuple(valueSchemas)
1359
+ values: z.object(getValuesShapeFromFieldDefinitions(fieldDefinitions))
1283
1360
  });
1284
1361
  }
1285
1362
 
@@ -1295,7 +1372,8 @@ const serviceTypeSchema = z.enum([
1295
1372
  "Search",
1296
1373
  "Collection",
1297
1374
  "Entry",
1298
- "Value"
1375
+ "Value",
1376
+ "Release"
1299
1377
  ]);
1300
1378
  function paginatedListOf(schema) {
1301
1379
  return z.object({
@@ -1318,18 +1396,18 @@ const listGitTagsSchema = z.object({ path: z.string() });
1318
1396
 
1319
1397
  //#endregion
1320
1398
  //#region src/schema/userSchema.ts
1321
- const UserTypeSchema = z.enum(["local", "cloud"]);
1399
+ const userTypeSchema = z.enum(["local", "cloud"]);
1322
1400
  const baseUserSchema = gitSignatureSchema.extend({
1323
- userType: UserTypeSchema,
1401
+ userType: userTypeSchema,
1324
1402
  language: supportedLanguageSchema,
1325
1403
  localApi: z.object({
1326
1404
  isEnabled: z.boolean(),
1327
1405
  port: z.number()
1328
1406
  })
1329
1407
  });
1330
- const localUserSchema = baseUserSchema.extend({ userType: z.literal(UserTypeSchema.enum.local) });
1408
+ const localUserSchema = baseUserSchema.extend({ userType: z.literal(userTypeSchema.enum.local) });
1331
1409
  const cloudUserSchema = baseUserSchema.extend({
1332
- userType: z.literal(UserTypeSchema.enum.cloud),
1410
+ userType: z.literal(userTypeSchema.enum.cloud),
1333
1411
  id: uuidSchema
1334
1412
  });
1335
1413
  const userFileSchema = z.union([localUserSchema, cloudUserSchema]);
@@ -1403,6 +1481,97 @@ const logConsoleTransportSchema = logSchema.extend({
1403
1481
  level: z.string()
1404
1482
  });
1405
1483
 
1484
+ //#endregion
1485
+ //#region src/schema/releaseSchema.ts
1486
+ const semverBumpSchema = z.enum([
1487
+ "major",
1488
+ "minor",
1489
+ "patch"
1490
+ ]);
1491
+ const fieldChangeTypeSchema = z.enum([
1492
+ "added",
1493
+ "deleted",
1494
+ "valueTypeChanged",
1495
+ "fieldTypeChanged",
1496
+ "slugChanged",
1497
+ "minMaxTightened",
1498
+ "isRequiredToNotRequired",
1499
+ "isUniqueToNotUnique",
1500
+ "ofCollectionsChanged",
1501
+ "isNotRequiredToRequired",
1502
+ "isNotUniqueToUnique",
1503
+ "labelChanged",
1504
+ "descriptionChanged",
1505
+ "defaultValueChanged",
1506
+ "inputWidthChanged",
1507
+ "isDisabledChanged",
1508
+ "minMaxLoosened"
1509
+ ]);
1510
+ const fieldChangeSchema = z.object({
1511
+ collectionId: uuidSchema,
1512
+ fieldId: uuidSchema,
1513
+ fieldSlug: z.string(),
1514
+ changeType: fieldChangeTypeSchema,
1515
+ bump: semverBumpSchema
1516
+ });
1517
+ const collectionChangeTypeSchema = z.enum(["added", "deleted"]);
1518
+ const collectionChangeSchema = z.object({
1519
+ collectionId: uuidSchema,
1520
+ changeType: collectionChangeTypeSchema,
1521
+ bump: semverBumpSchema
1522
+ });
1523
+ const projectChangeTypeSchema = z.enum([
1524
+ "nameChanged",
1525
+ "descriptionChanged",
1526
+ "defaultLanguageChanged",
1527
+ "supportedLanguageAdded",
1528
+ "supportedLanguageRemoved"
1529
+ ]);
1530
+ const projectChangeSchema = z.object({
1531
+ changeType: projectChangeTypeSchema,
1532
+ bump: semverBumpSchema
1533
+ });
1534
+ const assetChangeTypeSchema = z.enum([
1535
+ "added",
1536
+ "deleted",
1537
+ "metadataChanged",
1538
+ "binaryChanged"
1539
+ ]);
1540
+ const assetChangeSchema = z.object({
1541
+ assetId: uuidSchema,
1542
+ changeType: assetChangeTypeSchema,
1543
+ bump: semverBumpSchema
1544
+ });
1545
+ const entryChangeTypeSchema = z.enum([
1546
+ "added",
1547
+ "deleted",
1548
+ "modified"
1549
+ ]);
1550
+ const entryChangeSchema = z.object({
1551
+ collectionId: uuidSchema,
1552
+ entryId: uuidSchema,
1553
+ changeType: entryChangeTypeSchema,
1554
+ bump: semverBumpSchema
1555
+ });
1556
+ const releaseDiffSchema = z.object({
1557
+ project: projectSchema,
1558
+ bump: semverBumpSchema.nullable(),
1559
+ currentVersion: versionSchema,
1560
+ nextVersion: versionSchema.nullable(),
1561
+ projectChanges: z.array(projectChangeSchema),
1562
+ collectionChanges: z.array(collectionChangeSchema),
1563
+ fieldChanges: z.array(fieldChangeSchema),
1564
+ assetChanges: z.array(assetChangeSchema),
1565
+ entryChanges: z.array(entryChangeSchema)
1566
+ });
1567
+ const prepareReleaseSchema = z.object({ projectId: uuidSchema.readonly() });
1568
+ const createReleaseSchema = z.object({ projectId: uuidSchema.readonly() });
1569
+ const createPreviewReleaseSchema = z.object({ projectId: uuidSchema.readonly() });
1570
+ const releaseResultSchema = z.object({
1571
+ version: versionSchema,
1572
+ diff: releaseDiffSchema
1573
+ });
1574
+
1406
1575
  //#endregion
1407
1576
  //#region src/api/routes/content/v1/projects.ts
1408
1577
  const tags$3 = ["Content API v1"];
@@ -1524,29 +1693,36 @@ const router$5 = createRouter().openapi(createRoute({
1524
1693
  return c.json(count, 200);
1525
1694
  }).openapi(createRoute({
1526
1695
  summary: "Get one Collection",
1527
- description: "Retrieve a Collection by ID",
1696
+ description: "Retrieve a Collection by UUID or slug",
1528
1697
  method: "get",
1529
- path: "/{projectId}/collections/{collectionId}",
1698
+ path: "/{projectId}/collections/{collectionIdOrSlug}",
1530
1699
  tags: tags$2,
1531
1700
  request: { params: z.object({
1532
1701
  projectId: uuidSchema.openapi({ param: {
1533
1702
  name: "projectId",
1534
1703
  in: "path"
1535
1704
  } }),
1536
- collectionId: uuidSchema.openapi({ param: {
1537
- name: "collectionId",
1538
- in: "path"
1539
- } })
1705
+ collectionIdOrSlug: z.string().openapi({
1706
+ param: {
1707
+ name: "collectionIdOrSlug",
1708
+ in: "path"
1709
+ },
1710
+ description: "Collection UUID or slug"
1711
+ })
1540
1712
  }) },
1541
1713
  responses: { [200]: {
1542
1714
  content: { "application/json": { schema: collectionSchema } },
1543
1715
  description: "The requested Collection"
1544
1716
  } }
1545
1717
  }), async (c) => {
1546
- const { projectId, collectionId } = c.req.valid("param");
1718
+ const { projectId, collectionIdOrSlug } = c.req.valid("param");
1719
+ const resolvedId = await c.var.collectionService.resolveCollectionId({
1720
+ projectId,
1721
+ idOrSlug: collectionIdOrSlug
1722
+ });
1547
1723
  const collection = await c.var.collectionService.read({
1548
1724
  projectId,
1549
- id: collectionId
1725
+ id: resolvedId
1550
1726
  });
1551
1727
  return c.json(collection, 200);
1552
1728
  });
@@ -1558,7 +1734,7 @@ const router$4 = createRouter().openapi(createRoute({
1558
1734
  summary: "List Entries",
1559
1735
  description: "Lists all Entries of the given Projects Collection",
1560
1736
  method: "get",
1561
- path: "/{projectId}/collections/{collectionId}/entries",
1737
+ path: "/{projectId}/collections/{collectionIdOrSlug}/entries",
1562
1738
  tags: tags$1,
1563
1739
  request: {
1564
1740
  params: z.object({
@@ -1566,10 +1742,13 @@ const router$4 = createRouter().openapi(createRoute({
1566
1742
  name: "projectId",
1567
1743
  in: "path"
1568
1744
  } }),
1569
- collectionId: uuidSchema.openapi({ param: {
1570
- name: "collectionId",
1571
- in: "path"
1572
- } })
1745
+ collectionIdOrSlug: z.string().openapi({
1746
+ param: {
1747
+ name: "collectionIdOrSlug",
1748
+ in: "path"
1749
+ },
1750
+ description: "Collection UUID or slug"
1751
+ })
1573
1752
  }),
1574
1753
  query: z.object({
1575
1754
  limit: z.string().pipe(z.coerce.number()).optional().openapi({
@@ -1587,8 +1766,12 @@ const router$4 = createRouter().openapi(createRoute({
1587
1766
  description: "A list of Entries for the given Projects Collection"
1588
1767
  } }
1589
1768
  }), async (c) => {
1590
- const { projectId, collectionId } = c.req.valid("param");
1769
+ const { projectId, collectionIdOrSlug } = c.req.valid("param");
1591
1770
  const { limit, offset } = c.req.valid("query");
1771
+ const collectionId = await c.var.collectionService.resolveCollectionId({
1772
+ projectId,
1773
+ idOrSlug: collectionIdOrSlug
1774
+ });
1592
1775
  const entries = await c.var.entryService.list({
1593
1776
  projectId,
1594
1777
  collectionId,
@@ -1600,24 +1783,31 @@ const router$4 = createRouter().openapi(createRoute({
1600
1783
  summary: "Count Entries",
1601
1784
  description: "Counts all Entries of the given Projects Collection",
1602
1785
  method: "get",
1603
- path: "/{projectId}/collections/{collectionId}/entries/count",
1786
+ path: "/{projectId}/collections/{collectionIdOrSlug}/entries/count",
1604
1787
  tags: tags$1,
1605
1788
  request: { params: z.object({
1606
1789
  projectId: uuidSchema.openapi({ param: {
1607
1790
  name: "projectId",
1608
1791
  in: "path"
1609
1792
  } }),
1610
- collectionId: uuidSchema.openapi({ param: {
1611
- name: "collectionId",
1612
- in: "path"
1613
- } })
1793
+ collectionIdOrSlug: z.string().openapi({
1794
+ param: {
1795
+ name: "collectionIdOrSlug",
1796
+ in: "path"
1797
+ },
1798
+ description: "Collection UUID or slug"
1799
+ })
1614
1800
  }) },
1615
1801
  responses: { [200]: {
1616
1802
  content: { "application/json": { schema: z.number() } },
1617
1803
  description: "The number of Entries of the given Projects Collection"
1618
1804
  } }
1619
1805
  }), async (c) => {
1620
- const { projectId, collectionId } = c.req.valid("param");
1806
+ const { projectId, collectionIdOrSlug } = c.req.valid("param");
1807
+ const collectionId = await c.var.collectionService.resolveCollectionId({
1808
+ projectId,
1809
+ idOrSlug: collectionIdOrSlug
1810
+ });
1621
1811
  const count = await c.var.entryService.count({
1622
1812
  projectId,
1623
1813
  collectionId
@@ -1627,17 +1817,20 @@ const router$4 = createRouter().openapi(createRoute({
1627
1817
  summary: "Get one Entry",
1628
1818
  description: "Retrieve an Entry by ID",
1629
1819
  method: "get",
1630
- path: "/{projectId}/collections/{collectionId}/entries/{entryId}",
1820
+ path: "/{projectId}/collections/{collectionIdOrSlug}/entries/{entryId}",
1631
1821
  tags: tags$1,
1632
1822
  request: { params: z.object({
1633
1823
  projectId: uuidSchema.openapi({ param: {
1634
1824
  name: "projectId",
1635
1825
  in: "path"
1636
1826
  } }),
1637
- collectionId: uuidSchema.openapi({ param: {
1638
- name: "collectionId",
1639
- in: "path"
1640
- } }),
1827
+ collectionIdOrSlug: z.string().openapi({
1828
+ param: {
1829
+ name: "collectionIdOrSlug",
1830
+ in: "path"
1831
+ },
1832
+ description: "Collection UUID or slug"
1833
+ }),
1641
1834
  entryId: uuidSchema.openapi({ param: {
1642
1835
  name: "entryId",
1643
1836
  in: "path"
@@ -1648,7 +1841,11 @@ const router$4 = createRouter().openapi(createRoute({
1648
1841
  description: "The requested Entry"
1649
1842
  } }
1650
1843
  }), async (c) => {
1651
- const { projectId, collectionId, entryId } = c.req.valid("param");
1844
+ const { projectId, collectionIdOrSlug, entryId } = c.req.valid("param");
1845
+ const collectionId = await c.var.collectionService.resolveCollectionId({
1846
+ projectId,
1847
+ idOrSlug: collectionIdOrSlug
1848
+ });
1652
1849
  const entry = await c.var.entryService.read({
1653
1850
  projectId,
1654
1851
  collectionId,
@@ -1915,6 +2112,9 @@ const pathTo = {
1915
2112
  collectionFile: (projectId, id) => {
1916
2113
  return Path.join(pathTo.collection(projectId, id), "collection.json");
1917
2114
  },
2115
+ collectionIndex: (projectId) => {
2116
+ return Path.join(pathTo.collections(projectId), "index.json");
2117
+ },
1918
2118
  entries: (projectId, collectionId) => {
1919
2119
  return Path.join(pathTo.collection(projectId, collectionId));
1920
2120
  },
@@ -2137,6 +2337,38 @@ var AbstractCrudService = class {
2137
2337
  }
2138
2338
  };
2139
2339
 
2340
+ //#endregion
2341
+ //#region src/service/migrations/applyMigrations.ts
2342
+ function applyMigrations(data, migrations, targetVersion) {
2343
+ let current = structuredClone(data);
2344
+ while (current["coreVersion"] !== targetVersion) {
2345
+ const migration = migrations.find((m) => m.from === current["coreVersion"]);
2346
+ if (!migration) {
2347
+ current["coreVersion"] = targetVersion;
2348
+ break;
2349
+ }
2350
+ current = migration.run(current);
2351
+ current["coreVersion"] = migration.to;
2352
+ }
2353
+ return current;
2354
+ }
2355
+
2356
+ //#endregion
2357
+ //#region src/service/migrations/assetMigrations.ts
2358
+ const assetMigrations = [];
2359
+
2360
+ //#endregion
2361
+ //#region src/service/migrations/collectionMigrations.ts
2362
+ const collectionMigrations = [];
2363
+
2364
+ //#endregion
2365
+ //#region src/service/migrations/entryMigrations.ts
2366
+ const entryMigrations = [];
2367
+
2368
+ //#endregion
2369
+ //#region src/service/migrations/projectMigrations.ts
2370
+ const projectMigrations = [];
2371
+
2140
2372
  //#endregion
2141
2373
  //#region src/util/shared.ts
2142
2374
  /**
@@ -2179,10 +2411,12 @@ function slug(string) {
2179
2411
  * Service that manages CRUD functionality for Asset files on disk
2180
2412
  */
2181
2413
  var AssetService = class extends AbstractCrudService {
2414
+ coreVersion;
2182
2415
  jsonFileService;
2183
2416
  gitService;
2184
- constructor(options, logService, jsonFileService, gitService) {
2417
+ constructor(coreVersion, options, logService, jsonFileService, gitService) {
2185
2418
  super(serviceTypeSchema.enum.Asset, options, logService);
2419
+ this.coreVersion = coreVersion;
2186
2420
  this.jsonFileService = jsonFileService;
2187
2421
  this.gitService = gitService;
2188
2422
  }
@@ -2202,6 +2436,7 @@ var AssetService = class extends AbstractCrudService {
2202
2436
  name: slug(props.name),
2203
2437
  objectType: "asset",
2204
2438
  id,
2439
+ coreVersion: this.coreVersion,
2205
2440
  created: datetime(),
2206
2441
  updated: null,
2207
2442
  extension: fileType.extension,
@@ -2246,6 +2481,13 @@ var AssetService = class extends AbstractCrudService {
2246
2481
  }
2247
2482
  }
2248
2483
  /**
2484
+ * Returns the commit history of an Asset
2485
+ */
2486
+ async history(props) {
2487
+ assetHistorySchema.parse(props);
2488
+ return this.gitService.log(pathTo.project(props.projectId), { filePath: pathTo.assetFile(props.projectId, props.id) });
2489
+ }
2490
+ /**
2249
2491
  * Copies an Asset to given file path on disk
2250
2492
  */
2251
2493
  async save(props) {
@@ -2354,13 +2596,11 @@ var AssetService = class extends AbstractCrudService {
2354
2596
  * @param projectId The project's ID
2355
2597
  * @param assetFile The AssetFile to convert
2356
2598
  */
2357
- async toAsset(projectId, assetFile, commitHash) {
2599
+ toAsset(projectId, assetFile, commitHash) {
2358
2600
  const assetPath = commitHash ? pathTo.tmpAsset(assetFile.id, commitHash, assetFile.extension) : pathTo.asset(projectId, assetFile.id, assetFile.extension);
2359
- const history = await this.gitService.log(pathTo.project(projectId), { filePath: pathTo.assetFile(projectId, assetFile.id) });
2360
2601
  return {
2361
2602
  ...assetFile,
2362
- absolutePath: assetPath,
2363
- history
2603
+ absolutePath: assetPath
2364
2604
  };
2365
2605
  }
2366
2606
  /**
@@ -2383,7 +2623,8 @@ var AssetService = class extends AbstractCrudService {
2383
2623
  * Migrates an potentially outdated Asset file to the current schema
2384
2624
  */
2385
2625
  migrate(potentiallyOutdatedAssetFile) {
2386
- return assetFileSchema.parse(potentiallyOutdatedAssetFile);
2626
+ const migrated = applyMigrations(migrateAssetSchema.parse(potentiallyOutdatedAssetFile), assetMigrations, this.coreVersion);
2627
+ return assetFileSchema.parse(migrated);
2387
2628
  }
2388
2629
  };
2389
2630
 
@@ -2393,29 +2634,59 @@ var AssetService = class extends AbstractCrudService {
2393
2634
  * Service that manages CRUD functionality for Collection files on disk
2394
2635
  */
2395
2636
  var CollectionService = class extends AbstractCrudService {
2637
+ coreVersion;
2396
2638
  jsonFileService;
2397
2639
  gitService;
2398
- constructor(options, logService, jsonFileService, gitService) {
2640
+ /** In-memory cache for collection indices, keyed by projectId */
2641
+ cachedIndex = /* @__PURE__ */ new Map();
2642
+ /** Promise deduplication for concurrent rebuilds, keyed by projectId */
2643
+ rebuildPromise = /* @__PURE__ */ new Map();
2644
+ constructor(coreVersion, options, logService, jsonFileService, gitService) {
2399
2645
  super(serviceTypeSchema.enum.Collection, options, logService);
2646
+ this.coreVersion = coreVersion;
2400
2647
  this.jsonFileService = jsonFileService;
2401
2648
  this.gitService = gitService;
2402
2649
  }
2403
2650
  /**
2651
+ * Resolves a UUID-or-slug string to a collection UUID.
2652
+ *
2653
+ * If the input matches UUID format, verifies the folder exists on disk first.
2654
+ * If the folder doesn't exist, falls back to slug lookup.
2655
+ * Otherwise, looks up via the index.
2656
+ */
2657
+ async resolveCollectionId(props) {
2658
+ if (uuidSchema.safeParse(props.idOrSlug).success) {
2659
+ const collectionPath = pathTo.collection(props.projectId, props.idOrSlug);
2660
+ if (await Fs.pathExists(collectionPath)) return props.idOrSlug;
2661
+ }
2662
+ const index = await this.getIndex(props.projectId);
2663
+ for (const [uuid, slugValue] of Object.entries(index)) if (slugValue === props.idOrSlug) return uuid;
2664
+ this.cachedIndex.delete(props.projectId);
2665
+ const freshIndex = await this.getIndex(props.projectId);
2666
+ for (const [uuid, slugValue] of Object.entries(freshIndex)) if (slugValue === props.idOrSlug) return uuid;
2667
+ throw new Error(`Collection not found: "${props.idOrSlug}" does not match any collection UUID or slug`);
2668
+ }
2669
+ /**
2404
2670
  * Creates a new Collection
2405
2671
  */
2406
2672
  async create(props) {
2407
2673
  createCollectionSchema.parse(props);
2674
+ this.validateFieldDefinitionSlugUniqueness(props.fieldDefinitions);
2408
2675
  const id = uuid();
2409
2676
  const projectPath = pathTo.project(props.projectId);
2410
2677
  const collectionPath = pathTo.collection(props.projectId, id);
2411
2678
  const collectionFilePath = pathTo.collectionFile(props.projectId, id);
2679
+ const slugPlural = slug(props.slug.plural);
2680
+ const index = await this.getIndex(props.projectId);
2681
+ if (Object.values(index).includes(slugPlural)) throw new Error(`Collection slug "${slugPlural}" is already in use by another collection`);
2412
2682
  const collectionFile = {
2413
2683
  ...props,
2414
2684
  objectType: "collection",
2415
2685
  id,
2686
+ coreVersion: this.coreVersion,
2416
2687
  slug: {
2417
2688
  singular: slug(props.slug.singular),
2418
- plural: slug(props.slug.plural)
2689
+ plural: slugPlural
2419
2690
  },
2420
2691
  created: datetime(),
2421
2692
  updated: null
@@ -2430,7 +2701,9 @@ var CollectionService = class extends AbstractCrudService {
2430
2701
  id
2431
2702
  }
2432
2703
  });
2433
- return this.toCollection(props.projectId, collectionFile);
2704
+ index[id] = slugPlural;
2705
+ await this.writeIndex(props.projectId, index);
2706
+ return this.toCollection(collectionFile);
2434
2707
  }
2435
2708
  /**
2436
2709
  * Returns a Collection by ID
@@ -2441,33 +2714,103 @@ var CollectionService = class extends AbstractCrudService {
2441
2714
  readCollectionSchema.parse(props);
2442
2715
  if (!props.commitHash) {
2443
2716
  const collectionFile = await this.jsonFileService.read(pathTo.collectionFile(props.projectId, props.id), collectionFileSchema);
2444
- return this.toCollection(props.projectId, collectionFile);
2717
+ return this.toCollection(collectionFile);
2445
2718
  } else {
2446
2719
  const collectionFile = this.migrate(JSON.parse(await this.gitService.getFileContentAtCommit(pathTo.project(props.projectId), pathTo.collectionFile(props.projectId, props.id), props.commitHash)));
2447
- return this.toCollection(props.projectId, collectionFile);
2720
+ return this.toCollection(collectionFile);
2448
2721
  }
2449
2722
  }
2450
2723
  /**
2724
+ * Reads a Collection by its slug
2725
+ */
2726
+ async readBySlug(props) {
2727
+ const id = await this.resolveCollectionId({
2728
+ projectId: props.projectId,
2729
+ idOrSlug: props.slug
2730
+ });
2731
+ return this.read({
2732
+ projectId: props.projectId,
2733
+ id,
2734
+ commitHash: props.commitHash
2735
+ });
2736
+ }
2737
+ /**
2738
+ * Returns the commit history of a Collection
2739
+ */
2740
+ async history(props) {
2741
+ collectionHistorySchema.parse(props);
2742
+ return this.gitService.log(pathTo.project(props.projectId), { filePath: pathTo.collectionFile(props.projectId, props.id) });
2743
+ }
2744
+ /**
2451
2745
  * Updates given Collection
2452
2746
  *
2453
- * @todo finish implementing checks for FieldDefinitions and extract methods
2454
- *
2455
- * @param projectId Project ID of the collection to update
2456
- * @param collection Collection to write to disk
2457
- * @returns An object containing information about the actions needed to be taken,
2458
- * before given update can be executed or void if the update was executed successfully
2747
+ * Handles fieldDefinition slug rename cascade and collection slug uniqueness.
2459
2748
  */
2460
2749
  async update(props) {
2461
2750
  updateCollectionSchema.parse(props);
2751
+ this.validateFieldDefinitionSlugUniqueness(props.fieldDefinitions);
2462
2752
  const projectPath = pathTo.project(props.projectId);
2463
2753
  const collectionFilePath = pathTo.collectionFile(props.projectId, props.id);
2754
+ const prevCollectionFile = await this.read(props);
2464
2755
  const collectionFile = {
2465
- ...await this.read(props),
2756
+ ...prevCollectionFile,
2466
2757
  ...props,
2467
2758
  updated: datetime()
2468
2759
  };
2760
+ const oldFieldDefs = prevCollectionFile.fieldDefinitions;
2761
+ const newFieldDefs = props.fieldDefinitions;
2762
+ const slugRenames = [];
2763
+ const oldByUuid = new Map(oldFieldDefs.map((fd) => [fd.id, fd]));
2764
+ for (const newFd of newFieldDefs) {
2765
+ const oldFd = oldByUuid.get(newFd.id);
2766
+ if (oldFd && oldFd.slug !== newFd.slug) slugRenames.push({
2767
+ oldSlug: oldFd.slug,
2768
+ newSlug: newFd.slug
2769
+ });
2770
+ }
2771
+ const filesToGitAdd = [collectionFilePath];
2772
+ if (slugRenames.length > 0) {
2773
+ const entriesPath = pathTo.entries(props.projectId, props.id);
2774
+ if (await Fs.pathExists(entriesPath)) {
2775
+ const entryFiles = (await Fs.readdir(entriesPath)).filter((f) => f.endsWith(".json") && f !== "collection.json");
2776
+ for (const entryFileName of entryFiles) {
2777
+ const entryFilePath = pathTo.entryFile(props.projectId, props.id, entryFileName.replace(".json", ""));
2778
+ try {
2779
+ const entryFile = await this.jsonFileService.read(entryFilePath, entryFileSchema);
2780
+ let changed = false;
2781
+ const newValues = { ...entryFile.values };
2782
+ for (const { oldSlug, newSlug } of slugRenames) if (oldSlug in newValues) {
2783
+ newValues[newSlug] = newValues[oldSlug];
2784
+ delete newValues[oldSlug];
2785
+ changed = true;
2786
+ }
2787
+ if (changed) {
2788
+ const updatedEntryFile = {
2789
+ ...entryFile,
2790
+ values: newValues
2791
+ };
2792
+ await this.jsonFileService.update(updatedEntryFile, entryFilePath, entryFileSchema);
2793
+ filesToGitAdd.push(entryFilePath);
2794
+ }
2795
+ } catch (error) {
2796
+ this.logService.warn({
2797
+ source: "core",
2798
+ message: `Failed to update entry "${entryFileName}" during slug rename cascade: ${error instanceof Error ? error.message : String(error)}`
2799
+ });
2800
+ }
2801
+ }
2802
+ }
2803
+ }
2804
+ const newSlugPlural = slug(props.slug.plural);
2805
+ if (prevCollectionFile.slug.plural !== newSlugPlural) {
2806
+ const index = await this.getIndex(props.projectId);
2807
+ const existingUuid = Object.entries(index).find(([, s]) => s === newSlugPlural);
2808
+ if (existingUuid && existingUuid[0] !== props.id) throw new Error(`Collection slug "${newSlugPlural}" is already in use by another collection`);
2809
+ index[props.id] = newSlugPlural;
2810
+ await this.writeIndex(props.projectId, index);
2811
+ }
2469
2812
  await this.jsonFileService.update(collectionFile, collectionFilePath, collectionFileSchema);
2470
- await this.gitService.add(projectPath, [collectionFilePath]);
2813
+ await this.gitService.add(projectPath, filesToGitAdd);
2471
2814
  await this.gitService.commit(projectPath, {
2472
2815
  method: "update",
2473
2816
  reference: {
@@ -2475,10 +2818,10 @@ var CollectionService = class extends AbstractCrudService {
2475
2818
  id: collectionFile.id
2476
2819
  }
2477
2820
  });
2478
- return this.toCollection(props.projectId, collectionFile);
2821
+ return this.toCollection(collectionFile);
2479
2822
  }
2480
2823
  /**
2481
- * Deletes given Collection (folder), including it's items
2824
+ * Deletes given Collection (folder), including it's Entries
2482
2825
  *
2483
2826
  * The Fields that Collection used are not deleted.
2484
2827
  */
@@ -2495,6 +2838,9 @@ var CollectionService = class extends AbstractCrudService {
2495
2838
  id: props.id
2496
2839
  }
2497
2840
  });
2841
+ const index = await this.getIndex(props.projectId);
2842
+ delete index[props.id];
2843
+ await this.writeIndex(props.projectId, index);
2498
2844
  }
2499
2845
  async list(props) {
2500
2846
  listCollectionsSchema.parse(props);
@@ -2529,7 +2875,8 @@ var CollectionService = class extends AbstractCrudService {
2529
2875
  * Migrates an potentially outdated Collection file to the current schema
2530
2876
  */
2531
2877
  migrate(potentiallyOutdatedCollectionFile) {
2532
- return collectionFileSchema.parse(potentiallyOutdatedCollectionFile);
2878
+ const migrated = applyMigrations(migrateCollectionSchema.parse(potentiallyOutdatedCollectionFile), collectionMigrations, this.coreVersion);
2879
+ return collectionFileSchema.parse(migrated);
2533
2880
  }
2534
2881
  /**
2535
2882
  * Creates an Collection from given CollectionFile
@@ -2537,26 +2884,83 @@ var CollectionService = class extends AbstractCrudService {
2537
2884
  * @param projectId The project's ID
2538
2885
  * @param collectionFile The CollectionFile to convert
2539
2886
  */
2540
- async toCollection(projectId, collectionFile) {
2541
- const history = await this.gitService.log(pathTo.project(projectId), { filePath: pathTo.collectionFile(projectId, collectionFile.id) });
2542
- return {
2543
- ...collectionFile,
2544
- history
2545
- };
2887
+ toCollection(collectionFile) {
2888
+ return { ...collectionFile };
2546
2889
  }
2547
- };
2548
-
2549
- //#endregion
2890
+ /**
2891
+ * Gets the collection index, rebuilding from disk if not cached
2892
+ */
2893
+ async getIndex(projectId) {
2894
+ const cached = this.cachedIndex.get(projectId);
2895
+ if (cached) return cached;
2896
+ const pending = this.rebuildPromise.get(projectId);
2897
+ if (pending) return pending;
2898
+ const promise = this.rebuildIndex(projectId);
2899
+ this.rebuildPromise.set(projectId, promise);
2900
+ const result = await promise;
2901
+ this.cachedIndex.set(projectId, result);
2902
+ this.rebuildPromise.delete(projectId);
2903
+ return result;
2904
+ }
2905
+ /**
2906
+ * Writes the index file atomically and updates cache
2907
+ */
2908
+ async writeIndex(projectId, index) {
2909
+ const indexPath = pathTo.collectionIndex(projectId);
2910
+ await Fs.writeFile(indexPath, JSON.stringify(index, null, 2), { encoding: "utf8" });
2911
+ this.cachedIndex.set(projectId, index);
2912
+ }
2913
+ /**
2914
+ * Rebuilds the index by scanning all collection folders
2915
+ */
2916
+ async rebuildIndex(projectId) {
2917
+ this.logService.info({
2918
+ source: "core",
2919
+ message: `Rebuilding Collection index for Project "${projectId}"`
2920
+ });
2921
+ const index = {};
2922
+ const collectionFolders = await folders(pathTo.collections(projectId));
2923
+ for (const folder of collectionFolders) {
2924
+ if (!uuidSchema.safeParse(folder.name).success) continue;
2925
+ try {
2926
+ const collectionFilePath = pathTo.collectionFile(projectId, folder.name);
2927
+ const collectionFile = await this.jsonFileService.read(collectionFilePath, collectionFileSchema);
2928
+ index[collectionFile.id] = collectionFile.slug.plural;
2929
+ } catch (error) {
2930
+ this.logService.warn({
2931
+ source: "core",
2932
+ message: `Skipping collection folder "${folder.name}" during index rebuild: ${error instanceof Error ? error.message : String(error)}`
2933
+ });
2934
+ }
2935
+ }
2936
+ await this.writeIndex(projectId, index);
2937
+ return index;
2938
+ }
2939
+ /**
2940
+ * Validates that no two fieldDefinitions share the same slug
2941
+ */
2942
+ validateFieldDefinitionSlugUniqueness(fieldDefinitions) {
2943
+ const seen = /* @__PURE__ */ new Set();
2944
+ for (const fd of fieldDefinitions) {
2945
+ if (seen.has(fd.slug)) throw new Error(`Duplicate fieldDefinition slug "${fd.slug}": each fieldDefinition within a collection must have a unique slug`);
2946
+ seen.add(fd.slug);
2947
+ }
2948
+ }
2949
+ };
2950
+
2951
+ //#endregion
2550
2952
  //#region src/service/EntryService.ts
2551
2953
  /**
2552
2954
  * Service that manages CRUD functionality for Entry files on disk
2553
2955
  */
2554
2956
  var EntryService = class extends AbstractCrudService {
2957
+ coreVersion;
2555
2958
  jsonFileService;
2556
2959
  gitService;
2557
2960
  collectionService;
2558
- constructor(options, logService, jsonFileService, gitService, collectionService) {
2961
+ constructor(coreVersion, options, logService, jsonFileService, gitService, collectionService) {
2559
2962
  super(serviceTypeSchema.enum.Entry, options, logService);
2963
+ this.coreVersion = coreVersion;
2560
2964
  this.jsonFileService = jsonFileService;
2561
2965
  this.gitService = gitService;
2562
2966
  this.collectionService = collectionService;
@@ -2576,11 +2980,12 @@ var EntryService = class extends AbstractCrudService {
2576
2980
  const entryFile = {
2577
2981
  objectType: "entry",
2578
2982
  id,
2983
+ coreVersion: this.coreVersion,
2579
2984
  values: props.values,
2580
2985
  created: datetime(),
2581
2986
  updated: null
2582
2987
  };
2583
- const entry = await this.toEntry(props.projectId, props.collectionId, entryFile);
2988
+ const entry = this.toEntry(entryFile);
2584
2989
  getCreateEntrySchemaFromFieldDefinitions(collection.fieldDefinitions).parse(props);
2585
2990
  await this.jsonFileService.create(entryFile, entryFilePath, entryFileSchema);
2586
2991
  await this.gitService.add(projectPath, [entryFilePath]);
@@ -2603,13 +3008,20 @@ var EntryService = class extends AbstractCrudService {
2603
3008
  readEntrySchema.parse(props);
2604
3009
  if (!props.commitHash) {
2605
3010
  const entryFile = await this.jsonFileService.read(pathTo.entryFile(props.projectId, props.collectionId, props.id), entryFileSchema);
2606
- return this.toEntry(props.projectId, props.collectionId, entryFile);
3011
+ return this.toEntry(entryFile);
2607
3012
  } else {
2608
3013
  const entryFile = this.migrate(JSON.parse(await this.gitService.getFileContentAtCommit(pathTo.project(props.projectId), pathTo.entryFile(props.projectId, props.collectionId, props.id), props.commitHash)));
2609
- return this.toEntry(props.projectId, props.collectionId, entryFile);
3014
+ return this.toEntry(entryFile);
2610
3015
  }
2611
3016
  }
2612
3017
  /**
3018
+ * Returns the commit history of an Entry
3019
+ */
3020
+ async history(props) {
3021
+ entryHistorySchema.parse(props);
3022
+ return this.gitService.log(pathTo.project(props.projectId), { filePath: pathTo.entryFile(props.projectId, props.collectionId, props.id) });
3023
+ }
3024
+ /**
2613
3025
  * Updates an Entry of given Collection with new Values and shared Values
2614
3026
  */
2615
3027
  async update(props) {
@@ -2629,7 +3041,7 @@ var EntryService = class extends AbstractCrudService {
2629
3041
  values: props.values,
2630
3042
  updated: datetime()
2631
3043
  };
2632
- const entry = await this.toEntry(props.projectId, props.collectionId, entryFile);
3044
+ const entry = this.toEntry(entryFile);
2633
3045
  getUpdateEntrySchemaFromFieldDefinitions(collection.fieldDefinitions).parse(props);
2634
3046
  await this.jsonFileService.update(entryFile, entryFilePath, entryFileSchema);
2635
3047
  await this.gitService.add(projectPath, [entryFilePath]);
@@ -2695,17 +3107,14 @@ var EntryService = class extends AbstractCrudService {
2695
3107
  * Migrates an potentially outdated Entry file to the current schema
2696
3108
  */
2697
3109
  migrate(potentiallyOutdatedEntryFile) {
2698
- return entryFileSchema.parse(potentiallyOutdatedEntryFile);
3110
+ const migrated = applyMigrations(migrateEntrySchema.parse(potentiallyOutdatedEntryFile), entryMigrations, this.coreVersion);
3111
+ return entryFileSchema.parse(migrated);
2699
3112
  }
2700
3113
  /**
2701
3114
  * Creates an Entry from given EntryFile by resolving it's Values
2702
3115
  */
2703
- async toEntry(projectId, collectionId, entryFile) {
2704
- const history = await this.gitService.log(pathTo.project(projectId), { filePath: pathTo.entryFile(projectId, collectionId, entryFile.id) });
2705
- return {
2706
- ...entryFile,
2707
- history
2708
- };
3116
+ toEntry(entryFile) {
3117
+ return { ...entryFile };
2709
3118
  }
2710
3119
  };
2711
3120
 
@@ -2734,10 +3143,11 @@ var GitTagService = class extends AbstractCrudService {
2734
3143
  id
2735
3144
  ];
2736
3145
  if (props.hash) args = [...args, props.hash];
3146
+ const fullMessage = `${this.serializeTagMessage(props.message)}\n\n${this.tagMessageToTrailers(props.message).join("\n")}`;
2737
3147
  args = [
2738
3148
  ...args,
2739
3149
  "-m",
2740
- props.message
3150
+ fullMessage
2741
3151
  ];
2742
3152
  await this.git(props.path, args);
2743
3153
  return await this.read({
@@ -2796,27 +3206,33 @@ var GitTagService = class extends AbstractCrudService {
2796
3206
  async list(props) {
2797
3207
  listGitTagsSchema.parse(props);
2798
3208
  let args = ["tag", "--list"];
3209
+ const format = [
3210
+ "%(refname:short)",
3211
+ "%(trailers:key=Type,valueonly)",
3212
+ "%(trailers:key=Version,valueonly)",
3213
+ "%(trailers:key=Core-Version,valueonly)",
3214
+ "%(*authorname)",
3215
+ "%(*authoremail)",
3216
+ "%(*authordate:iso-strict)"
3217
+ ].join("|");
2799
3218
  args = [
2800
3219
  ...args,
2801
3220
  "--sort=-*authordate",
2802
- "--format=%(refname:short)|%(subject)|%(*authorname)|%(*authoremail)|%(*authordate:iso-strict)"
3221
+ `--format=${format}`
2803
3222
  ];
2804
- const gitTags = (await this.git(props.path, args)).stdout.split("\n").filter((line) => {
3223
+ const gitTags = (await this.git(props.path, args)).stdout.replace(/\n\|/g, "|").split("\n").filter((line) => {
2805
3224
  return line.trim() !== "";
2806
3225
  }).map((line) => {
2807
3226
  const lineArray = line.split("|");
2808
- if (lineArray[3]?.startsWith("<") && lineArray[3]?.endsWith(">")) {
2809
- lineArray[3] = lineArray[3].slice(1, -1);
2810
- lineArray[3] = lineArray[3].slice(0, -1);
2811
- }
3227
+ if (lineArray[5]?.startsWith("<") && lineArray[5]?.endsWith(">")) lineArray[5] = lineArray[5].slice(1, -1);
2812
3228
  return {
2813
3229
  id: lineArray[0],
2814
- message: lineArray[1],
3230
+ message: this.parseTagTrailers(lineArray[1]?.trim(), lineArray[2]?.trim(), lineArray[3]?.trim()),
2815
3231
  author: {
2816
- name: lineArray[2],
2817
- email: lineArray[3]
3232
+ name: lineArray[4],
3233
+ email: lineArray[5]
2818
3234
  },
2819
- datetime: datetime(lineArray[4])
3235
+ datetime: datetime(lineArray[6])
2820
3236
  };
2821
3237
  }).filter(this.isGitTag.bind(this));
2822
3238
  return {
@@ -2839,6 +3255,43 @@ var GitTagService = class extends AbstractCrudService {
2839
3255
  return (await this.list({ path: props.path })).total;
2840
3256
  }
2841
3257
  /**
3258
+ * Serializes a GitTagMessage into a human-readable subject line
3259
+ */
3260
+ serializeTagMessage(message) {
3261
+ return `${message.type.charAt(0).toUpperCase() + message.type.slice(1)} ${message.type === "upgrade" ? message.coreVersion : message.version}`;
3262
+ }
3263
+ /**
3264
+ * Converts a GitTagMessage into git trailer lines
3265
+ */
3266
+ tagMessageToTrailers(message) {
3267
+ const trailers = [`Type: ${message.type}`];
3268
+ if (message.type === "upgrade") trailers.push(`Core-Version: ${message.coreVersion}`);
3269
+ else trailers.push(`Version: ${message.version}`);
3270
+ return trailers;
3271
+ }
3272
+ /**
3273
+ * Parses git trailer values back into a GitTagMessage
3274
+ */
3275
+ parseTagTrailers(type, version, coreVersion) {
3276
+ switch (type) {
3277
+ case "upgrade": return gitTagMessageSchema.parse({
3278
+ type,
3279
+ coreVersion
3280
+ });
3281
+ case "release":
3282
+ case "preview": return gitTagMessageSchema.parse({
3283
+ type,
3284
+ version
3285
+ });
3286
+ default:
3287
+ this.logService.warn({
3288
+ source: "core",
3289
+ message: `Tag with ID "${type}" has an invalid or missing Type trailer and will be ignored`
3290
+ });
3291
+ return null;
3292
+ }
3293
+ }
3294
+ /**
2842
3295
  * Type guard for GitTag
2843
3296
  *
2844
3297
  * @param obj The object to check
@@ -3128,9 +3581,16 @@ var GitService = class {
3128
3581
  gitMessageSchema.parse(message);
3129
3582
  const user = await this.userService.get();
3130
3583
  if (!user) throw new NoCurrentUserError();
3584
+ const subject = `${message.method.charAt(0).toUpperCase() + message.method.slice(1)} ${message.reference.objectType} ${message.reference.id}`;
3585
+ const trailers = [
3586
+ `Method: ${message.method}`,
3587
+ `Object-Type: ${message.reference.objectType}`,
3588
+ `Object-Id: ${message.reference.id}`
3589
+ ];
3590
+ if (message.reference.collectionId) trailers.push(`Collection-Id: ${message.reference.collectionId}`);
3131
3591
  const args = [
3132
3592
  "commit",
3133
- `--message=${JSON.stringify(message)}`,
3593
+ `--message=${`${subject}\n\n${trailers.join("\n")}`}`,
3134
3594
  `--author=${user.name} <${user.email}>`
3135
3595
  ];
3136
3596
  await this.git(path, args);
@@ -3150,33 +3610,52 @@ var GitService = class {
3150
3610
  let args = ["log"];
3151
3611
  if (options?.between?.from) args = [...args, `${options.between.from}..${options.between.to || "HEAD"}`];
3152
3612
  if (options?.limit) args = [...args, `--max-count=${options.limit}`];
3153
- args = [...args, "--format=%H|%s|%an|%ae|%aI|%D"];
3613
+ const format = [
3614
+ "%H",
3615
+ "%(trailers:key=Method,valueonly)",
3616
+ "%(trailers:key=Object-Type,valueonly)",
3617
+ "%(trailers:key=Object-Id,valueonly)",
3618
+ "%(trailers:key=Collection-Id,valueonly)",
3619
+ "%an",
3620
+ "%ae",
3621
+ "%aI",
3622
+ "%D"
3623
+ ].join("|");
3624
+ args = [...args, `--format=${format}`];
3154
3625
  if (options?.filePath) args = [
3155
3626
  ...args,
3156
3627
  "--",
3157
3628
  options.filePath
3158
3629
  ];
3159
- const noEmptyLinesArr = (await this.git(path, args)).stdout.split("\n").filter((line) => {
3630
+ const noEmptyLinesArr = (await this.git(path, args)).stdout.replace(/\n\|/g, "|").split("\n").filter((line) => {
3160
3631
  return line.trim() !== "";
3161
3632
  });
3162
3633
  return (await Promise.all(noEmptyLinesArr.map(async (line) => {
3163
3634
  const lineArray = line.split("|");
3164
- const tagId = this.refNameToTagName(lineArray[5] || "");
3635
+ const tagId = this.refNameToTagName(lineArray[8]?.trim() || "");
3165
3636
  const tag = tagId ? await this.tags.read({
3166
3637
  path,
3167
3638
  id: tagId
3168
3639
  }) : null;
3640
+ const collectionId = lineArray[4]?.trim();
3169
3641
  return {
3170
3642
  hash: lineArray[0],
3171
- message: JSON.parse(lineArray[1] || ""),
3643
+ message: {
3644
+ method: lineArray[1]?.trim(),
3645
+ reference: {
3646
+ objectType: lineArray[2]?.trim(),
3647
+ id: lineArray[3]?.trim(),
3648
+ ...collectionId ? { collectionId } : {}
3649
+ }
3650
+ },
3172
3651
  author: {
3173
- name: lineArray[2],
3174
- email: lineArray[3]
3652
+ name: lineArray[5],
3653
+ email: lineArray[6]
3175
3654
  },
3176
- datetime: datetime(lineArray[4]),
3655
+ datetime: datetime(lineArray[7]),
3177
3656
  tag
3178
3657
  };
3179
- }))).filter(this.isGitCommit.bind(this));
3658
+ }))).filter((obj) => this.isGitCommit(obj));
3180
3659
  }
3181
3660
  /**
3182
3661
  * Retrieves the content of a file at a specific commit
@@ -3190,6 +3669,35 @@ var GitService = class {
3190
3669
  };
3191
3670
  return (await this.git(path, args, { processCallback: setEncoding })).stdout;
3192
3671
  }
3672
+ /**
3673
+ * Lists directory entries at a specific commit
3674
+ *
3675
+ * Useful for discovering what files/folders existed at a past commit,
3676
+ * e.g. to detect deleted collections when comparing branches.
3677
+ *
3678
+ * @see https://git-scm.com/docs/git-ls-tree
3679
+ *
3680
+ * @param path Path to the repository
3681
+ * @param treePath Relative path within the repository to list
3682
+ * @param commitRef Commit hash, branch name, or other git ref
3683
+ */
3684
+ async listTreeAtCommit(path, treePath, commitRef) {
3685
+ const args = [
3686
+ "ls-tree",
3687
+ "--name-only",
3688
+ commitRef,
3689
+ `${treePath.replace(`${path}${Path.sep}`, "").split("\\").join("/")}/`
3690
+ ];
3691
+ try {
3692
+ return (await this.git(path, args)).stdout.split("\n").map((line) => line.trim()).filter((line) => line !== "").map((entry) => {
3693
+ const parts = entry.split("/");
3694
+ return parts[parts.length - 1] || entry;
3695
+ });
3696
+ } catch (error) {
3697
+ if (error instanceof GitError) return [];
3698
+ throw error;
3699
+ }
3700
+ }
3193
3701
  refNameToTagName(refName) {
3194
3702
  const tagName = refName.replace("tag: ", "").trim();
3195
3703
  if (tagName === "" || uuidSchema.safeParse(tagName).success === false) return null;
@@ -3558,7 +4066,6 @@ var ProjectService = class extends AbstractCrudService {
3558
4066
  created: datetime(),
3559
4067
  updated: null,
3560
4068
  coreVersion: this.coreVersion,
3561
- status: "todo",
3562
4069
  version: "0.0.1"
3563
4070
  };
3564
4071
  const projectPath = pathTo.project(id);
@@ -3612,11 +4119,23 @@ var ProjectService = class extends AbstractCrudService {
3612
4119
  const projectFile = await this.jsonFileService.read(pathTo.projectFile(props.id), projectFileSchema);
3613
4120
  return await this.toProject(projectFile);
3614
4121
  } else {
3615
- const projectFile = this.migrate(migrateProjectSchema.parse(JSON.parse(await this.gitService.getFileContentAtCommit(pathTo.project(props.id), pathTo.projectFile(props.id), props.commitHash))));
4122
+ const projectFile = this.migrate(JSON.parse(await this.gitService.getFileContentAtCommit(pathTo.project(props.id), pathTo.projectFile(props.id), props.commitHash)));
3616
4123
  return await this.toProject(projectFile);
3617
4124
  }
3618
4125
  }
3619
4126
  /**
4127
+ * Returns the commit history of a Project
4128
+ */
4129
+ async history(props) {
4130
+ projectHistorySchema.parse(props);
4131
+ const projectPath = pathTo.project(props.id);
4132
+ const fullHistory = await this.gitService.log(projectPath);
4133
+ return {
4134
+ history: await this.gitService.log(projectPath, { filePath: pathTo.projectFile(props.id) }),
4135
+ fullHistory
4136
+ };
4137
+ }
4138
+ /**
3620
4139
  * Updates given Project
3621
4140
  */
3622
4141
  async update(props) {
@@ -3649,7 +4168,7 @@ var ProjectService = class extends AbstractCrudService {
3649
4168
  const projectPath = pathTo.project(props.id);
3650
4169
  const projectFilePath = pathTo.projectFile(props.id);
3651
4170
  if (await this.gitService.branches.current(projectPath) !== projectBranchSchema.enum.work) await this.gitService.branches.switch(projectPath, projectBranchSchema.enum.work);
3652
- const currentProjectFile = migrateProjectSchema.parse(await this.jsonFileService.unsafeRead(projectFilePath));
4171
+ const currentProjectFile = await this.jsonFileService.unsafeRead(projectFilePath);
3653
4172
  if (Semver.gt(currentProjectFile.coreVersion, this.coreVersion)) throw new ProjectUpgradeError(`The Projects Core version "${currentProjectFile.coreVersion}" is higher than the current Core version "${this.coreVersion}".`);
3654
4173
  if (Semver.eq(currentProjectFile.coreVersion, this.coreVersion) && props.force !== true) throw new ProjectUpgradeError(`The Projects Core version "${currentProjectFile.coreVersion}" is already up to date.`);
3655
4174
  const assetReferences = await this.listReferences("asset", props.id);
@@ -3686,7 +4205,10 @@ var ProjectService = class extends AbstractCrudService {
3686
4205
  });
3687
4206
  await this.gitService.tags.create({
3688
4207
  path: projectPath,
3689
- message: `Upgraded Project to Core version ${migratedProjectFile.coreVersion}`
4208
+ message: {
4209
+ type: "upgrade",
4210
+ coreVersion: migratedProjectFile.coreVersion
4211
+ }
3690
4212
  });
3691
4213
  await this.gitService.branches.delete(projectPath, upgradeBranchName, true);
3692
4214
  this.logService.info({
@@ -3792,7 +4314,7 @@ var ProjectService = class extends AbstractCrudService {
3792
4314
  return (await Promise.all(projectReferences.map(async (reference) => {
3793
4315
  const json = await this.jsonFileService.unsafeRead(pathTo.projectFile(reference.id));
3794
4316
  const projectFile = migrateProjectSchema.parse(json);
3795
- if (projectFile.coreVersion !== this.coreVersion) return projectFile;
4317
+ if (projectFile.coreVersion !== this.coreVersion) return this.migrate(projectFile);
3796
4318
  return null;
3797
4319
  }))).filter(isNotEmpty);
3798
4320
  }
@@ -3824,9 +4346,9 @@ var ProjectService = class extends AbstractCrudService {
3824
4346
  /**
3825
4347
  * Migrates an potentially outdated Project file to the current schema
3826
4348
  */
3827
- migrate(props) {
3828
- props.coreVersion = this.coreVersion;
3829
- return projectFileSchema.parse(props);
4349
+ migrate(potentiallyOutdatedFile) {
4350
+ const migrated = applyMigrations(migrateProjectSchema.parse(potentiallyOutdatedFile), projectMigrations, this.coreVersion);
4351
+ return projectFileSchema.parse(migrated);
3830
4352
  }
3831
4353
  /**
3832
4354
  * Creates a Project from given ProjectFile
@@ -3835,13 +4357,9 @@ var ProjectService = class extends AbstractCrudService {
3835
4357
  const projectPath = pathTo.project(projectFile.id);
3836
4358
  let remoteOriginUrl = null;
3837
4359
  if (await this.gitService.remotes.hasOrigin(projectPath)) remoteOriginUrl = await this.gitService.remotes.getOriginUrl(projectPath);
3838
- const fullHistory = await this.gitService.log(pathTo.project(projectFile.id));
3839
- const history = await this.gitService.log(pathTo.project(projectFile.id), { filePath: pathTo.projectFile(projectFile.id) });
3840
4360
  return {
3841
4361
  ...projectFile,
3842
- remoteOriginUrl,
3843
- history,
3844
- fullHistory
4362
+ remoteOriginUrl
3845
4363
  };
3846
4364
  }
3847
4365
  /**
@@ -3871,7 +4389,8 @@ var ProjectService = class extends AbstractCrudService {
3871
4389
  "!/.gitattributes",
3872
4390
  "!/**/.gitkeep",
3873
4391
  "",
3874
- "# elek.io related ignores"
4392
+ "# elek.io related ignores",
4393
+ "collections/index.json"
3875
4394
  ].join(Os.EOL));
3876
4395
  }
3877
4396
  async upgradeObjectFile(projectId, objectType, reference, collectionId) {
@@ -3937,6 +4456,665 @@ var ProjectService = class extends AbstractCrudService {
3937
4456
  }
3938
4457
  };
3939
4458
 
4459
+ //#endregion
4460
+ //#region src/service/ReleaseService.ts
4461
+ /**
4462
+ * Service that manages Release functionality
4463
+ *
4464
+ * A release diffs the current `work` branch against the `production` branch
4465
+ * to determine what changed, computes a semver bump, and merges work into production.
4466
+ */
4467
+ var ReleaseService = class extends AbstractCrudService {
4468
+ gitService;
4469
+ jsonFileService;
4470
+ projectService;
4471
+ constructor(options, logService, gitService, jsonFileService, projectService) {
4472
+ super(serviceTypeSchema.enum.Release, options, logService);
4473
+ this.gitService = gitService;
4474
+ this.jsonFileService = jsonFileService;
4475
+ this.projectService = projectService;
4476
+ }
4477
+ /**
4478
+ * Prepares a release by diffing the current `work` branch against `production`.
4479
+ *
4480
+ * Returns a read-only summary of all changes and the computed next version.
4481
+ * If there are no changes, the next version and bump will be null.
4482
+ */
4483
+ async prepare(props) {
4484
+ prepareReleaseSchema.parse(props);
4485
+ const projectPath = pathTo.project(props.projectId);
4486
+ const currentBranch = await this.gitService.branches.current(projectPath);
4487
+ if (currentBranch !== projectBranchSchema.enum.work) throw new Error(`Not on work branch (currently on "${currentBranch}")`);
4488
+ const project = await this.projectService.read({ id: props.projectId });
4489
+ const currentVersion = project.version;
4490
+ const productionRef = projectBranchSchema.enum.production;
4491
+ const productionProject = await this.getProjectAtRef(props.projectId, projectPath, productionRef);
4492
+ const projectDiff = this.diffProject(project, productionProject);
4493
+ const currentCollections = await this.getCollectionsAtRef(props.projectId, projectPath, projectBranchSchema.enum.work);
4494
+ const productionCollections = await this.getCollectionsAtRef(props.projectId, projectPath, productionRef);
4495
+ const collectionDiff = this.diffCollections(currentCollections, productionCollections);
4496
+ const currentAssets = await this.getAssetsAtRef(props.projectId, projectPath, projectBranchSchema.enum.work);
4497
+ const productionAssets = await this.getAssetsAtRef(props.projectId, projectPath, productionRef);
4498
+ const assetDiff = this.diffAssets(currentAssets, productionAssets);
4499
+ const allCollectionIds = new Set([...currentCollections.map((c) => c.id), ...productionCollections.map((c) => c.id)]);
4500
+ const entryDiff = await this.diffEntries(props.projectId, projectPath, allCollectionIds, productionRef);
4501
+ let finalBump = null;
4502
+ for (const bump of [
4503
+ projectDiff.bump,
4504
+ collectionDiff.bump,
4505
+ assetDiff.bump,
4506
+ entryDiff.bump
4507
+ ]) if (bump) finalBump = finalBump ? this.higherBump(finalBump, bump) : bump;
4508
+ if (!finalBump) {
4509
+ if (await this.hasCommitsBetween(projectPath, productionRef, projectBranchSchema.enum.work)) finalBump = "patch";
4510
+ }
4511
+ const nextVersion = finalBump ? Semver.inc(currentVersion, finalBump) : null;
4512
+ return {
4513
+ project,
4514
+ bump: finalBump,
4515
+ currentVersion,
4516
+ nextVersion,
4517
+ projectChanges: projectDiff.projectChanges,
4518
+ collectionChanges: collectionDiff.collectionChanges,
4519
+ fieldChanges: collectionDiff.fieldChanges,
4520
+ assetChanges: assetDiff.assetChanges,
4521
+ entryChanges: entryDiff.entryChanges
4522
+ };
4523
+ }
4524
+ /**
4525
+ * Creates a release by:
4526
+ * 1. Recomputing the diff (stateless)
4527
+ * 2. Merging `work` into `production`
4528
+ * 3. Updating the project version on `production`
4529
+ * 4. Tagging on `production`
4530
+ * 5. Merging `production` back into `work` (fast-forward to sync the version commit)
4531
+ * 6. Switching back to `work`
4532
+ */
4533
+ async create(props) {
4534
+ createReleaseSchema.parse(props);
4535
+ const projectPath = pathTo.project(props.projectId);
4536
+ const projectFilePath = pathTo.projectFile(props.projectId);
4537
+ const diff = await this.prepare(props);
4538
+ if (!diff.bump || !diff.nextVersion) throw new Error("Cannot create a release: no changes detected since the last full release");
4539
+ const nextVersion = diff.nextVersion;
4540
+ try {
4541
+ await this.gitService.branches.switch(projectPath, projectBranchSchema.enum.production);
4542
+ await this.gitService.merge(projectPath, projectBranchSchema.enum.work);
4543
+ const updatedProjectFile = {
4544
+ ...diff.project,
4545
+ version: nextVersion,
4546
+ updated: datetime()
4547
+ };
4548
+ await this.jsonFileService.update(updatedProjectFile, projectFilePath, projectFileSchema);
4549
+ await this.gitService.add(projectPath, [projectFilePath]);
4550
+ await this.gitService.commit(projectPath, {
4551
+ method: "release",
4552
+ reference: {
4553
+ objectType: "project",
4554
+ id: props.projectId
4555
+ }
4556
+ });
4557
+ await this.gitService.tags.create({
4558
+ path: projectPath,
4559
+ message: {
4560
+ type: "release",
4561
+ version: nextVersion
4562
+ }
4563
+ });
4564
+ await this.gitService.branches.switch(projectPath, projectBranchSchema.enum.work);
4565
+ await this.gitService.merge(projectPath, projectBranchSchema.enum.production);
4566
+ } catch (error) {
4567
+ await this.gitService.branches.switch(projectPath, projectBranchSchema.enum.work).catch(() => {});
4568
+ throw error;
4569
+ }
4570
+ this.logService.info({
4571
+ source: "core",
4572
+ message: `Released version ${nextVersion} (${diff.bump} bump)`
4573
+ });
4574
+ return {
4575
+ version: nextVersion,
4576
+ diff
4577
+ };
4578
+ }
4579
+ /**
4580
+ * Creates a preview release by:
4581
+ * 1. Recomputing the diff (stateless)
4582
+ * 2. Computing the preview version (e.g. 1.1.0-preview.3)
4583
+ * 3. Updating the project version on `work`
4584
+ * 4. Tagging on `work` (no merge into production)
4585
+ *
4586
+ * Preview releases are snapshots of the current work state.
4587
+ * They don't promote to production — only full releases do.
4588
+ */
4589
+ async createPreview(props) {
4590
+ createPreviewReleaseSchema.parse(props);
4591
+ const projectPath = pathTo.project(props.projectId);
4592
+ const projectFilePath = pathTo.projectFile(props.projectId);
4593
+ const diff = await this.prepare(props);
4594
+ if (!diff.bump || !diff.nextVersion) throw new Error("Cannot create a preview release: no changes detected since the last full release");
4595
+ const previewNumber = await this.countPreviewsSinceLastRelease(projectPath, diff.nextVersion);
4596
+ const previewVersion = `${diff.nextVersion}-preview.${previewNumber + 1}`;
4597
+ try {
4598
+ const updatedProjectFile = {
4599
+ ...diff.project,
4600
+ version: previewVersion,
4601
+ updated: datetime()
4602
+ };
4603
+ await this.jsonFileService.update(updatedProjectFile, projectFilePath, projectFileSchema);
4604
+ await this.gitService.add(projectPath, [projectFilePath]);
4605
+ await this.gitService.commit(projectPath, {
4606
+ method: "release",
4607
+ reference: {
4608
+ objectType: "project",
4609
+ id: props.projectId
4610
+ }
4611
+ });
4612
+ await this.gitService.tags.create({
4613
+ path: projectPath,
4614
+ message: {
4615
+ type: "preview",
4616
+ version: previewVersion
4617
+ }
4618
+ });
4619
+ } catch (error) {
4620
+ await this.gitService.branches.switch(projectPath, projectBranchSchema.enum.work).catch(() => {});
4621
+ throw error;
4622
+ }
4623
+ this.logService.info({
4624
+ source: "core",
4625
+ message: `Preview released version ${previewVersion} (${diff.bump} bump)`
4626
+ });
4627
+ return {
4628
+ version: previewVersion,
4629
+ diff
4630
+ };
4631
+ }
4632
+ /**
4633
+ * Reads the project file as it exists at a given git ref
4634
+ */
4635
+ async getProjectAtRef(projectId, projectPath, ref) {
4636
+ try {
4637
+ const content = await this.gitService.getFileContentAtCommit(projectPath, pathTo.projectFile(projectId), ref);
4638
+ return projectFileSchema.parse(JSON.parse(content));
4639
+ } catch {
4640
+ return null;
4641
+ }
4642
+ }
4643
+ /**
4644
+ * Reads asset metadata files as they exist at a given git ref
4645
+ */
4646
+ async getAssetsAtRef(projectId, projectPath, ref) {
4647
+ const assetsPath = pathTo.assets(projectId);
4648
+ const fileNames = await this.gitService.listTreeAtCommit(projectPath, assetsPath, ref);
4649
+ const assets = [];
4650
+ for (const fileName of fileNames) {
4651
+ if (!fileName.endsWith(".json")) continue;
4652
+ const assetId = fileName.replace(".json", "");
4653
+ const assetFilePath = pathTo.assetFile(projectId, assetId);
4654
+ try {
4655
+ const content = await this.gitService.getFileContentAtCommit(projectPath, assetFilePath, ref);
4656
+ const assetFile = assetFileSchema.parse(JSON.parse(content));
4657
+ assets.push(assetFile);
4658
+ } catch {
4659
+ this.logService.debug({
4660
+ source: "core",
4661
+ message: `Skipping asset "${fileName}" at ref "${ref}" during release diff`
4662
+ });
4663
+ }
4664
+ }
4665
+ return assets;
4666
+ }
4667
+ /**
4668
+ * Reads entry files for a single collection as they exist at a given git ref
4669
+ */
4670
+ async getEntriesAtRef(projectId, projectPath, collectionId, ref) {
4671
+ const entriesPath = pathTo.entries(projectId, collectionId);
4672
+ const fileNames = await this.gitService.listTreeAtCommit(projectPath, entriesPath, ref);
4673
+ const entries = [];
4674
+ for (const fileName of fileNames) {
4675
+ if (!fileName.endsWith(".json") || fileName === "collection.json") continue;
4676
+ const entryId = fileName.replace(".json", "");
4677
+ const entryFilePath = pathTo.entryFile(projectId, collectionId, entryId);
4678
+ try {
4679
+ const content = await this.gitService.getFileContentAtCommit(projectPath, entryFilePath, ref);
4680
+ const entryFile = entryFileSchema.parse(JSON.parse(content));
4681
+ entries.push(entryFile);
4682
+ } catch {
4683
+ this.logService.debug({
4684
+ source: "core",
4685
+ message: `Skipping entry "${fileName}" in collection "${collectionId}" at ref "${ref}" during release diff`
4686
+ });
4687
+ }
4688
+ }
4689
+ return entries;
4690
+ }
4691
+ /**
4692
+ * Reads collections as they exist at a given git ref (branch or commit)
4693
+ */
4694
+ async getCollectionsAtRef(projectId, projectPath, ref) {
4695
+ const collectionsPath = pathTo.collections(projectId);
4696
+ const folderNames = await this.gitService.listTreeAtCommit(projectPath, collectionsPath, ref);
4697
+ const collections = [];
4698
+ for (const folderName of folderNames) {
4699
+ const collectionFilePath = pathTo.collectionFile(projectId, folderName);
4700
+ try {
4701
+ const content = await this.gitService.getFileContentAtCommit(projectPath, collectionFilePath, ref);
4702
+ const collectionFile = collectionFileSchema.parse(JSON.parse(content));
4703
+ collections.push(collectionFile);
4704
+ } catch {
4705
+ this.logService.debug({
4706
+ source: "core",
4707
+ message: `Skipping folder "${folderName}" at ref "${ref}" during release diff`
4708
+ });
4709
+ }
4710
+ }
4711
+ return collections;
4712
+ }
4713
+ /**
4714
+ * Checks if there are any commits between two refs
4715
+ */
4716
+ async hasCommitsBetween(projectPath, from, to) {
4717
+ try {
4718
+ return (await this.gitService.log(projectPath, { between: {
4719
+ from,
4720
+ to
4721
+ } })).length > 0;
4722
+ } catch {
4723
+ return true;
4724
+ }
4725
+ }
4726
+ /**
4727
+ * Diffs two sets of collections and returns all changes with the computed bump level.
4728
+ *
4729
+ * Always collects all changes so they can be displayed to the user.
4730
+ */
4731
+ diffCollections(currentCollections, productionCollections) {
4732
+ const collectionChanges = [];
4733
+ const fieldChanges = [];
4734
+ let highestBump = null;
4735
+ const currentById = new Map(currentCollections.map((c) => [c.id, c]));
4736
+ const productionById = new Map(productionCollections.map((c) => [c.id, c]));
4737
+ for (const [id] of productionById) if (!currentById.has(id)) {
4738
+ collectionChanges.push({
4739
+ collectionId: id,
4740
+ changeType: "deleted",
4741
+ bump: "major"
4742
+ });
4743
+ highestBump = "major";
4744
+ }
4745
+ for (const [id] of currentById) if (!productionById.has(id)) {
4746
+ collectionChanges.push({
4747
+ collectionId: id,
4748
+ changeType: "added",
4749
+ bump: "minor"
4750
+ });
4751
+ highestBump = this.higherBump(highestBump, "minor");
4752
+ }
4753
+ for (const [id, currentCollection] of currentById) {
4754
+ const productionCollection = productionById.get(id);
4755
+ if (!productionCollection) continue;
4756
+ const changes = this.diffFieldDefinitions(id, currentCollection.fieldDefinitions, productionCollection.fieldDefinitions);
4757
+ fieldChanges.push(...changes);
4758
+ for (const change of changes) highestBump = this.higherBump(highestBump, change.bump);
4759
+ }
4760
+ return {
4761
+ bump: highestBump,
4762
+ collectionChanges,
4763
+ fieldChanges
4764
+ };
4765
+ }
4766
+ /**
4767
+ * Diffs the project file between current and production.
4768
+ *
4769
+ * Skips immutable/system-managed fields (id, objectType, created, updated, version, coreVersion).
4770
+ */
4771
+ diffProject(current, production) {
4772
+ const projectChanges = [];
4773
+ if (!production) return {
4774
+ bump: null,
4775
+ projectChanges
4776
+ };
4777
+ let highestBump = null;
4778
+ if (current.settings.language.default !== production.settings.language.default) {
4779
+ projectChanges.push({
4780
+ changeType: "defaultLanguageChanged",
4781
+ bump: "major"
4782
+ });
4783
+ highestBump = "major";
4784
+ }
4785
+ const currentSupported = new Set(current.settings.language.supported);
4786
+ const productionSupported = new Set(production.settings.language.supported);
4787
+ for (const lang of productionSupported) if (!currentSupported.has(lang)) {
4788
+ projectChanges.push({
4789
+ changeType: "supportedLanguageRemoved",
4790
+ bump: "major"
4791
+ });
4792
+ highestBump = "major";
4793
+ break;
4794
+ }
4795
+ for (const lang of currentSupported) if (!productionSupported.has(lang)) {
4796
+ projectChanges.push({
4797
+ changeType: "supportedLanguageAdded",
4798
+ bump: "minor"
4799
+ });
4800
+ highestBump = this.higherBump(highestBump, "minor");
4801
+ break;
4802
+ }
4803
+ if (current.name !== production.name) {
4804
+ projectChanges.push({
4805
+ changeType: "nameChanged",
4806
+ bump: "patch"
4807
+ });
4808
+ highestBump = this.higherBump(highestBump, "patch");
4809
+ }
4810
+ if (current.description !== production.description) {
4811
+ projectChanges.push({
4812
+ changeType: "descriptionChanged",
4813
+ bump: "patch"
4814
+ });
4815
+ highestBump = this.higherBump(highestBump, "patch");
4816
+ }
4817
+ return {
4818
+ bump: highestBump,
4819
+ projectChanges
4820
+ };
4821
+ }
4822
+ /**
4823
+ * Diffs two sets of assets and returns all changes with the computed bump level.
4824
+ */
4825
+ diffAssets(currentAssets, productionAssets) {
4826
+ const assetChanges = [];
4827
+ let highestBump = null;
4828
+ const currentById = new Map(currentAssets.map((a) => [a.id, a]));
4829
+ const productionById = new Map(productionAssets.map((a) => [a.id, a]));
4830
+ for (const [id] of productionById) if (!currentById.has(id)) {
4831
+ assetChanges.push({
4832
+ assetId: id,
4833
+ changeType: "deleted",
4834
+ bump: "major"
4835
+ });
4836
+ highestBump = "major";
4837
+ }
4838
+ for (const [id] of currentById) if (!productionById.has(id)) {
4839
+ assetChanges.push({
4840
+ assetId: id,
4841
+ changeType: "added",
4842
+ bump: "minor"
4843
+ });
4844
+ highestBump = this.higherBump(highestBump, "minor");
4845
+ }
4846
+ for (const [id, current] of currentById) {
4847
+ const production = productionById.get(id);
4848
+ if (!production) continue;
4849
+ if (current.extension !== production.extension || current.mimeType !== production.mimeType || current.size !== production.size) {
4850
+ assetChanges.push({
4851
+ assetId: id,
4852
+ changeType: "binaryChanged",
4853
+ bump: "patch"
4854
+ });
4855
+ highestBump = this.higherBump(highestBump, "patch");
4856
+ }
4857
+ if (current.name !== production.name || current.description !== production.description) {
4858
+ assetChanges.push({
4859
+ assetId: id,
4860
+ changeType: "metadataChanged",
4861
+ bump: "patch"
4862
+ });
4863
+ highestBump = this.higherBump(highestBump, "patch");
4864
+ }
4865
+ }
4866
+ return {
4867
+ bump: highestBump,
4868
+ assetChanges
4869
+ };
4870
+ }
4871
+ /**
4872
+ * Diffs entries across all collections between current and production.
4873
+ */
4874
+ async diffEntries(projectId, projectPath, allCollectionIds, productionRef) {
4875
+ const entryChanges = [];
4876
+ let highestBump = null;
4877
+ for (const collectionId of allCollectionIds) {
4878
+ const currentEntries = await this.getEntriesAtRef(projectId, projectPath, collectionId, projectBranchSchema.enum.work);
4879
+ const productionEntries = await this.getEntriesAtRef(projectId, projectPath, collectionId, productionRef);
4880
+ const currentById = new Map(currentEntries.map((e) => [e.id, e]));
4881
+ const productionById = new Map(productionEntries.map((e) => [e.id, e]));
4882
+ for (const [id] of productionById) if (!currentById.has(id)) {
4883
+ entryChanges.push({
4884
+ collectionId,
4885
+ entryId: id,
4886
+ changeType: "deleted",
4887
+ bump: "major"
4888
+ });
4889
+ highestBump = "major";
4890
+ }
4891
+ for (const [id] of currentById) if (!productionById.has(id)) {
4892
+ entryChanges.push({
4893
+ collectionId,
4894
+ entryId: id,
4895
+ changeType: "added",
4896
+ bump: "minor"
4897
+ });
4898
+ highestBump = this.higherBump(highestBump, "minor");
4899
+ }
4900
+ for (const [id, current] of currentById) {
4901
+ const production = productionById.get(id);
4902
+ if (!production) continue;
4903
+ if (!isDeepStrictEqual(current.values, production.values)) {
4904
+ entryChanges.push({
4905
+ collectionId,
4906
+ entryId: id,
4907
+ changeType: "modified",
4908
+ bump: "patch"
4909
+ });
4910
+ highestBump = this.higherBump(highestBump, "patch");
4911
+ }
4912
+ }
4913
+ }
4914
+ return {
4915
+ bump: highestBump,
4916
+ entryChanges
4917
+ };
4918
+ }
4919
+ /**
4920
+ * Diffs field definitions of a single collection.
4921
+ *
4922
+ * Matches fields by UUID and classifies each change.
4923
+ * Always collects all changes so they can be displayed to the user.
4924
+ */
4925
+ diffFieldDefinitions(collectionId, currentFields, productionFields) {
4926
+ const changes = [];
4927
+ const currentById = new Map(currentFields.map((f) => [f.id, f]));
4928
+ const productionById = new Map(productionFields.map((f) => [f.id, f]));
4929
+ for (const [id, field] of productionById) if (!currentById.has(id)) changes.push({
4930
+ collectionId,
4931
+ fieldId: id,
4932
+ fieldSlug: field.slug,
4933
+ changeType: "deleted",
4934
+ bump: "major"
4935
+ });
4936
+ for (const [id, field] of currentById) if (!productionById.has(id)) changes.push({
4937
+ collectionId,
4938
+ fieldId: id,
4939
+ fieldSlug: field.slug,
4940
+ changeType: "added",
4941
+ bump: "minor"
4942
+ });
4943
+ for (const [id, currentField] of currentById) {
4944
+ const productionField = productionById.get(id);
4945
+ if (!productionField) continue;
4946
+ const fieldChanges = this.diffSingleField(collectionId, currentField, productionField);
4947
+ changes.push(...fieldChanges);
4948
+ }
4949
+ return changes;
4950
+ }
4951
+ /**
4952
+ * Compares two versions of the same field definition and returns all detected changes.
4953
+ *
4954
+ * Collects every change on the field so the full diff can be shown to the user.
4955
+ */
4956
+ diffSingleField(collectionId, current, production) {
4957
+ const changes = [];
4958
+ const base = {
4959
+ collectionId,
4960
+ fieldId: current.id,
4961
+ fieldSlug: current.slug
4962
+ };
4963
+ if (current.valueType !== production.valueType) changes.push({
4964
+ ...base,
4965
+ changeType: "valueTypeChanged",
4966
+ bump: "major"
4967
+ });
4968
+ if (current.fieldType !== production.fieldType) changes.push({
4969
+ ...base,
4970
+ changeType: "fieldTypeChanged",
4971
+ bump: "major"
4972
+ });
4973
+ if (current.slug !== production.slug) changes.push({
4974
+ ...base,
4975
+ changeType: "slugChanged",
4976
+ bump: "major"
4977
+ });
4978
+ if (this.isMinMaxTightened(current, production)) changes.push({
4979
+ ...base,
4980
+ changeType: "minMaxTightened",
4981
+ bump: "major"
4982
+ });
4983
+ if (production.isRequired === true && current.isRequired === false) changes.push({
4984
+ ...base,
4985
+ changeType: "isRequiredToNotRequired",
4986
+ bump: "major"
4987
+ });
4988
+ if (production.isUnique === true && current.isUnique === false) changes.push({
4989
+ ...base,
4990
+ changeType: "isUniqueToNotUnique",
4991
+ bump: "major"
4992
+ });
4993
+ if (current.fieldType === "entry" && production.fieldType === "entry") {
4994
+ if (!isDeepStrictEqual([...current.ofCollections].sort(), [...production.ofCollections].sort())) changes.push({
4995
+ ...base,
4996
+ changeType: "ofCollectionsChanged",
4997
+ bump: "major"
4998
+ });
4999
+ }
5000
+ if (production.isRequired === false && current.isRequired === true) changes.push({
5001
+ ...base,
5002
+ changeType: "isNotRequiredToRequired",
5003
+ bump: "minor"
5004
+ });
5005
+ if (production.isUnique === false && current.isUnique === true) changes.push({
5006
+ ...base,
5007
+ changeType: "isNotUniqueToUnique",
5008
+ bump: "minor"
5009
+ });
5010
+ if (this.isMinMaxLoosened(current, production)) changes.push({
5011
+ ...base,
5012
+ changeType: "minMaxLoosened",
5013
+ bump: "patch"
5014
+ });
5015
+ if (!isDeepStrictEqual(current.label, production.label)) changes.push({
5016
+ ...base,
5017
+ changeType: "labelChanged",
5018
+ bump: "patch"
5019
+ });
5020
+ if (!isDeepStrictEqual(current.description, production.description)) changes.push({
5021
+ ...base,
5022
+ changeType: "descriptionChanged",
5023
+ bump: "patch"
5024
+ });
5025
+ if ("defaultValue" in current && "defaultValue" in production && !isDeepStrictEqual(current.defaultValue, production.defaultValue)) changes.push({
5026
+ ...base,
5027
+ changeType: "defaultValueChanged",
5028
+ bump: "patch"
5029
+ });
5030
+ if (current.inputWidth !== production.inputWidth) changes.push({
5031
+ ...base,
5032
+ changeType: "inputWidthChanged",
5033
+ bump: "patch"
5034
+ });
5035
+ if (current.isDisabled !== production.isDisabled) changes.push({
5036
+ ...base,
5037
+ changeType: "isDisabledChanged",
5038
+ bump: "patch"
5039
+ });
5040
+ return changes;
5041
+ }
5042
+ /**
5043
+ * Checks if min/max constraints have been tightened.
5044
+ *
5045
+ * Tightening means: new min > old min, or new max < old max.
5046
+ * A null value means no constraint (unbounded).
5047
+ */
5048
+ isMinMaxTightened(current, production) {
5049
+ const currentMin = this.getMinMax(current, "min");
5050
+ const productionMin = this.getMinMax(production, "min");
5051
+ const currentMax = this.getMinMax(current, "max");
5052
+ const productionMax = this.getMinMax(production, "max");
5053
+ if (currentMin !== null && productionMin === null) return true;
5054
+ if (currentMin !== null && productionMin !== null && currentMin > productionMin) return true;
5055
+ if (currentMax !== null && productionMax === null) return true;
5056
+ if (currentMax !== null && productionMax !== null && currentMax < productionMax) return true;
5057
+ return false;
5058
+ }
5059
+ /**
5060
+ * Checks if min/max constraints have been loosened.
5061
+ *
5062
+ * Loosening means: new min < old min, or new max > old max.
5063
+ */
5064
+ isMinMaxLoosened(current, production) {
5065
+ const currentMin = this.getMinMax(current, "min");
5066
+ const productionMin = this.getMinMax(production, "min");
5067
+ const currentMax = this.getMinMax(current, "max");
5068
+ const productionMax = this.getMinMax(production, "max");
5069
+ if (currentMin === null && productionMin !== null) return true;
5070
+ if (currentMin !== null && productionMin !== null && currentMin < productionMin) return true;
5071
+ if (currentMax === null && productionMax !== null) return true;
5072
+ if (currentMax !== null && productionMax !== null && currentMax > productionMax) return true;
5073
+ return false;
5074
+ }
5075
+ /**
5076
+ * Safely extracts min or max from a field definition (not all types have it)
5077
+ */
5078
+ getMinMax(field, prop) {
5079
+ switch (field.fieldType) {
5080
+ case "text":
5081
+ case "textarea":
5082
+ case "number":
5083
+ case "range":
5084
+ case "asset":
5085
+ case "entry": return field[prop];
5086
+ default: return null;
5087
+ }
5088
+ }
5089
+ /**
5090
+ * Counts existing preview tags for a given base version since the last full release.
5091
+ */
5092
+ async countPreviewsSinceLastRelease(projectPath, baseVersion) {
5093
+ const tags = await this.gitService.tags.list({ path: projectPath });
5094
+ let count = 0;
5095
+ for (const tag of tags.list) {
5096
+ if (tag.message.type === "upgrade") continue;
5097
+ if (tag.message.type === "release") break;
5098
+ if (tag.message.type === "preview") {
5099
+ if (tag.message.version.split("-")[0] === baseVersion) count++;
5100
+ }
5101
+ }
5102
+ return count;
5103
+ }
5104
+ /**
5105
+ * Returns the higher of two bumps (major > minor > patch)
5106
+ */
5107
+ higherBump(a, b) {
5108
+ const order = {
5109
+ patch: 0,
5110
+ minor: 1,
5111
+ major: 2
5112
+ };
5113
+ if (a === null) return b;
5114
+ return order[a] >= order[b] ? a : b;
5115
+ }
5116
+ };
5117
+
3940
5118
  //#endregion
3941
5119
  //#region src/service/UserService.ts
3942
5120
  /**
@@ -3972,7 +5150,7 @@ var UserService = class {
3972
5150
  setUserSchema.parse(props);
3973
5151
  const userFilePath = pathTo.userFile;
3974
5152
  const userFile = { ...props };
3975
- if (userFile.userType === UserTypeSchema.enum.cloud) {}
5153
+ if (userFile.userType === userTypeSchema.enum.cloud) {}
3976
5154
  await this.jsonFileService.update(userFile, userFilePath, userFileSchema);
3977
5155
  this.logService.debug({
3978
5156
  source: "core",
@@ -4000,6 +5178,7 @@ var ElekIoCore = class {
4000
5178
  projectService;
4001
5179
  collectionService;
4002
5180
  entryService;
5181
+ releaseService;
4003
5182
  localApi;
4004
5183
  constructor(props) {
4005
5184
  this.coreVersion = package_default.version;
@@ -4012,10 +5191,11 @@ var ElekIoCore = class {
4012
5191
  this.jsonFileService = new JsonFileService(this.options, this.logService);
4013
5192
  this.userService = new UserService(this.logService, this.jsonFileService);
4014
5193
  this.gitService = new GitService(this.options, this.logService, this.userService);
4015
- this.assetService = new AssetService(this.options, this.logService, this.jsonFileService, this.gitService);
4016
- this.collectionService = new CollectionService(this.options, this.logService, this.jsonFileService, this.gitService);
4017
- this.entryService = new EntryService(this.options, this.logService, this.jsonFileService, this.gitService, this.collectionService);
5194
+ this.assetService = new AssetService(this.coreVersion, this.options, this.logService, this.jsonFileService, this.gitService);
5195
+ this.collectionService = new CollectionService(this.coreVersion, this.options, this.logService, this.jsonFileService, this.gitService);
5196
+ this.entryService = new EntryService(this.coreVersion, this.options, this.logService, this.jsonFileService, this.gitService, this.collectionService);
4018
5197
  this.projectService = new ProjectService(this.coreVersion, this.options, this.logService, this.jsonFileService, this.gitService, this.assetService, this.collectionService, this.entryService);
5198
+ this.releaseService = new ReleaseService(this.options, this.logService, this.gitService, this.jsonFileService, this.projectService);
4019
5199
  this.localApi = new LocalApi(this.logService, this.projectService, this.collectionService, this.entryService, this.assetService);
4020
5200
  this.logService.info({
4021
5201
  source: "core",
@@ -4075,6 +5255,12 @@ var ElekIoCore = class {
4075
5255
  return this.entryService;
4076
5256
  }
4077
5257
  /**
5258
+ * Prepare and create releases
5259
+ */
5260
+ get releases() {
5261
+ return this.releaseService;
5262
+ }
5263
+ /**
4078
5264
  * Allows starting and stopping a REST API
4079
5265
  * to allow developers to read local Project data
4080
5266
  */