@routevn/creator-model 1.0.0 → 1.0.2

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.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/src/model.js +928 -64
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@routevn/creator-model",
3
- "version": "1.0.0",
3
+ "version": "1.0.2",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/model.js CHANGED
@@ -18,6 +18,7 @@ import {
18
18
 
19
19
  const COLLECTION_KEYS = [
20
20
  "scenes",
21
+ "files",
21
22
  "images",
22
23
  "sounds",
23
24
  "videos",
@@ -32,6 +33,33 @@ const COLLECTION_KEYS = [
32
33
  ];
33
34
  const ROOT_KEYS = ["project", "story", ...COLLECTION_KEYS];
34
35
  const isString = (value) => typeof value === "string";
36
+ const FILE_ITEM_TYPES = [
37
+ "image",
38
+ "image-thumbnail",
39
+ "audio",
40
+ "audio-waveform",
41
+ "video",
42
+ "video-thumbnail",
43
+ "font",
44
+ ];
45
+ const IMAGE_FILE_REFERENCE_TYPES = {
46
+ fileId: ["image"],
47
+ thumbnailFileId: ["image-thumbnail"],
48
+ };
49
+ const SOUND_FILE_REFERENCE_TYPES = {
50
+ fileId: ["audio"],
51
+ waveformDataFileId: ["audio-waveform"],
52
+ };
53
+ const VIDEO_FILE_REFERENCE_TYPES = {
54
+ fileId: ["video"],
55
+ thumbnailFileId: ["video-thumbnail"],
56
+ };
57
+ const FONT_FILE_REFERENCE_TYPES = {
58
+ fileId: ["font"],
59
+ };
60
+ const CHARACTER_FILE_REFERENCE_TYPES = {
61
+ fileId: ["image"],
62
+ };
35
63
  const isHexColor = (value) =>
36
64
  typeof value === "string" && /^#[0-9a-fA-F]{6}$/.test(value);
37
65
  const LIVE_TWEEN_PROPERTY_KEYS = [
@@ -104,7 +132,7 @@ const LAYOUT_ELEMENT_BASE_TYPES = [
104
132
  "container-ref-choice-item",
105
133
  "container-ref-dialogue-line",
106
134
  ];
107
- export const SCHEMA_VERSION = 1;
135
+ export const SCHEMA_VERSION = 2;
108
136
  const LAYOUT_CONTAINER_ELEMENT_TYPES = [
109
137
  "folder",
110
138
  "container",
@@ -482,6 +510,79 @@ const validateLineItems = ({ items, path, errorFactory }) => {
482
510
  }
483
511
  };
484
512
 
513
+ const validateFileItems = ({ items, path, errorFactory }) => {
514
+ for (const [itemId, item] of Object.entries(items)) {
515
+ const itemPath = `${path}.${itemId}`;
516
+
517
+ if (item?.type !== "folder" && !FILE_ITEM_TYPES.includes(item?.type)) {
518
+ return invalidFromErrorFactory(
519
+ errorFactory,
520
+ `${itemPath}.type must be 'folder' or a supported file type`,
521
+ );
522
+ }
523
+
524
+ {
525
+ const result = validateAllowedKeys({
526
+ value: item,
527
+ allowedKeys:
528
+ item.type === "folder"
529
+ ? ["id", "type", "name"]
530
+ : ["id", "type", "mimeType", "size", "sha256"],
531
+ path: itemPath,
532
+ errorFactory,
533
+ });
534
+ if (result?.valid === false) {
535
+ return result;
536
+ }
537
+ }
538
+
539
+ if (!isNonEmptyString(item.id)) {
540
+ return invalidFromErrorFactory(
541
+ errorFactory,
542
+ `${itemPath}.id must be a non-empty string`,
543
+ );
544
+ }
545
+
546
+ if (item.id !== itemId) {
547
+ return invalidFromErrorFactory(
548
+ errorFactory,
549
+ `${itemPath}.id must match item key '${itemId}'`,
550
+ );
551
+ }
552
+
553
+ if (item.type === "folder") {
554
+ if (!isNonEmptyString(item.name)) {
555
+ return invalidFromErrorFactory(
556
+ errorFactory,
557
+ `${itemPath}.name must be a non-empty string`,
558
+ );
559
+ }
560
+ continue;
561
+ }
562
+
563
+ if (!isNonEmptyString(item.mimeType)) {
564
+ return invalidFromErrorFactory(
565
+ errorFactory,
566
+ `${itemPath}.mimeType must be a non-empty string`,
567
+ );
568
+ }
569
+
570
+ if (!isFiniteNumber(item.size)) {
571
+ return invalidFromErrorFactory(
572
+ errorFactory,
573
+ `${itemPath}.size must be a finite number`,
574
+ );
575
+ }
576
+
577
+ if (!isNonEmptyString(item.sha256)) {
578
+ return invalidFromErrorFactory(
579
+ errorFactory,
580
+ `${itemPath}.sha256 must be a non-empty string`,
581
+ );
582
+ }
583
+ }
584
+ };
585
+
485
586
  const validateImageItems = ({ items, path, errorFactory }) => {
486
587
  for (const [itemId, item] of Object.entries(items)) {
487
588
  const itemPath = `${path}.${itemId}`;
@@ -501,6 +602,7 @@ const validateImageItems = ({ items, path, errorFactory }) => {
501
602
  "type",
502
603
  "name",
503
604
  "description",
605
+ "thumbnailFileId",
504
606
  "fileId",
505
607
  "fileType",
506
608
  "fileSize",
@@ -534,6 +636,16 @@ const validateImageItems = ({ items, path, errorFactory }) => {
534
636
  }
535
637
 
536
638
  if (item.type === "image") {
639
+ if (
640
+ item.thumbnailFileId !== undefined &&
641
+ !isNonEmptyString(item.thumbnailFileId)
642
+ ) {
643
+ return invalidFromErrorFactory(
644
+ errorFactory,
645
+ `${itemPath}.thumbnailFileId must be a non-empty string when provided`,
646
+ );
647
+ }
648
+
537
649
  if (!isNonEmptyString(item.fileId)) {
538
650
  return invalidFromErrorFactory(errorFactory, `${itemPath}.fileId must be a non-empty string`);
539
651
  }
@@ -1737,6 +1849,7 @@ const validateLayoutElementData = ({
1737
1849
  "variableId",
1738
1850
  "$when",
1739
1851
  "click",
1852
+ "rightClick",
1740
1853
  "change",
1741
1854
  ];
1742
1855
 
@@ -1870,6 +1983,10 @@ const validateLayoutElementData = ({
1870
1983
  return invalidFromErrorFactory(errorFactory, `${path}.click must be an object when provided`);
1871
1984
  }
1872
1985
 
1986
+ if (data.rightClick !== undefined && !isPlainObject(data.rightClick)) {
1987
+ return invalidFromErrorFactory(errorFactory, `${path}.rightClick must be an object when provided`);
1988
+ }
1989
+
1873
1990
  if (data.change !== undefined && !isPlainObject(data.change)) {
1874
1991
  return invalidFromErrorFactory(errorFactory, `${path}.change must be an object when provided`);
1875
1992
  }
@@ -1921,6 +2038,7 @@ const validateLayoutElementItems = ({ items, path, errorFactory }) => {
1921
2038
  "variableId",
1922
2039
  "$when",
1923
2040
  "click",
2041
+ "rightClick",
1924
2042
  "change",
1925
2043
  ],
1926
2044
  path: itemPath,
@@ -2577,6 +2695,17 @@ const validateCollection = ({ collection, path }) => {
2577
2695
  return result;
2578
2696
  }
2579
2697
  }
2698
+ } else if (path === "state.files") {
2699
+ {
2700
+ const result = validateFileItems({
2701
+ items: collection.items,
2702
+ path: `${path}.items`,
2703
+ errorFactory: createStateValidationError,
2704
+ });
2705
+ if (result?.valid === false) {
2706
+ return result;
2707
+ }
2708
+ }
2580
2709
  } else if (path === "state.sounds") {
2581
2710
  {
2582
2711
  const result = validateSoundItems({
@@ -2784,6 +2913,7 @@ const validateCollection = ({ collection, path }) => {
2784
2913
  }
2785
2914
  }
2786
2915
  } else if (
2916
+ path === "state.files" ||
2787
2917
  path === "state.transforms" ||
2788
2918
  path === "state.variables" ||
2789
2919
  path === "state.textStyles" ||
@@ -2813,6 +2943,53 @@ const validateCollection = ({ collection, path }) => {
2813
2943
  }
2814
2944
  };
2815
2945
 
2946
+ const validateFileReference = ({
2947
+ state,
2948
+ fileId,
2949
+ path,
2950
+ allowedTypes,
2951
+ details = {},
2952
+ errorFactory = createPreconditionValidationError,
2953
+ }) => {
2954
+ if (fileId === undefined || fileId === null) {
2955
+ return VALID_RESULT;
2956
+ }
2957
+
2958
+ const expectedTypeMessage =
2959
+ Array.isArray(allowedTypes) && allowedTypes.length > 0
2960
+ ? `${path} must reference an existing non-folder file with type ${allowedTypes
2961
+ .map((type) => `'${type}'`)
2962
+ .join(" or ")}`
2963
+ : `${path} must reference an existing non-folder file`;
2964
+ const file = state.files?.items?.[fileId];
2965
+ if (!isPlainObject(file) || file.type === "folder") {
2966
+ return invalidFromErrorFactory(
2967
+ errorFactory,
2968
+ expectedTypeMessage,
2969
+ Array.isArray(allowedTypes) && allowedTypes.length > 0
2970
+ ? {
2971
+ ...details,
2972
+ expectedFileTypes: [...allowedTypes],
2973
+ }
2974
+ : details,
2975
+ );
2976
+ }
2977
+
2978
+ if (Array.isArray(allowedTypes) && allowedTypes.length > 0 && !allowedTypes.includes(file.type)) {
2979
+ return invalidFromErrorFactory(
2980
+ errorFactory,
2981
+ expectedTypeMessage,
2982
+ {
2983
+ ...details,
2984
+ expectedFileTypes: [...allowedTypes],
2985
+ actualFileType: file.type,
2986
+ },
2987
+ );
2988
+ }
2989
+
2990
+ return VALID_RESULT;
2991
+ };
2992
+
2816
2993
  export const assertInvariants = ({ state }) => {
2817
2994
  if (!isPlainObject(state)) {
2818
2995
  return invalidInvariant("state must be an object");
@@ -2924,6 +3101,166 @@ export const assertInvariants = ({ state }) => {
2924
3101
  }
2925
3102
  }
2926
3103
 
3104
+ for (const [imageId, image] of Object.entries(state.images.items)) {
3105
+ if (image.type !== "image") {
3106
+ continue;
3107
+ }
3108
+
3109
+ {
3110
+ const result = validateFileReference({
3111
+ state,
3112
+ fileId: image.fileId,
3113
+ path: "image.fileId",
3114
+ allowedTypes: IMAGE_FILE_REFERENCE_TYPES.fileId,
3115
+ details: { imageId, fileId: image.fileId },
3116
+ errorFactory: createInvariantValidationError,
3117
+ });
3118
+ if (!result.valid) {
3119
+ return result;
3120
+ }
3121
+ }
3122
+
3123
+ if (image.thumbnailFileId !== undefined) {
3124
+ const result = validateFileReference({
3125
+ state,
3126
+ fileId: image.thumbnailFileId,
3127
+ path: "image.thumbnailFileId",
3128
+ allowedTypes: IMAGE_FILE_REFERENCE_TYPES.thumbnailFileId,
3129
+ details: { imageId, thumbnailFileId: image.thumbnailFileId },
3130
+ errorFactory: createInvariantValidationError,
3131
+ });
3132
+ if (!result.valid) {
3133
+ return result;
3134
+ }
3135
+ }
3136
+ }
3137
+
3138
+ for (const [soundId, sound] of Object.entries(state.sounds.items)) {
3139
+ if (sound.type !== "sound") {
3140
+ continue;
3141
+ }
3142
+
3143
+ {
3144
+ const result = validateFileReference({
3145
+ state,
3146
+ fileId: sound.fileId,
3147
+ path: "sound.fileId",
3148
+ allowedTypes: SOUND_FILE_REFERENCE_TYPES.fileId,
3149
+ details: { soundId, fileId: sound.fileId },
3150
+ errorFactory: createInvariantValidationError,
3151
+ });
3152
+ if (!result.valid) {
3153
+ return result;
3154
+ }
3155
+ }
3156
+
3157
+ if (sound.waveformDataFileId !== undefined && sound.waveformDataFileId !== null) {
3158
+ const result = validateFileReference({
3159
+ state,
3160
+ fileId: sound.waveformDataFileId,
3161
+ path: "sound.waveformDataFileId",
3162
+ allowedTypes: SOUND_FILE_REFERENCE_TYPES.waveformDataFileId,
3163
+ details: { soundId, waveformDataFileId: sound.waveformDataFileId },
3164
+ errorFactory: createInvariantValidationError,
3165
+ });
3166
+ if (!result.valid) {
3167
+ return result;
3168
+ }
3169
+ }
3170
+ }
3171
+
3172
+ for (const [videoId, video] of Object.entries(state.videos.items)) {
3173
+ if (video.type !== "video") {
3174
+ continue;
3175
+ }
3176
+
3177
+ {
3178
+ const result = validateFileReference({
3179
+ state,
3180
+ fileId: video.fileId,
3181
+ path: "video.fileId",
3182
+ allowedTypes: VIDEO_FILE_REFERENCE_TYPES.fileId,
3183
+ details: { videoId, fileId: video.fileId },
3184
+ errorFactory: createInvariantValidationError,
3185
+ });
3186
+ if (!result.valid) {
3187
+ return result;
3188
+ }
3189
+ }
3190
+
3191
+ {
3192
+ const result = validateFileReference({
3193
+ state,
3194
+ fileId: video.thumbnailFileId,
3195
+ path: "video.thumbnailFileId",
3196
+ allowedTypes: VIDEO_FILE_REFERENCE_TYPES.thumbnailFileId,
3197
+ details: { videoId, thumbnailFileId: video.thumbnailFileId },
3198
+ errorFactory: createInvariantValidationError,
3199
+ });
3200
+ if (!result.valid) {
3201
+ return result;
3202
+ }
3203
+ }
3204
+ }
3205
+
3206
+ for (const [fontId, font] of Object.entries(state.fonts.items)) {
3207
+ if (font.type !== "font") {
3208
+ continue;
3209
+ }
3210
+
3211
+ const result = validateFileReference({
3212
+ state,
3213
+ fileId: font.fileId,
3214
+ path: "font.fileId",
3215
+ allowedTypes: FONT_FILE_REFERENCE_TYPES.fileId,
3216
+ details: { fontId, fileId: font.fileId },
3217
+ errorFactory: createInvariantValidationError,
3218
+ });
3219
+ if (!result.valid) {
3220
+ return result;
3221
+ }
3222
+ }
3223
+
3224
+ for (const [characterId, character] of Object.entries(state.characters.items)) {
3225
+ if (character.type !== "character") {
3226
+ continue;
3227
+ }
3228
+
3229
+ if (character.fileId !== undefined) {
3230
+ const result = validateFileReference({
3231
+ state,
3232
+ fileId: character.fileId,
3233
+ path: "character.fileId",
3234
+ allowedTypes: CHARACTER_FILE_REFERENCE_TYPES.fileId,
3235
+ details: { characterId, fileId: character.fileId },
3236
+ errorFactory: createInvariantValidationError,
3237
+ });
3238
+ if (!result.valid) {
3239
+ return result;
3240
+ }
3241
+ }
3242
+
3243
+ for (const [spriteId, sprite] of Object.entries(
3244
+ character.sprites?.items || {},
3245
+ )) {
3246
+ if (sprite.type !== "image") {
3247
+ continue;
3248
+ }
3249
+
3250
+ const result = validateFileReference({
3251
+ state,
3252
+ fileId: sprite.fileId,
3253
+ path: "character.sprite.fileId",
3254
+ allowedTypes: CHARACTER_FILE_REFERENCE_TYPES.fileId,
3255
+ details: { characterId, spriteId, fileId: sprite.fileId },
3256
+ errorFactory: createInvariantValidationError,
3257
+ });
3258
+ if (!result.valid) {
3259
+ return result;
3260
+ }
3261
+ }
3262
+ }
3263
+
2927
3264
  const assertImageReference = ({ layoutId, elementId, field, targetId }) => {
2928
3265
  const image = state.images.items[targetId];
2929
3266
  if (!isPlainObject(image) || image.type === "folder") {
@@ -3397,6 +3734,7 @@ const validateImageCreateData = ({ data, errorFactory }) => {
3397
3734
  "type",
3398
3735
  "name",
3399
3736
  "description",
3737
+ "thumbnailFileId",
3400
3738
  "fileId",
3401
3739
  "fileType",
3402
3740
  "fileSize",
@@ -3422,6 +3760,16 @@ const validateImageCreateData = ({ data, errorFactory }) => {
3422
3760
  }
3423
3761
 
3424
3762
  if (data.type === "image") {
3763
+ if (
3764
+ data.thumbnailFileId !== undefined &&
3765
+ !isNonEmptyString(data.thumbnailFileId)
3766
+ ) {
3767
+ return invalidFromErrorFactory(
3768
+ errorFactory,
3769
+ "payload.data.thumbnailFileId must be a non-empty string when provided",
3770
+ );
3771
+ }
3772
+
3425
3773
  if (!isNonEmptyString(data.fileId)) {
3426
3774
  return invalidFromErrorFactory(errorFactory, "payload.data.fileId must be a non-empty string");
3427
3775
  }
@@ -3902,6 +4250,108 @@ const validateFontUpdateData = ({ data, errorFactory }) => {
3902
4250
  }
3903
4251
  };
3904
4252
 
4253
+ const validateFileCreateData = ({ data, errorFactory }) => {
4254
+ if (!isPlainObject(data)) {
4255
+ return invalidFromErrorFactory(
4256
+ errorFactory,
4257
+ "payload.data must be an object",
4258
+ );
4259
+ }
4260
+
4261
+ if (data.type !== "folder" && !FILE_ITEM_TYPES.includes(data.type)) {
4262
+ return invalidFromErrorFactory(
4263
+ errorFactory,
4264
+ "payload.data.type must be 'folder' or a supported file type",
4265
+ );
4266
+ }
4267
+
4268
+ {
4269
+ const result = validateAllowedKeys({
4270
+ value: data,
4271
+ allowedKeys:
4272
+ data.type === "folder"
4273
+ ? ["type", "name"]
4274
+ : ["type", "mimeType", "size", "sha256"],
4275
+ path: "payload.data",
4276
+ errorFactory,
4277
+ });
4278
+ if (result?.valid === false) {
4279
+ return result;
4280
+ }
4281
+ }
4282
+
4283
+ if (data.type === "folder") {
4284
+ if (!isNonEmptyString(data.name)) {
4285
+ return invalidFromErrorFactory(
4286
+ errorFactory,
4287
+ "payload.data.name must be a non-empty string",
4288
+ );
4289
+ }
4290
+ return;
4291
+ }
4292
+
4293
+ if (!isNonEmptyString(data.mimeType)) {
4294
+ return invalidFromErrorFactory(
4295
+ errorFactory,
4296
+ "payload.data.mimeType must be a non-empty string",
4297
+ );
4298
+ }
4299
+
4300
+ if (!isFiniteNumber(data.size)) {
4301
+ return invalidFromErrorFactory(
4302
+ errorFactory,
4303
+ "payload.data.size must be a finite number",
4304
+ );
4305
+ }
4306
+
4307
+ if (!isNonEmptyString(data.sha256)) {
4308
+ return invalidFromErrorFactory(
4309
+ errorFactory,
4310
+ "payload.data.sha256 must be a non-empty string",
4311
+ );
4312
+ }
4313
+ };
4314
+
4315
+ const validateReferencedFilesInData = ({
4316
+ state,
4317
+ data,
4318
+ fields,
4319
+ fieldTypes = {},
4320
+ nullableFields = [],
4321
+ details = {},
4322
+ errorFactory = createPreconditionValidationError,
4323
+ }) => {
4324
+ for (const field of fields) {
4325
+ const fileId = data[field];
4326
+
4327
+ if (fileId === undefined) {
4328
+ continue;
4329
+ }
4330
+
4331
+ if (fileId === null && nullableFields.includes(field)) {
4332
+ continue;
4333
+ }
4334
+
4335
+ const result = validateFileReference({
4336
+ state,
4337
+ fileId,
4338
+ path: `payload.data.${field}`,
4339
+ allowedTypes: fieldTypes[field],
4340
+ details: {
4341
+ ...details,
4342
+ field,
4343
+ fileId,
4344
+ },
4345
+ errorFactory,
4346
+ });
4347
+ if (!result.valid) {
4348
+ return result;
4349
+ }
4350
+ }
4351
+
4352
+ return VALID_RESULT;
4353
+ };
4354
+
3905
4355
  const validateColorCreateData = ({ data, errorFactory }) => {
3906
4356
  if (!isPlainObject(data)) {
3907
4357
  return invalidFromErrorFactory(errorFactory, "payload.data must be an object");
@@ -5092,6 +5542,8 @@ const createFolderedCollectionCommandDefinitions = ({
5092
5542
  }),
5093
5543
  validateCreateState = () => {},
5094
5544
  validateUpdateState = () => {},
5545
+ validateDeleteState = () => {},
5546
+ includeUpdate = true,
5095
5547
  }) => {
5096
5548
  const existingMessage = `payload.${idField} must reference an existing ${itemLabel}`;
5097
5549
  const duplicateMessage = `payload.${idField} must not already exist`;
@@ -5220,68 +5672,72 @@ const createFolderedCollectionCommandDefinitions = ({
5220
5672
  return state;
5221
5673
  },
5222
5674
  },
5223
- {
5224
- type: `${familyName}.update`,
5225
- validatePayload: ({ payload }) => {
5226
- let result = captureValidation(() =>
5227
- validateExactKeys({
5228
- value: payload,
5229
- expectedKeys: [idField, "data"],
5230
- path: "payload",
5231
- errorFactory: createPayloadValidationError,
5232
- }),
5233
- );
5234
- if (!result.valid) {
5235
- return result;
5236
- }
5237
-
5238
- if (!isNonEmptyString(payload[idField])) {
5239
- return invalidPayload(
5240
- `payload.${idField} must be a non-empty string`,
5241
- );
5242
- }
5243
-
5244
- result = captureValidation(() =>
5245
- updateDataValidator({
5246
- data: payload.data,
5247
- errorFactory: createPayloadValidationError,
5248
- }),
5249
- );
5250
- if (!result.valid) {
5251
- return result;
5252
- }
5253
-
5254
- return VALID_RESULT;
5255
- },
5256
- validateAgainstState: ({ state, payload }) => {
5257
- const currentItem = state[collectionKey].items[payload[idField]];
5258
- if (!isPlainObject(currentItem)) {
5259
- return invalidPrecondition(existingMessage);
5260
- }
5261
-
5262
- const result = captureValidation(() =>
5263
- validateUpdateState({
5264
- state,
5265
- payload,
5266
- currentItem,
5267
- }),
5268
- );
5269
- if (!result.valid) {
5270
- return result;
5271
- }
5272
-
5273
- return VALID_RESULT;
5274
- },
5275
- reduce: ({ state, payload }) => {
5276
- const currentItem = state[collectionKey].items[payload[idField]];
5277
- state[collectionKey].items[payload[idField]] = updateItem({
5278
- state,
5279
- payload,
5280
- currentItem,
5281
- });
5282
- return state;
5283
- },
5284
- },
5675
+ ...(includeUpdate
5676
+ ? [
5677
+ {
5678
+ type: `${familyName}.update`,
5679
+ validatePayload: ({ payload }) => {
5680
+ let result = captureValidation(() =>
5681
+ validateExactKeys({
5682
+ value: payload,
5683
+ expectedKeys: [idField, "data"],
5684
+ path: "payload",
5685
+ errorFactory: createPayloadValidationError,
5686
+ }),
5687
+ );
5688
+ if (!result.valid) {
5689
+ return result;
5690
+ }
5691
+
5692
+ if (!isNonEmptyString(payload[idField])) {
5693
+ return invalidPayload(
5694
+ `payload.${idField} must be a non-empty string`,
5695
+ );
5696
+ }
5697
+
5698
+ result = captureValidation(() =>
5699
+ updateDataValidator({
5700
+ data: payload.data,
5701
+ errorFactory: createPayloadValidationError,
5702
+ }),
5703
+ );
5704
+ if (!result.valid) {
5705
+ return result;
5706
+ }
5707
+
5708
+ return VALID_RESULT;
5709
+ },
5710
+ validateAgainstState: ({ state, payload }) => {
5711
+ const currentItem = state[collectionKey].items[payload[idField]];
5712
+ if (!isPlainObject(currentItem)) {
5713
+ return invalidPrecondition(existingMessage);
5714
+ }
5715
+
5716
+ const result = captureValidation(() =>
5717
+ validateUpdateState({
5718
+ state,
5719
+ payload,
5720
+ currentItem,
5721
+ }),
5722
+ );
5723
+ if (!result.valid) {
5724
+ return result;
5725
+ }
5726
+
5727
+ return VALID_RESULT;
5728
+ },
5729
+ reduce: ({ state, payload }) => {
5730
+ const currentItem = state[collectionKey].items[payload[idField]];
5731
+ state[collectionKey].items[payload[idField]] = updateItem({
5732
+ state,
5733
+ payload,
5734
+ currentItem,
5735
+ });
5736
+ return state;
5737
+ },
5738
+ },
5739
+ ]
5740
+ : []),
5285
5741
  {
5286
5742
  type: `${familyName}.delete`,
5287
5743
  validatePayload: ({ payload }) => {
@@ -5320,6 +5776,16 @@ const createFolderedCollectionCommandDefinitions = ({
5320
5776
  }
5321
5777
  }
5322
5778
 
5779
+ const result = captureValidation(() =>
5780
+ validateDeleteState({
5781
+ state,
5782
+ payload,
5783
+ }),
5784
+ );
5785
+ if (!result.valid) {
5786
+ return result;
5787
+ }
5788
+
5323
5789
  return VALID_RESULT;
5324
5790
  },
5325
5791
  reduce: ({ state, payload }) => {
@@ -5479,6 +5945,143 @@ const getCharacterSpriteCollection = ({ state, characterId }) =>
5479
5945
  const getLayoutElementCollection = ({ state, layoutId }) =>
5480
5946
  state.layouts.items[layoutId]?.elements;
5481
5947
 
5948
+ const findReferencedFileUsage = ({ state, fileId }) => {
5949
+ for (const [imageId, image] of Object.entries(state.images.items)) {
5950
+ if (image.type !== "image") {
5951
+ continue;
5952
+ }
5953
+
5954
+ if (image.fileId === fileId) {
5955
+ return {
5956
+ kind: "image",
5957
+ field: "fileId",
5958
+ ownerId: imageId,
5959
+ };
5960
+ }
5961
+
5962
+ if (image.thumbnailFileId === fileId) {
5963
+ return {
5964
+ kind: "image",
5965
+ field: "thumbnailFileId",
5966
+ ownerId: imageId,
5967
+ };
5968
+ }
5969
+ }
5970
+
5971
+ for (const [soundId, sound] of Object.entries(state.sounds.items)) {
5972
+ if (sound.type !== "sound") {
5973
+ continue;
5974
+ }
5975
+
5976
+ if (sound.fileId === fileId) {
5977
+ return {
5978
+ kind: "sound",
5979
+ field: "fileId",
5980
+ ownerId: soundId,
5981
+ };
5982
+ }
5983
+
5984
+ if (sound.waveformDataFileId === fileId) {
5985
+ return {
5986
+ kind: "sound",
5987
+ field: "waveformDataFileId",
5988
+ ownerId: soundId,
5989
+ };
5990
+ }
5991
+ }
5992
+
5993
+ for (const [videoId, video] of Object.entries(state.videos.items)) {
5994
+ if (video.type !== "video") {
5995
+ continue;
5996
+ }
5997
+
5998
+ if (video.fileId === fileId) {
5999
+ return {
6000
+ kind: "video",
6001
+ field: "fileId",
6002
+ ownerId: videoId,
6003
+ };
6004
+ }
6005
+
6006
+ if (video.thumbnailFileId === fileId) {
6007
+ return {
6008
+ kind: "video",
6009
+ field: "thumbnailFileId",
6010
+ ownerId: videoId,
6011
+ };
6012
+ }
6013
+ }
6014
+
6015
+ for (const [fontId, font] of Object.entries(state.fonts.items)) {
6016
+ if (font.type !== "font") {
6017
+ continue;
6018
+ }
6019
+
6020
+ if (font.fileId === fileId) {
6021
+ return {
6022
+ kind: "font",
6023
+ field: "fileId",
6024
+ ownerId: fontId,
6025
+ };
6026
+ }
6027
+ }
6028
+
6029
+ for (const [characterId, character] of Object.entries(state.characters.items)) {
6030
+ if (character.type !== "character") {
6031
+ continue;
6032
+ }
6033
+
6034
+ if (character.fileId === fileId) {
6035
+ return {
6036
+ kind: "character",
6037
+ field: "fileId",
6038
+ ownerId: characterId,
6039
+ };
6040
+ }
6041
+
6042
+ for (const [spriteId, sprite] of Object.entries(
6043
+ character.sprites?.items || {},
6044
+ )) {
6045
+ if (sprite.type !== "image") {
6046
+ continue;
6047
+ }
6048
+
6049
+ if (sprite.fileId === fileId) {
6050
+ return {
6051
+ kind: "character.sprite",
6052
+ field: "fileId",
6053
+ ownerId: spriteId,
6054
+ characterId,
6055
+ };
6056
+ }
6057
+ }
6058
+ }
6059
+
6060
+ return null;
6061
+ };
6062
+
6063
+ const collectDeletedFileIds = ({ state, fileIds }) => {
6064
+ const deletedIds = new Set();
6065
+
6066
+ for (const fileId of fileIds) {
6067
+ const node = findTreeNode({
6068
+ nodes: state.files.tree,
6069
+ nodeId: fileId,
6070
+ });
6071
+
6072
+ if (!node) {
6073
+ deletedIds.add(fileId);
6074
+ continue;
6075
+ }
6076
+
6077
+ for (const deletedId of collectTreeDescendantIds({ node })) {
6078
+ deletedIds.add(deletedId);
6079
+ }
6080
+ }
6081
+
6082
+ return deletedIds;
6083
+ };
6084
+
5482
6085
  const COMMAND_DEFINITIONS = [
5483
6086
  {
5484
6087
  type: "project.create",
@@ -5503,6 +6106,51 @@ const COMMAND_DEFINITIONS = [
5503
6106
  validateAgainstState: () => {},
5504
6107
  reduce: ({ payload }) => structuredClone(payload.state),
5505
6108
  },
6109
+ ...createFolderedCollectionCommandDefinitions({
6110
+ familyName: "file",
6111
+ collectionKey: "files",
6112
+ idField: "fileId",
6113
+ itemLabel: "file item",
6114
+ createDataValidator: validateFileCreateData,
6115
+ updateDataValidator: () => VALID_RESULT,
6116
+ includeUpdate: false,
6117
+ createItem: ({ payload }) =>
6118
+ payload.data.type === "folder"
6119
+ ? {
6120
+ id: payload.fileId,
6121
+ type: "folder",
6122
+ name: payload.data.name,
6123
+ }
6124
+ : {
6125
+ id: payload.fileId,
6126
+ type: payload.data.type,
6127
+ mimeType: payload.data.mimeType,
6128
+ size: payload.data.size,
6129
+ sha256: payload.data.sha256,
6130
+ },
6131
+ validateDeleteState: ({ state, payload }) => {
6132
+ for (const fileId of collectDeletedFileIds({
6133
+ state,
6134
+ fileIds: payload.fileIds,
6135
+ })) {
6136
+ const usage = findReferencedFileUsage({ state, fileId });
6137
+ if (!usage) {
6138
+ continue;
6139
+ }
6140
+
6141
+ return invalidPrecondition(
6142
+ `payload.fileIds cannot delete a referenced file`,
6143
+ {
6144
+ fileId,
6145
+ referenceKind: usage.kind,
6146
+ referenceField: usage.field,
6147
+ referenceOwnerId: usage.ownerId,
6148
+ ...(usage.characterId ? { characterId: usage.characterId } : {}),
6149
+ },
6150
+ );
6151
+ }
6152
+ },
6153
+ }),
5506
6154
  {
5507
6155
  type: "story.update",
5508
6156
  validatePayload: ({ payload }) => {
@@ -6849,6 +7497,21 @@ const COMMAND_DEFINITIONS = [
6849
7497
  );
6850
7498
  }
6851
7499
  }
7500
+
7501
+ if (payload.data.type === "image") {
7502
+ const result = validateReferencedFilesInData({
7503
+ state,
7504
+ data: payload.data,
7505
+ fields: ["fileId", "thumbnailFileId"],
7506
+ fieldTypes: IMAGE_FILE_REFERENCE_TYPES,
7507
+ details: {
7508
+ imageId: payload.imageId,
7509
+ },
7510
+ });
7511
+ if (!result.valid) {
7512
+ return result;
7513
+ }
7514
+ }
6852
7515
  },
6853
7516
  reduce: ({ state, payload }) => {
6854
7517
  const nextImage = {
@@ -6863,6 +7526,9 @@ const COMMAND_DEFINITIONS = [
6863
7526
 
6864
7527
  if (payload.data.type === "image") {
6865
7528
  nextImage.fileId = payload.data.fileId;
7529
+ if (payload.data.thumbnailFileId !== undefined) {
7530
+ nextImage.thumbnailFileId = payload.data.thumbnailFileId;
7531
+ }
6866
7532
  if (payload.data.fileType !== undefined) {
6867
7533
  nextImage.fileType = payload.data.fileType;
6868
7534
  }
@@ -6936,6 +7602,7 @@ const COMMAND_DEFINITIONS = [
6936
7602
  if (
6937
7603
  currentImage.type === "folder" &&
6938
7604
  (payload.data.fileId !== undefined ||
7605
+ payload.data.thumbnailFileId !== undefined ||
6939
7606
  payload.data.fileType !== undefined ||
6940
7607
  payload.data.fileSize !== undefined ||
6941
7608
  payload.data.width !== undefined ||
@@ -6945,6 +7612,21 @@ const COMMAND_DEFINITIONS = [
6945
7612
  "folder image items cannot update file fields",
6946
7613
  );
6947
7614
  }
7615
+
7616
+ if (currentImage.type === "image") {
7617
+ const result = validateReferencedFilesInData({
7618
+ state,
7619
+ data: payload.data,
7620
+ fields: ["fileId", "thumbnailFileId"],
7621
+ fieldTypes: IMAGE_FILE_REFERENCE_TYPES,
7622
+ details: {
7623
+ imageId: payload.imageId,
7624
+ },
7625
+ });
7626
+ if (!result.valid) {
7627
+ return result;
7628
+ }
7629
+ }
6948
7630
  },
6949
7631
  reduce: ({ state, payload }) => {
6950
7632
  const currentImage = state.images.items[payload.imageId];
@@ -7249,6 +7931,22 @@ const COMMAND_DEFINITIONS = [
7249
7931
  );
7250
7932
  }
7251
7933
  }
7934
+
7935
+ if (payload.data.type === "sound") {
7936
+ const result = validateReferencedFilesInData({
7937
+ state,
7938
+ data: payload.data,
7939
+ fields: ["fileId", "waveformDataFileId"],
7940
+ fieldTypes: SOUND_FILE_REFERENCE_TYPES,
7941
+ nullableFields: ["waveformDataFileId"],
7942
+ details: {
7943
+ soundId: payload.soundId,
7944
+ },
7945
+ });
7946
+ if (!result.valid) {
7947
+ return result;
7948
+ }
7949
+ }
7252
7950
  },
7253
7951
  reduce: ({ state, payload }) => {
7254
7952
  const nextSound = {
@@ -7345,6 +8043,22 @@ const COMMAND_DEFINITIONS = [
7345
8043
  "folder sound items cannot update file fields",
7346
8044
  );
7347
8045
  }
8046
+
8047
+ if (currentSound.type === "sound") {
8048
+ const result = validateReferencedFilesInData({
8049
+ state,
8050
+ data: payload.data,
8051
+ fields: ["fileId", "waveformDataFileId"],
8052
+ fieldTypes: SOUND_FILE_REFERENCE_TYPES,
8053
+ nullableFields: ["waveformDataFileId"],
8054
+ details: {
8055
+ soundId: payload.soundId,
8056
+ },
8057
+ });
8058
+ if (!result.valid) {
8059
+ return result;
8060
+ }
8061
+ }
7348
8062
  },
7349
8063
  reduce: ({ state, payload }) => {
7350
8064
  const currentSound = state.sounds.items[payload.soundId];
@@ -7649,6 +8363,21 @@ const COMMAND_DEFINITIONS = [
7649
8363
  );
7650
8364
  }
7651
8365
  }
8366
+
8367
+ if (payload.data.type === "video") {
8368
+ const result = validateReferencedFilesInData({
8369
+ state,
8370
+ data: payload.data,
8371
+ fields: ["fileId", "thumbnailFileId"],
8372
+ fieldTypes: VIDEO_FILE_REFERENCE_TYPES,
8373
+ details: {
8374
+ videoId: payload.videoId,
8375
+ },
8376
+ });
8377
+ if (!result.valid) {
8378
+ return result;
8379
+ }
8380
+ }
7652
8381
  },
7653
8382
  reduce: ({ state, payload }) => {
7654
8383
  const nextVideo = {
@@ -7747,6 +8476,21 @@ const COMMAND_DEFINITIONS = [
7747
8476
  "folder video items cannot update file fields",
7748
8477
  );
7749
8478
  }
8479
+
8480
+ if (currentVideo.type === "video") {
8481
+ const result = validateReferencedFilesInData({
8482
+ state,
8483
+ data: payload.data,
8484
+ fields: ["fileId", "thumbnailFileId"],
8485
+ fieldTypes: VIDEO_FILE_REFERENCE_TYPES,
8486
+ details: {
8487
+ videoId: payload.videoId,
8488
+ },
8489
+ });
8490
+ if (!result.valid) {
8491
+ return result;
8492
+ }
8493
+ }
7750
8494
  },
7751
8495
  reduce: ({ state, payload }) => {
7752
8496
  const currentVideo = state.videos.items[payload.videoId];
@@ -8431,6 +9175,21 @@ const COMMAND_DEFINITIONS = [
8431
9175
  );
8432
9176
  }
8433
9177
  }
9178
+
9179
+ if (payload.data.type === "font") {
9180
+ const result = validateReferencedFilesInData({
9181
+ state,
9182
+ data: payload.data,
9183
+ fields: ["fileId"],
9184
+ fieldTypes: FONT_FILE_REFERENCE_TYPES,
9185
+ details: {
9186
+ fontId: payload.fontId,
9187
+ },
9188
+ });
9189
+ if (!result.valid) {
9190
+ return result;
9191
+ }
9192
+ }
8434
9193
  },
8435
9194
  reduce: ({ state, payload }) => {
8436
9195
  const nextFont = {
@@ -8517,6 +9276,21 @@ const COMMAND_DEFINITIONS = [
8517
9276
  "folder font items cannot update font fields",
8518
9277
  );
8519
9278
  }
9279
+
9280
+ if (currentFont.type === "font") {
9281
+ const result = validateReferencedFilesInData({
9282
+ state,
9283
+ data: payload.data,
9284
+ fields: ["fileId"],
9285
+ fieldTypes: FONT_FILE_REFERENCE_TYPES,
9286
+ details: {
9287
+ fontId: payload.fontId,
9288
+ },
9289
+ });
9290
+ if (!result.valid) {
9291
+ return result;
9292
+ }
9293
+ }
8520
9294
  },
8521
9295
  reduce: ({ state, payload }) => {
8522
9296
  const currentFont = state.fonts.items[payload.fontId];
@@ -9294,7 +10068,50 @@ const COMMAND_DEFINITIONS = [
9294
10068
 
9295
10069
  return item;
9296
10070
  },
9297
- validateUpdateState: ({ payload, currentItem }) => {
10071
+ validateCreateState: ({ state, payload }) => {
10072
+ if (payload.data.type !== "character") {
10073
+ return;
10074
+ }
10075
+
10076
+ if (payload.data.fileId !== undefined) {
10077
+ const result = validateReferencedFilesInData({
10078
+ state,
10079
+ data: payload.data,
10080
+ fields: ["fileId"],
10081
+ fieldTypes: CHARACTER_FILE_REFERENCE_TYPES,
10082
+ details: {
10083
+ characterId: payload.characterId,
10084
+ },
10085
+ });
10086
+ if (!result.valid) {
10087
+ return result;
10088
+ }
10089
+ }
10090
+
10091
+ for (const [spriteId, sprite] of Object.entries(
10092
+ payload.data.sprites?.items || {},
10093
+ )) {
10094
+ if (sprite.type !== "image") {
10095
+ continue;
10096
+ }
10097
+
10098
+ const result = validateFileReference({
10099
+ state,
10100
+ fileId: sprite.fileId,
10101
+ path: "payload.data.sprites.items.*.fileId",
10102
+ allowedTypes: CHARACTER_FILE_REFERENCE_TYPES.fileId,
10103
+ details: {
10104
+ characterId: payload.characterId,
10105
+ spriteId,
10106
+ fileId: sprite.fileId,
10107
+ },
10108
+ });
10109
+ if (!result.valid) {
10110
+ return result;
10111
+ }
10112
+ }
10113
+ },
10114
+ validateUpdateState: ({ state, payload, currentItem }) => {
9298
10115
  if (
9299
10116
  currentItem.type === "folder" &&
9300
10117
  Object.keys(payload.data).some((key) => key !== "name")
@@ -9303,6 +10120,21 @@ const COMMAND_DEFINITIONS = [
9303
10120
  "folder character items cannot update character fields",
9304
10121
  );
9305
10122
  }
10123
+
10124
+ if (currentItem.type === "character") {
10125
+ const result = validateReferencedFilesInData({
10126
+ state,
10127
+ data: payload.data,
10128
+ fields: ["fileId"],
10129
+ fieldTypes: CHARACTER_FILE_REFERENCE_TYPES,
10130
+ details: {
10131
+ characterId: payload.characterId,
10132
+ },
10133
+ });
10134
+ if (!result.valid) {
10135
+ return result;
10136
+ }
10137
+ }
9306
10138
  },
9307
10139
  }),
9308
10140
  ...createFolderedCollectionCommandDefinitions({
@@ -9446,6 +10278,22 @@ const COMMAND_DEFINITIONS = [
9446
10278
  );
9447
10279
  }
9448
10280
  }
10281
+
10282
+ if (payload.data.type === "image") {
10283
+ const result = validateReferencedFilesInData({
10284
+ state,
10285
+ data: payload.data,
10286
+ fields: ["fileId"],
10287
+ fieldTypes: CHARACTER_FILE_REFERENCE_TYPES,
10288
+ details: {
10289
+ characterId: payload.characterId,
10290
+ spriteId: payload.spriteId,
10291
+ },
10292
+ });
10293
+ if (!result.valid) {
10294
+ return result;
10295
+ }
10296
+ }
9449
10297
  },
9450
10298
  reduce: ({ state, payload }) => {
9451
10299
  const collection = getCharacterSpriteCollection({
@@ -9538,6 +10386,22 @@ const COMMAND_DEFINITIONS = [
9538
10386
  "folder sprite items cannot update image fields",
9539
10387
  );
9540
10388
  }
10389
+
10390
+ if (currentItem.type === "image") {
10391
+ const result = validateReferencedFilesInData({
10392
+ state,
10393
+ data: payload.data,
10394
+ fields: ["fileId"],
10395
+ fieldTypes: CHARACTER_FILE_REFERENCE_TYPES,
10396
+ details: {
10397
+ characterId: payload.characterId,
10398
+ spriteId: payload.spriteId,
10399
+ },
10400
+ });
10401
+ if (!result.valid) {
10402
+ return result;
10403
+ }
10404
+ }
9541
10405
  },
9542
10406
  reduce: ({ state, payload }) => {
9543
10407
  const collection = getCharacterSpriteCollection({