sg-widgets-core 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/mock.mjs CHANGED
@@ -1,4 +1,4 @@
1
- import { A as NEGATING_OPERATORS, D as toFilterArray, I as isFilterable, L as isLinkType, N as TIME_UNITS, R as isNumericType, V as operatorsFor, a as eventLogFilters, d as displayNameOf, f as fieldSchemaOverride, r as SgApiError, s as normalizeEventLogEntry, t as EVENT_LOG_FIELDS, v as pluralPath } from "./client-BdePDloQ.mjs";
1
+ import { A as NEGATING_OPERATORS, D as toFilterArray, I as isFilterable, L as isLinkType, N as TIME_UNITS, R as isNumericType, V as operatorsFor, a as eventLogFilters, d as displayNameOf, f as fieldSchemaOverride, r as SgApiError, s as normalizeEventLogEntry, t as EVENT_LOG_FIELDS, v as pluralPath } from "./client-izKszX7l.mjs";
2
2
  //#region src/mock.ts
3
3
  /**
4
4
  * Status vocabularies. `valid_values` is the site's whole vocabulary and is
@@ -151,6 +151,19 @@ const PROJECT_STATUSES = [
151
151
  "Complete",
152
152
  "On Hold"
153
153
  ];
154
+ /** PublishedFile's list is site configuration; this site keeps the three the probed one held (entity_types/PublishedFile). */
155
+ const PUBLISHED_FILE_STATUSES = [
156
+ "wtg",
157
+ "ip",
158
+ "cmpt"
159
+ ];
160
+ /** The four `TaskDependency.dependency_type` values, the first the default (085_task_dependency_types). */
161
+ const DEPENDENCY_TYPES = [
162
+ "finish-to-start-next-day",
163
+ "start-to-start",
164
+ "finish-to-finish",
165
+ "start-to-finish-next-day"
166
+ ];
154
167
  const AUDIT = {
155
168
  id: {
156
169
  displayName: "Id",
@@ -277,6 +290,15 @@ const SPECS = {
277
290
  displayName: "Users",
278
291
  dataType: "multi_entity",
279
292
  validTypes: ["HumanUser"]
293
+ },
294
+ tracking_settings: {
295
+ displayName: "Tracking Settings",
296
+ dataType: "serializable"
297
+ },
298
+ task_templates: {
299
+ displayName: "Task Templates",
300
+ dataType: "multi_entity",
301
+ validTypes: ["TaskTemplate"]
280
302
  }
281
303
  },
282
304
  Sequence: {
@@ -313,6 +335,11 @@ const SPECS = {
313
335
  displayName: "Shots",
314
336
  dataType: "multi_entity",
315
337
  validTypes: ["Shot"]
338
+ },
339
+ task_template: {
340
+ displayName: "Task Template",
341
+ dataType: "entity",
342
+ validTypes: ["TaskTemplate"]
316
343
  }
317
344
  },
318
345
  Shot: {
@@ -392,6 +419,11 @@ const SPECS = {
392
419
  displayName: "Tasks",
393
420
  dataType: "multi_entity",
394
421
  validTypes: ["Task"]
422
+ },
423
+ task_template: {
424
+ displayName: "Task Template",
425
+ dataType: "entity",
426
+ validTypes: ["TaskTemplate"]
395
427
  }
396
428
  },
397
429
  Asset: {
@@ -451,6 +483,11 @@ const SPECS = {
451
483
  displayName: "Tasks",
452
484
  dataType: "multi_entity",
453
485
  validTypes: ["Task"]
486
+ },
487
+ task_template: {
488
+ displayName: "Task Template",
489
+ dataType: "entity",
490
+ validTypes: ["TaskTemplate"]
454
491
  }
455
492
  },
456
493
  Version: {
@@ -634,6 +671,151 @@ const SPECS = {
634
671
  displayName: "Upstream Tasks",
635
672
  dataType: "multi_entity",
636
673
  validTypes: ["Task"]
674
+ },
675
+ downstream_tasks: {
676
+ displayName: "Downstream Tasks",
677
+ dataType: "multi_entity",
678
+ validTypes: ["Task"]
679
+ },
680
+ sg_sort_order: {
681
+ displayName: "Sort Order",
682
+ dataType: "number"
683
+ },
684
+ task_template: {
685
+ displayName: "Task Template",
686
+ dataType: "entity",
687
+ validTypes: ["TaskTemplate"]
688
+ },
689
+ template_task: {
690
+ displayName: "Template Task",
691
+ dataType: "entity",
692
+ validTypes: ["Task"]
693
+ }
694
+ },
695
+ TaskTemplate: {
696
+ ...AUDIT,
697
+ code: {
698
+ displayName: "Template Name",
699
+ dataType: "text",
700
+ mandatory: true
701
+ },
702
+ description: {
703
+ displayName: "Description",
704
+ dataType: "text"
705
+ },
706
+ entity_type: {
707
+ displayName: "Entity Type",
708
+ dataType: "entity_type",
709
+ editable: false,
710
+ writable: true
711
+ },
712
+ projects: {
713
+ displayName: "Projects",
714
+ dataType: "multi_entity",
715
+ validTypes: ["Project"]
716
+ },
717
+ task_count: {
718
+ displayName: "Task Count",
719
+ dataType: "number",
720
+ editable: false
721
+ }
722
+ },
723
+ TaskDependency: {
724
+ ...AUDIT,
725
+ task: {
726
+ displayName: "Task",
727
+ dataType: "entity",
728
+ validTypes: ["Task"]
729
+ },
730
+ dependent_task: {
731
+ displayName: "Dependent Task",
732
+ dataType: "entity",
733
+ validTypes: ["Task"]
734
+ },
735
+ dependency_type: {
736
+ displayName: "Dependency Type",
737
+ dataType: "text",
738
+ defaultValue: DEPENDENCY_TYPES[0]
739
+ },
740
+ offset_days: {
741
+ displayName: "Offset Days",
742
+ dataType: "number"
743
+ },
744
+ shift_ratio: {
745
+ displayName: "Shift Ratio",
746
+ dataType: "float"
747
+ },
748
+ task_id: {
749
+ displayName: "Task Id",
750
+ dataType: "number",
751
+ editable: false
752
+ },
753
+ dependent_task_id: {
754
+ displayName: "Dependent Task Id",
755
+ dataType: "number",
756
+ editable: false
757
+ }
758
+ },
759
+ PublishedFile: {
760
+ ...AUDIT,
761
+ code: {
762
+ displayName: "Published File Name",
763
+ dataType: "text",
764
+ mandatory: true
765
+ },
766
+ name: {
767
+ displayName: "Name",
768
+ dataType: "text"
769
+ },
770
+ description: {
771
+ displayName: "Description",
772
+ dataType: "text"
773
+ },
774
+ version_number: {
775
+ displayName: "Version Number",
776
+ dataType: "number"
777
+ },
778
+ path: {
779
+ displayName: "Path",
780
+ dataType: "url"
781
+ },
782
+ path_cache: {
783
+ displayName: "Path Cache",
784
+ dataType: "text"
785
+ },
786
+ sg_status_list: statusSpec(PUBLISHED_FILE_STATUSES, "wtg"),
787
+ image: {
788
+ displayName: "Thumbnail",
789
+ dataType: "image"
790
+ },
791
+ project: {
792
+ displayName: "Project",
793
+ dataType: "entity",
794
+ validTypes: ["Project"]
795
+ },
796
+ entity: {
797
+ displayName: "Link",
798
+ dataType: "entity",
799
+ validTypes: [
800
+ "Asset",
801
+ "Shot",
802
+ "Sequence"
803
+ ]
804
+ },
805
+ task: {
806
+ displayName: "Task",
807
+ dataType: "entity",
808
+ validTypes: ["Task"]
809
+ },
810
+ version: {
811
+ displayName: "Version",
812
+ dataType: "entity",
813
+ validTypes: ["Version"]
814
+ },
815
+ published_file_type: {
816
+ displayName: "Published File Type",
817
+ dataType: "entity",
818
+ validTypes: ["PublishedFileType"]
637
819
  }
638
820
  },
639
821
  Note: {
@@ -1048,15 +1230,26 @@ const IDENTITY_FIELD = {
1048
1230
  Task: "content",
1049
1231
  Note: "subject",
1050
1232
  Reply: "content",
1051
- Attachment: "display_name"
1233
+ Attachment: "display_name",
1234
+ TaskTemplate: "code",
1235
+ PublishedFile: "code"
1052
1236
  };
1053
1237
  const GENERATED_IDENTITY = /* @__PURE__ */ new Set([
1054
1238
  "Sequence",
1055
1239
  "Shot",
1056
1240
  "Asset",
1057
1241
  "Version",
1058
- "Task"
1242
+ "Task",
1243
+ "TaskTemplate",
1244
+ "PublishedFile"
1059
1245
  ]);
1246
+ /** The name a generated identity starts with, where it is not the display name (entity_types/TaskTemplate). */
1247
+ const GENERATED_NAME = { TaskTemplate: "TaskTemplate" };
1248
+ /** Where one side of a link is written, the other side follows (088_project_template_defaults). */
1249
+ const MIRRORED = {
1250
+ "Project.task_templates": ["TaskTemplate", "projects"],
1251
+ "TaskTemplate.projects": ["Project", "task_templates"]
1252
+ };
1060
1253
  const DISPLAY_NAMES = {
1061
1254
  Project: "Project",
1062
1255
  Sequence: "Sequence",
@@ -1072,13 +1265,31 @@ const DISPLAY_NAMES = {
1072
1265
  Note: "Note",
1073
1266
  Reply: "Reply",
1074
1267
  Attachment: "Attachment",
1075
- EventLogEntry: "Event Log Entry"
1268
+ EventLogEntry: "Event Log Entry",
1269
+ TaskTemplate: "Task Template",
1270
+ TaskDependency: "Task Dependency",
1271
+ PublishedFile: "Published File"
1076
1272
  };
1077
1273
  /**
1078
1274
  * Which codes each project hides, per type. `hidden_values` is the only thing
1079
1275
  * `project_id` changes (009_status_lists), and it is not a subset of
1080
1276
  * `valid_values`: the probed site hid `blk` and `rdy`, neither of them valid.
1081
1277
  */
1278
+ /**
1279
+ * The fields this site may hide, `visible.editable` true: its own additions and a few
1280
+ * stock fields, `Project.code` among them. Every other field reads false, `sg_` prefix
1281
+ * or not (056_stock_vs_custom_field).
1282
+ */
1283
+ const HIDEABLE = /* @__PURE__ */ new Set([
1284
+ "Project.code",
1285
+ "Shot.sg_complexity",
1286
+ "Shot.sg_lens",
1287
+ "Shot.sg_shot_notes_url",
1288
+ "Asset.sg_build_days",
1289
+ "Asset.sg_complexity",
1290
+ "Version.sg_department",
1291
+ "Version.sg_bar_color"
1292
+ ]);
1082
1293
  const HIDDEN_VALUES = {
1083
1294
  70: {
1084
1295
  "Version.sg_status_list": [
@@ -1980,6 +2191,124 @@ function buildFixtures(seed, counts = {}) {
1980
2191
  });
1981
2192
  eventId += eventId % 7 === 0 ? 3 : 1;
1982
2193
  }
2194
+ let dependencyId = 1;
2195
+ const depend = (downstream, upstream) => {
2196
+ add("TaskDependency", dependencyId, {
2197
+ cached_display_name: `Task ${downstream.id} dependent on Task ${upstream.id}`,
2198
+ task: ref(downstream),
2199
+ dependent_task: ref(upstream),
2200
+ dependency_type: DEPENDENCY_TYPES[0],
2201
+ offset_days: null,
2202
+ shift_ratio: null,
2203
+ task_id: downstream.id,
2204
+ dependent_task_id: upstream.id,
2205
+ created_at: isoDateTime(-180),
2206
+ updated_at: isoDateTime(-180),
2207
+ created_by: ref(bot),
2208
+ updated_by: ref(bot)
2209
+ });
2210
+ dependencyId += 1;
2211
+ };
2212
+ let templateTaskId = 5600;
2213
+ const [shotTemplate, assetTemplate] = [{
2214
+ id: 40,
2215
+ code: "Shot basic",
2216
+ entityType: "Shot"
2217
+ }, {
2218
+ id: 41,
2219
+ code: "Asset build",
2220
+ entityType: "Asset"
2221
+ }].map((t) => {
2222
+ const template = add("TaskTemplate", t.id, {
2223
+ code: t.code,
2224
+ cached_display_name: t.code,
2225
+ description: `Every ${t.entityType} step, one after the other.`,
2226
+ entity_type: t.entityType,
2227
+ projects: [],
2228
+ task_count: null,
2229
+ created_at: isoDateTime(-190),
2230
+ updated_at: isoDateTime(-190),
2231
+ created_by: ref(bot),
2232
+ updated_by: ref(bot)
2233
+ });
2234
+ let previous = null;
2235
+ steps.filter((st) => st.values["entity_type"] === t.entityType).forEach((st, i) => {
2236
+ const content = String(st.values["code"]);
2237
+ const templateTask = add("Task", templateTaskId, {
2238
+ content,
2239
+ cached_display_name: content,
2240
+ sg_description: null,
2241
+ sg_status_list: "wtg",
2242
+ start_date: null,
2243
+ due_date: null,
2244
+ duration: null,
2245
+ est_in_mins: null,
2246
+ time_logs_sum: 0,
2247
+ time_percent_of_est: null,
2248
+ color: "pipeline_step",
2249
+ milestone: false,
2250
+ project: null,
2251
+ entity: null,
2252
+ step: ref(st),
2253
+ task_assignees: [],
2254
+ task_reviewers: [],
2255
+ sg_sort_order: (i + 1) * 10,
2256
+ task_template: ref(template),
2257
+ template_task: null,
2258
+ created_at: isoDateTime(-190),
2259
+ updated_at: isoDateTime(-190),
2260
+ created_by: ref(bot),
2261
+ updated_by: ref(bot)
2262
+ });
2263
+ templateTaskId += 1;
2264
+ if (previous) depend(templateTask, previous);
2265
+ previous = templateTask;
2266
+ });
2267
+ return template;
2268
+ });
2269
+ p0.values["tracking_settings"] = { default_task_template: { Shot: {
2270
+ type: "TaskTemplate",
2271
+ id: shotTemplate.id,
2272
+ name: shotTemplate.values["code"],
2273
+ valid: "valid"
2274
+ } } };
2275
+ p1.values["tracking_settings"] = { default_task_template: {} };
2276
+ p2.values["tracking_settings"] = {};
2277
+ for (const p of projects) p.values["task_templates"] = [];
2278
+ p0.values["task_templates"] = [ref(assetTemplate)];
2279
+ assetTemplate.values["projects"] = [ref(p0)];
2280
+ const shotTemplateTasks = (rows.get("Task") ?? []).filter((t) => t.values["task_template"]?.id === shotTemplate.id);
2281
+ for (const shot of shots.filter((sh) => sh.values["sg_sequence"].id === sequences[0]?.id)) {
2282
+ shot.values["task_template"] = ref(shotTemplate);
2283
+ for (const taskRef of shot.values["tasks"]) {
2284
+ const task = index.get(`Task:${taskRef.id}`);
2285
+ const source = shotTemplateTasks.find((t) => t.values["step"].id === task.values["step"].id);
2286
+ task.values["template_task"] = source ? ref(source) : null;
2287
+ }
2288
+ }
2289
+ versions.slice(0, 12).forEach((version, i) => {
2290
+ const code = `${String(version.values["code"])}.exr`;
2291
+ add("PublishedFile", 3e3 + i, {
2292
+ code,
2293
+ cached_display_name: code,
2294
+ name: code.replace(/_v\d+\.exr$/, ".exr"),
2295
+ description: null,
2296
+ version_number: 1,
2297
+ path: null,
2298
+ path_cache: null,
2299
+ sg_status_list: "cmpt",
2300
+ image: null,
2301
+ project: version.values["project"],
2302
+ entity: version.values["entity"],
2303
+ task: version.values["sg_task"],
2304
+ version: ref(version),
2305
+ published_file_type: null,
2306
+ created_at: version.values["created_at"],
2307
+ updated_at: version.values["created_at"],
2308
+ created_by: ref(bot),
2309
+ updated_by: ref(bot)
2310
+ });
2311
+ });
1983
2312
  const follows = /* @__PURE__ */ new Map();
1984
2313
  const follow = (userRef, row) => {
1985
2314
  if (!userRef || userRef.type !== "HumanUser") return;
@@ -2011,12 +2340,17 @@ var MockClient = class {
2011
2340
  retired = /* @__PURE__ */ new Map();
2012
2341
  /** Rows a batch created with no `project`: no read reaches them, a delete does (report 001). */
2013
2342
  unreadable = /* @__PURE__ */ new Map();
2343
+ /** What a Task delete did to other rows, by `Task:id`, for its revive to undo (089_task_delete_side_effects). */
2344
+ cascades = /* @__PURE__ */ new Map();
2345
+ /** The highest id handed out per type, so an erased row's id is never reused. */
2346
+ issued = /* @__PURE__ */ new Map();
2014
2347
  deletes = 0;
2015
2348
  constructor(options = {}) {
2016
2349
  this.fixtures = buildFixtures(options.seed ?? 1, options.counts ?? {});
2017
2350
  this.latencyMs = options.latencyMs ?? 0;
2018
2351
  this.clock = toClock(options.now);
2019
2352
  this.pendingFailure = options.failNext ?? null;
2353
+ this.derive();
2020
2354
  }
2021
2355
  /**
2022
2356
  * Make the next call reject with an `SgApiError`, for demoing an error state.
@@ -2063,7 +2397,8 @@ var MockClient = class {
2063
2397
  dataType: s.dataType,
2064
2398
  editable: s.editable ?? true,
2065
2399
  mandatory: s.mandatory ?? false,
2066
- unique: s.unique ?? false
2400
+ unique: s.unique ?? false,
2401
+ hideable: HIDEABLE.has(`${entityType}.${name}`)
2067
2402
  };
2068
2403
  if (s.validTypes) field.validTypes = s.validTypes;
2069
2404
  if (s.validValues) field.validValues = s.validValues;
@@ -2096,6 +2431,14 @@ var MockClient = class {
2096
2431
  hasMore: page.length === size
2097
2432
  };
2098
2433
  }
2434
+ async read(entityType, id, options = {}) {
2435
+ await this.gate();
2436
+ const spec = this.schemaOf(entityType);
2437
+ const key = `${entityType}:${id}`;
2438
+ const row = options.retired ? this.retired.get(key) : this.fixtures.index.get(key);
2439
+ if (!row) throw notFound(`${entityType}: ${id} not found`);
2440
+ return this.project(row, spec, options.fields);
2441
+ }
2099
2442
  async textSearch(text, entityTypes, page) {
2100
2443
  await this.gate();
2101
2444
  const words = text.trim().split(/\s+/).filter(Boolean);
@@ -2133,21 +2476,31 @@ var MockClient = class {
2133
2476
  }
2134
2477
  async update(entityType, id, patch) {
2135
2478
  await this.gate();
2136
- return this.updateRow(entityType, id, patch);
2479
+ return this.updateRow(entityType, id, patch, false);
2137
2480
  }
2138
- updateRow(entityType, id, patch) {
2481
+ updateRow(entityType, id, patch, inBatch) {
2139
2482
  const spec = this.schemaOf(entityType);
2140
2483
  const row = this.fixtures.index.get(`${entityType}:${id}`);
2141
- if (!row) throw new SgApiError(404, null, `Entity of type [${entityType}] with id=${id} does not exist.`);
2484
+ if (!row) throw notFound(`Entity of type [${entityType}] with id=${id} does not exist.`);
2142
2485
  for (const [name, value] of Object.entries(patch)) {
2143
2486
  const field = spec[name];
2144
2487
  if (!field) throw new SgApiError(400, null, `API update() ${entityType}.${name} doesn't exist.`);
2145
2488
  if (field.createOnly) throw new SgApiError(400, null, `API update() ${entityType}.${name} is editable on create only.`);
2146
- if (field.editable === false) throw new SgApiError(400, null, `API update() ${entityType}.${name} is read only.`);
2489
+ if (field.editable === false && !field.writable) throw new SgApiError(400, null, `API update() ${entityType}.${name} is read only.`);
2147
2490
  this.checkLink("update", entityType, name, field, value);
2148
- row.values[name] = field.dataType === "text" && value === "" ? null : value;
2491
+ this.checkValue(inBatch, entityType, name, field, value);
2492
+ }
2493
+ for (const [name, value] of Object.entries(patch)) {
2494
+ const field = spec[name];
2495
+ if (entityType === "Task" && (name === "upstream_tasks" || name === "downstream_tasks")) {
2496
+ this.writeEdges(row, name, value);
2497
+ continue;
2498
+ }
2499
+ row.values[name] = (field.dataType === "text" || field.dataType === "status_list") && value === "" ? null : value;
2500
+ this.mirror(row, name);
2149
2501
  }
2150
2502
  if (spec["updated_at"] && Object.keys(patch).length > 0) row.values["updated_at"] = isoDateTime(0);
2503
+ this.derive();
2151
2504
  return this.project(row, spec);
2152
2505
  }
2153
2506
  /**
@@ -2493,17 +2846,21 @@ var MockClient = class {
2493
2846
  */
2494
2847
  createRow(entityType, body, inBatch) {
2495
2848
  const spec = this.schemaOf(entityType);
2496
- const orphan = spec["project"] !== void 0 && body["project"] === void 0;
2849
+ const templateTask = entityType === "Task" && !isNullish(body["task_template"]);
2850
+ const orphan = spec["project"] !== void 0 && body["project"] === void 0 && !templateTask;
2497
2851
  if (orphan && !inBatch) throw new SgApiError(400, null, `API create() missing 'project' attribute: ${JSON.stringify(body)}`);
2852
+ if (templateTask && !isNullish(body["project"])) throw new SgApiError(400, null, "API create() Invalid Task: a task template may not have a project");
2498
2853
  const identity = IDENTITY_FIELD[entityType];
2499
2854
  for (const [name, value] of Object.entries(body)) {
2500
2855
  const field = spec[name];
2501
2856
  if (!field && inBatch) throw new SgApiError(400, null, `Invalid field value, update failed [2 - Invalid field name: field [${entityType}.${name}] does not exist or user does not have access permission.]`);
2502
2857
  if (!field) throw new SgApiError(400, null, `API create() ${entityType}.${name} doesn't exist.`);
2503
- if (field.editable === false && !field.createOnly) throw new SgApiError(400, null, `API create() ${entityType}.${name} is read only.`);
2858
+ if (field.editable === false && !field.createOnly && !field.writable) throw new SgApiError(400, null, `API create() ${entityType}.${name} is read only.`);
2504
2859
  this.checkLink("create", entityType, name, field, value);
2860
+ this.checkValue(true, entityType, name, field, value);
2505
2861
  if (name === identity && value === "") throw new SgApiError(400, null, `Create failed for [${entityType}]: Cannot set identifier field to empty. (${entityType})`);
2506
2862
  }
2863
+ if (entityType === "TaskDependency") this.checkEdge(body);
2507
2864
  const id = this.nextId(entityType);
2508
2865
  const values = {};
2509
2866
  for (const [name, field] of Object.entries(spec)) values[name] = field.dataType === "multi_entity" ? [] : field.defaultValue ?? null;
@@ -2517,7 +2874,8 @@ var MockClient = class {
2517
2874
  values["created_at"] = isoDateTime(0);
2518
2875
  if (spec["updated_at"]) values["updated_at"] = isoDateTime(0);
2519
2876
  Object.assign(values, body, { id });
2520
- if (identity && values[identity] === null && GENERATED_IDENTITY.has(entityType)) values[identity] = `New ${DISPLAY_NAMES[entityType] ?? entityType} ${id}`;
2877
+ if (identity && values[identity] === null && GENERATED_IDENTITY.has(entityType)) values[identity] = `New ${GENERATED_NAME[entityType] ?? DISPLAY_NAMES[entityType] ?? entityType} ${id}`;
2878
+ if (entityType === "TaskDependency" && values["dependency_type"] === null) values["dependency_type"] = DEPENDENCY_TYPES[0];
2521
2879
  values["cached_display_name"] = entityType === "Note" ? [values["subject"], values["content"]].filter(Boolean).join(" - ") : displayNameOf({
2522
2880
  ...values,
2523
2881
  cached_display_name: null
@@ -2536,12 +2894,18 @@ var MockClient = class {
2536
2894
  else this.fixtures.rows.set(entityType, [row]);
2537
2895
  this.fixtures.index.set(`${entityType}:${id}`, row);
2538
2896
  this.linkBack(row);
2897
+ for (const name of Object.keys(body)) if (entityType === "Task" && (name === "upstream_tasks" || name === "downstream_tasks")) this.writeEdges(row, name, body[name]);
2898
+ else this.mirror(row, name);
2899
+ this.derive();
2539
2900
  return this.project(row, spec);
2540
2901
  }
2541
2902
  /**
2542
2903
  * Retire one row: reads skip it and a second delete is 404 (delete_entity_type_id).
2543
- * The mock retires only the row; what a delete does to rows that link to it
2544
- * (probe 060, 089_task_delete_side_effects) is not modelled.
2904
+ * A Task takes its TaskDependency rows with it and leaves `Version.sg_task` and
2905
+ * `PublishedFile.task` null until its revive (089_task_delete_side_effects), alone or
2906
+ * in a batch (103_batch_delete_revive). A TaskTemplate retires its tasks
2907
+ * (entity_types/TaskTemplate). What a Shot delete does to its Versions (probe 060)
2908
+ * is not modelled.
2545
2909
  */
2546
2910
  async delete(entityType, id) {
2547
2911
  await this.gate();
@@ -2551,29 +2915,213 @@ var MockClient = class {
2551
2915
  this.schemaOf(entityType);
2552
2916
  const key = `${entityType}:${id}`;
2553
2917
  const row = this.fixtures.index.get(key) ?? this.unreadable.get(key);
2554
- if (!row) throw new SgApiError(404, null, `Entity of type [${entityType}] with id=${id} does not exist.`);
2918
+ if (!row) throw notFound(`Entity of type [${entityType}] with id=${id} does not exist.`);
2919
+ if (entityType === "TaskTemplate") for (const task of this.live("Task").filter((t) => refsOf(t.values["task_template"])[0]?.id === id)) this.deleteRow("Task", task.id);
2920
+ if (entityType === "Task") {
2921
+ const cascade = {
2922
+ edges: [],
2923
+ unlinked: []
2924
+ };
2925
+ for (const edge of this.edgesOf(id)) {
2926
+ this.retire(edge);
2927
+ cascade.edges.push(`TaskDependency:${edge.id}`);
2928
+ }
2929
+ for (const [type, field] of [["Version", "sg_task"], ["PublishedFile", "task"]]) for (const other of this.live(type)) {
2930
+ const value = other.values[field];
2931
+ if (value?.type !== "Task" || value.id !== id) continue;
2932
+ other.values[field] = null;
2933
+ cascade.unlinked.push({
2934
+ row: other,
2935
+ field,
2936
+ value
2937
+ });
2938
+ }
2939
+ this.cascades.set(key, cascade);
2940
+ }
2555
2941
  this.unreadable.delete(key);
2556
- this.fixtures.index.delete(key);
2557
- const rows = this.fixtures.rows.get(entityType) ?? [];
2558
- const at = rows.indexOf(row);
2559
- if (at >= 0) rows.splice(at, 1);
2560
- this.retired.set(key, row);
2942
+ this.retire(row);
2943
+ this.derive();
2561
2944
  }
2562
- /** Bring a retired row back with its values; `false` for a row that is already live (post_entity_type_id). */
2945
+ /**
2946
+ * Bring a retired row back with its values; `false` for a row that is already live
2947
+ * (post_entity_type_id). A Task gets back what its delete took (089_task_delete_side_effects).
2948
+ * A TaskDependency whose pair is linked again refuses, the pair being a unique index
2949
+ * (095_dependency_remove_undo). A TaskTemplate's tasks stay retired: unmeasured.
2950
+ */
2563
2951
  async revive(entityType, id) {
2564
2952
  await this.gate();
2565
2953
  this.schemaOf(entityType);
2566
2954
  const key = `${entityType}:${id}`;
2567
2955
  if (this.fixtures.index.has(key)) return false;
2568
2956
  const row = this.retired.get(key);
2569
- if (!row) throw new SgApiError(404, null, `Entity of type [${entityType}] with id=${id} does not exist.`);
2957
+ if (!row) throw notFound(`Entity of type [${entityType}] with id=${id} does not exist.`);
2958
+ if (entityType === "TaskDependency" && this.pairTaken(row)) throw new SgApiError(400, null, `Revive failed for [TaskDependency with id=${id}]: Can't unretire the entity because a field has a non-unique value for a unique index: sgcu_task_dependencies`);
2959
+ this.unretire(row);
2960
+ const cascade = this.cascades.get(key);
2961
+ if (cascade) {
2962
+ this.cascades.delete(key);
2963
+ for (const edgeKey of cascade.edges) {
2964
+ const edge = this.retired.get(edgeKey);
2965
+ if (edge && !this.pairTaken(edge)) this.unretire(edge);
2966
+ }
2967
+ for (const { row: other, field, value } of cascade.unlinked) if (this.fixtures.index.get(`${other.type}:${other.id}`) === other && other.values[field] === null) other.values[field] = value;
2968
+ }
2969
+ this.derive();
2970
+ return true;
2971
+ }
2972
+ retire(row) {
2973
+ const key = `${row.type}:${row.id}`;
2974
+ this.fixtures.index.delete(key);
2975
+ const rows = this.fixtures.rows.get(row.type) ?? [];
2976
+ const at = rows.indexOf(row);
2977
+ if (at >= 0) rows.splice(at, 1);
2978
+ this.retired.set(key, row);
2979
+ }
2980
+ unretire(row) {
2981
+ const key = `${row.type}:${row.id}`;
2570
2982
  this.retired.delete(key);
2571
- const rows = this.fixtures.rows.get(entityType) ?? [];
2572
- const at = rows.findIndex((r) => r.id > id);
2983
+ const rows = this.fixtures.rows.get(row.type) ?? [];
2984
+ const at = rows.findIndex((r) => r.id > row.id);
2573
2985
  rows.splice(at < 0 ? rows.length : at, 0, row);
2574
- this.fixtures.rows.set(entityType, rows);
2986
+ this.fixtures.rows.set(row.type, rows);
2575
2987
  this.fixtures.index.set(key, row);
2576
- return true;
2988
+ }
2989
+ /** Remove a row for good: no read reaches it and no revive brings it back. */
2990
+ erase(row) {
2991
+ this.fixtures.index.delete(`${row.type}:${row.id}`);
2992
+ const rows = this.fixtures.rows.get(row.type) ?? [];
2993
+ const at = rows.indexOf(row);
2994
+ if (at >= 0) rows.splice(at, 1);
2995
+ }
2996
+ live(entityType) {
2997
+ return [...this.fixtures.rows.get(entityType) ?? []];
2998
+ }
2999
+ /** The live TaskDependency rows with this Task at either end. */
3000
+ edgesOf(taskId) {
3001
+ return this.live("TaskDependency").filter((d) => refsOf(d.values["task"])[0]?.id === taskId || refsOf(d.values["dependent_task"])[0]?.id === taskId);
3002
+ }
3003
+ /** Whether a live row already links this row's pair. */
3004
+ pairTaken(edge) {
3005
+ const down = refsOf(edge.values["task"])[0]?.id;
3006
+ const up = refsOf(edge.values["dependent_task"])[0]?.id;
3007
+ return this.live("TaskDependency").some((d) => d !== edge && refsOf(d.values["task"])[0]?.id === down && refsOf(d.values["dependent_task"])[0]?.id === up);
3008
+ }
3009
+ /** Whether `from` depends on `target`, directly or through other Tasks. */
3010
+ dependsOn(from, target) {
3011
+ const seen = /* @__PURE__ */ new Set();
3012
+ const queue = [from];
3013
+ while (queue.length > 0) {
3014
+ const at = queue.shift();
3015
+ if (at === target) return true;
3016
+ if (seen.has(at)) continue;
3017
+ seen.add(at);
3018
+ for (const d of this.live("TaskDependency")) if (refsOf(d.values["task"])[0]?.id === at) {
3019
+ const up = refsOf(d.values["dependent_task"])[0]?.id;
3020
+ if (up !== void 0) queue.push(up);
3021
+ }
3022
+ }
3023
+ return false;
3024
+ }
3025
+ /**
3026
+ * The checks a TaskDependency create makes: one of four types, one row per pair, and
3027
+ * no loop of any length (085_task_dependency_types, 107_dependency_three_task_loop).
3028
+ */
3029
+ checkEdge(body) {
3030
+ const failed = (reason) => new SgApiError(400, null, `Create failed for [TaskDependency]: ${reason}`);
3031
+ if ("dependency_type" in body && !DEPENDENCY_TYPES.includes(body["dependency_type"])) throw failed(`Validation failed: Dependency type Dependency type '${body["dependency_type"] === null ? "" : String(body["dependency_type"])}' is invalid, accepted values are ${DEPENDENCY_TYPES.join(", ")}.`);
3032
+ const down = refsOf(body["task"])[0]?.id;
3033
+ const up = refsOf(body["dependent_task"])[0]?.id;
3034
+ if (down === void 0 || up === void 0) return;
3035
+ if (this.live("TaskDependency").some((d) => refsOf(d.values["task"])[0]?.id === down && refsOf(d.values["dependent_task"])[0]?.id === up)) throw failed("Validation failed: There is already a connection between the entities.");
3036
+ if (down === up || this.dependsOn(up, down)) throw failed("Can't create this dependency as it causes a loop.");
3037
+ }
3038
+ /**
3039
+ * A write to `upstream_tasks` or `downstream_tasks`: a new neighbour gets a row of the
3040
+ * default type and no offset, a dropped one's row is erased, not retired
3041
+ * (086_batch_tasks_with_dependencies, 095_dependency_remove_undo).
3042
+ */
3043
+ writeEdges(task, field, value) {
3044
+ const [own, other] = field === "upstream_tasks" ? ["task", "dependent_task"] : ["dependent_task", "task"];
3045
+ const wanted = refsOf(value).map((r) => r.id);
3046
+ const current = this.live("TaskDependency").filter((d) => refsOf(d.values[own])[0]?.id === task.id);
3047
+ for (const edge of current) if (!wanted.includes(refsOf(edge.values[other])[0]?.id)) this.erase(edge);
3048
+ for (const id of wanted) {
3049
+ if (current.some((d) => refsOf(d.values[other])[0]?.id === id)) continue;
3050
+ const neighbour = {
3051
+ type: "Task",
3052
+ id
3053
+ };
3054
+ const [down, up] = own === "task" ? [ref(task), neighbour] : [neighbour, ref(task)];
3055
+ const edgeId = this.nextId("TaskDependency");
3056
+ const author = this.fixtures.rows.get("ApiUser")?.[0];
3057
+ const edge = {
3058
+ type: "TaskDependency",
3059
+ id: edgeId,
3060
+ values: {
3061
+ id: edgeId,
3062
+ cached_display_name: null,
3063
+ task: down,
3064
+ dependent_task: up,
3065
+ dependency_type: DEPENDENCY_TYPES[0],
3066
+ offset_days: null,
3067
+ shift_ratio: null,
3068
+ task_id: down.id,
3069
+ dependent_task_id: up.id,
3070
+ created_at: isoDateTime(0),
3071
+ updated_at: isoDateTime(0),
3072
+ created_by: author ? ref(author) : null,
3073
+ updated_by: author ? ref(author) : null
3074
+ }
3075
+ };
3076
+ const rows = this.fixtures.rows.get("TaskDependency");
3077
+ if (rows) rows.push(edge);
3078
+ else this.fixtures.rows.set("TaskDependency", [edge]);
3079
+ this.fixtures.index.set(`TaskDependency:${edgeId}`, edge);
3080
+ }
3081
+ }
3082
+ /** Where the written field is one side of a two-sided link, make the other side agree. */
3083
+ mirror(row, name) {
3084
+ const pair = MIRRORED[`${row.type}.${name}`];
3085
+ if (!pair) return;
3086
+ const [otherType, otherField] = pair;
3087
+ const linked = new Set(refsOf(row.values[name]).map((r) => r.id));
3088
+ for (const other of this.live(otherType)) {
3089
+ const list = refsOf(other.values[otherField]).filter((r) => !(r.type === row.type && r.id === row.id));
3090
+ if (linked.has(other.id)) list.push(ref(row));
3091
+ other.values[otherField] = list;
3092
+ }
3093
+ }
3094
+ /**
3095
+ * What the server derives from other rows: a Task's two edge lists from the
3096
+ * TaskDependency rows, their ids and name, and a template's task count, a string.
3097
+ */
3098
+ derive() {
3099
+ const upstream = /* @__PURE__ */ new Map();
3100
+ const downstream = /* @__PURE__ */ new Map();
3101
+ const push = (map, key, value) => {
3102
+ map.set(key, [...map.get(key) ?? [], {
3103
+ type: "Task",
3104
+ id: value.id
3105
+ }]);
3106
+ };
3107
+ for (const edge of this.live("TaskDependency")) {
3108
+ const down = refsOf(edge.values["task"])[0];
3109
+ const up = refsOf(edge.values["dependent_task"])[0];
3110
+ edge.values["task_id"] = down?.id ?? null;
3111
+ edge.values["dependent_task_id"] = up?.id ?? null;
3112
+ edge.values["cached_display_name"] = `Task ${down?.id ?? ""} dependent on Task ${up?.id ?? ""}`;
3113
+ if (!down || !up) continue;
3114
+ push(upstream, down.id, up);
3115
+ push(downstream, up.id, down);
3116
+ }
3117
+ const counts = /* @__PURE__ */ new Map();
3118
+ for (const task of this.live("Task")) {
3119
+ task.values["upstream_tasks"] = upstream.get(task.id) ?? [];
3120
+ task.values["downstream_tasks"] = downstream.get(task.id) ?? [];
3121
+ const template = refsOf(task.values["task_template"])[0];
3122
+ if (template) counts.set(template.id, (counts.get(template.id) ?? 0) + 1);
3123
+ }
3124
+ for (const template of this.live("TaskTemplate")) template.values["task_count"] = String(counts.get(template.id) ?? 0);
2577
3125
  }
2578
3126
  /**
2579
3127
  * Apply the requests in order, and undo all of them when one fails (recipes/002).
@@ -2621,7 +3169,7 @@ var MockClient = class {
2621
3169
  };
2622
3170
  return {
2623
3171
  request_type: "update",
2624
- data: this.updateRow(request.entity, id, data)
3172
+ data: this.updateRow(request.entity, id, data, true)
2625
3173
  };
2626
3174
  }
2627
3175
  /** Everything a batch can change, and a function that puts it back. */
@@ -2631,6 +3179,8 @@ var MockClient = class {
2631
3179
  const values = new Map([...index.values()].map((row) => [row, structuredClone(row.values)]));
2632
3180
  const retired = new Map(this.retired);
2633
3181
  const unreadable = new Map(this.unreadable);
3182
+ const cascades = new Map(this.cascades);
3183
+ const issued = new Map(this.issued);
2634
3184
  const deletes = this.deletes;
2635
3185
  const refill = (target, from) => {
2636
3186
  target.clear();
@@ -2642,6 +3192,8 @@ var MockClient = class {
2642
3192
  for (const [row, saved] of values) row.values = saved;
2643
3193
  refill(this.retired, retired);
2644
3194
  refill(this.unreadable, unreadable);
3195
+ refill(this.cascades, cascades);
3196
+ refill(this.issued, issued);
2645
3197
  this.deletes = deletes;
2646
3198
  };
2647
3199
  }
@@ -2656,7 +3208,7 @@ var MockClient = class {
2656
3208
  await this.gate();
2657
3209
  const spec = this.schemaOf(entityType);
2658
3210
  const target = this.fixtures.index.get(`${entityType}:${id}`);
2659
- if (!target) throw new SgApiError(404, null, `Entity of type [${entityType}] with id=${id} does not exist.`);
3211
+ if (!target) throw notFound(`Entity of type [${entityType}] with id=${id} does not exist.`);
2660
3212
  if (!file.filename) throw new SgApiError(400, { filename: ["filename is missing"] }, "Request Parameters invalid.");
2661
3213
  if (file.field !== void 0 && !spec[file.field]) throw new SgApiError(404, null, `Field '${entityType}.${file.field}' does not exist.`);
2662
3214
  const attachmentId = this.nextId("Attachment");
@@ -2730,11 +3282,33 @@ var MockClient = class {
2730
3282
  }
2731
3283
  if (typeof value === "object" && !Array.isArray(value) && typeof value.type !== "string") throw new SgApiError(400, null, `API ${verb}() invalid/missing entity hash string 'type': ${JSON.stringify(value)}`);
2732
3284
  }
3285
+ /**
3286
+ * Values the server checks beyond their shape. A status code must be in the site-wide
3287
+ * `valid_values`, hidden ones included, and a create or any batch request wraps the
3288
+ * refusal (field_types/status_list, recipes/002, 094_permission_preflight). A
3289
+ * TaskTemplate's `entity_type` must name a type (entity_types/TaskTemplate).
3290
+ */
3291
+ checkValue(wrapped, entityType, name, field, value) {
3292
+ if (value === null || value === void 0 || value === "") return;
3293
+ let reason = null;
3294
+ if (field.dataType === "status_list" && !(field.validValues ?? []).includes(value)) {
3295
+ const valid = (field.validValues ?? []).map((v) => `'${v}'`).join(", ");
3296
+ reason = `Update failed for [${entityType}.${name}]: '${String(value)}' is not a valid status. Valid statuses: ${valid}.`;
3297
+ }
3298
+ if (field.dataType === "entity_type" && field.writable && !SPECS[value]) {
3299
+ const valid = Object.keys(SPECS).map((v) => `'${v}'`).join(", ");
3300
+ throw new SgApiError(400, null, `Update failed for [${entityType}.${name}]: '${String(value)}' is not a valid entity type. Valid entity types: ${valid}.`);
3301
+ }
3302
+ if (reason === null) return;
3303
+ throw new SgApiError(400, null, wrapped ? `Invalid field value, update failed [5 - ${reason}]` : reason);
3304
+ }
2733
3305
  /** The next free id of a type, which is what a create takes. */
2734
3306
  nextId(entityType) {
2735
3307
  let taken = (this.fixtures.rows.get(entityType) ?? []).reduce((max, row) => Math.max(max, row.id), 0);
2736
3308
  for (const row of [...this.retired.values(), ...this.unreadable.values()]) if (row.type === entityType) taken = Math.max(taken, row.id);
2737
- return taken + 1;
3309
+ const id = Math.max(taken, this.issued.get(entityType) ?? 0) + 1;
3310
+ this.issued.set(entityType, id);
3311
+ return id;
2738
3312
  }
2739
3313
  /** The reverse view of a link the server fills in: a Reply lands in `Note.replies`. */
2740
3314
  linkBack(row) {
@@ -3029,6 +3603,19 @@ function entityTypeNamed(name) {
3029
3603
  const wanted = name.toLowerCase();
3030
3604
  return Object.keys(SPECS).find((type) => type.toLowerCase() === wanted || pluralPath(type) === wanted) ?? null;
3031
3605
  }
3606
+ /**
3607
+ * The 404 a row that is not there answers: code 104, title `Not Found`, the message in
3608
+ * `detail` (put_entity_type_id, get_entity_notes_id_thread_contents). The message is the
3609
+ * detail, as on every other mock error.
3610
+ */
3611
+ function notFound(detail) {
3612
+ return new SgApiError(404, { errors: [{
3613
+ status: 404,
3614
+ code: 104,
3615
+ title: "Not Found",
3616
+ detail
3617
+ }] }, detail);
3618
+ }
3032
3619
  function isNullish(v) {
3033
3620
  return v === null || v === void 0 || v === "";
3034
3621
  }