sg-widgets-core 0.1.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 { B as operatorsFor, E as toFilterArray, F as isFilterable, I as isLinkType, L as isNumericType, M as TIME_UNITS, _ as pluralPath, d as fieldSchemaOverride, i as eventLogFilters, k as NEGATING_OPERATORS, o as normalizeEventLogEntry, r as SgApiError, t as EVENT_LOG_FIELDS, u as displayNameOf } from "./client-DwN_-UuF.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;
@@ -2007,11 +2336,21 @@ var MockClient = class {
2007
2336
  latencyMs;
2008
2337
  clock;
2009
2338
  pendingFailure;
2339
+ /** Rows a delete retired, by `Type:id`. Reads skip them and a revive puts them back. */
2340
+ retired = /* @__PURE__ */ new Map();
2341
+ /** Rows a batch created with no `project`: no read reaches them, a delete does (report 001). */
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();
2347
+ deletes = 0;
2010
2348
  constructor(options = {}) {
2011
2349
  this.fixtures = buildFixtures(options.seed ?? 1, options.counts ?? {});
2012
2350
  this.latencyMs = options.latencyMs ?? 0;
2013
2351
  this.clock = toClock(options.now);
2014
2352
  this.pendingFailure = options.failNext ?? null;
2353
+ this.derive();
2015
2354
  }
2016
2355
  /**
2017
2356
  * Make the next call reject with an `SgApiError`, for demoing an error state.
@@ -2058,7 +2397,8 @@ var MockClient = class {
2058
2397
  dataType: s.dataType,
2059
2398
  editable: s.editable ?? true,
2060
2399
  mandatory: s.mandatory ?? false,
2061
- unique: s.unique ?? false
2400
+ unique: s.unique ?? false,
2401
+ hideable: HIDEABLE.has(`${entityType}.${name}`)
2062
2402
  };
2063
2403
  if (s.validTypes) field.validTypes = s.validTypes;
2064
2404
  if (s.validValues) field.validValues = s.validValues;
@@ -2091,6 +2431,14 @@ var MockClient = class {
2091
2431
  hasMore: page.length === size
2092
2432
  };
2093
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
+ }
2094
2442
  async textSearch(text, entityTypes, page) {
2095
2443
  await this.gate();
2096
2444
  const words = text.trim().split(/\s+/).filter(Boolean);
@@ -2128,18 +2476,31 @@ var MockClient = class {
2128
2476
  }
2129
2477
  async update(entityType, id, patch) {
2130
2478
  await this.gate();
2479
+ return this.updateRow(entityType, id, patch, false);
2480
+ }
2481
+ updateRow(entityType, id, patch, inBatch) {
2131
2482
  const spec = this.schemaOf(entityType);
2132
2483
  const row = this.fixtures.index.get(`${entityType}:${id}`);
2133
- 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.`);
2134
2485
  for (const [name, value] of Object.entries(patch)) {
2135
2486
  const field = spec[name];
2136
2487
  if (!field) throw new SgApiError(400, null, `API update() ${entityType}.${name} doesn't exist.`);
2137
2488
  if (field.createOnly) throw new SgApiError(400, null, `API update() ${entityType}.${name} is editable on create only.`);
2138
- 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.`);
2139
2490
  this.checkLink("update", entityType, name, field, value);
2140
- 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);
2141
2501
  }
2142
2502
  if (spec["updated_at"] && Object.keys(patch).length > 0) row.values["updated_at"] = isoDateTime(0);
2503
+ this.derive();
2143
2504
  return this.project(row, spec);
2144
2505
  }
2145
2506
  /**
@@ -2476,16 +2837,30 @@ var MockClient = class {
2476
2837
  */
2477
2838
  async create(entityType, body) {
2478
2839
  await this.gate();
2840
+ return this.createRow(entityType, body, false);
2841
+ }
2842
+ /**
2843
+ * A create, alone or inside a batch. The batch skips the `project` check and stores
2844
+ * the row where no read reaches it (report 001), and it spells an unknown field its
2845
+ * own way (recipes/002).
2846
+ */
2847
+ createRow(entityType, body, inBatch) {
2479
2848
  const spec = this.schemaOf(entityType);
2480
- if (spec["project"] && body["project"] === void 0) throw new SgApiError(400, null, `API create() missing 'project' attribute: ${JSON.stringify(body)}`);
2849
+ const templateTask = entityType === "Task" && !isNullish(body["task_template"]);
2850
+ const orphan = spec["project"] !== void 0 && body["project"] === void 0 && !templateTask;
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");
2481
2853
  const identity = IDENTITY_FIELD[entityType];
2482
2854
  for (const [name, value] of Object.entries(body)) {
2483
2855
  const field = spec[name];
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.]`);
2484
2857
  if (!field) throw new SgApiError(400, null, `API create() ${entityType}.${name} doesn't exist.`);
2485
- 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.`);
2486
2859
  this.checkLink("create", entityType, name, field, value);
2860
+ this.checkValue(true, entityType, name, field, value);
2487
2861
  if (name === identity && value === "") throw new SgApiError(400, null, `Create failed for [${entityType}]: Cannot set identifier field to empty. (${entityType})`);
2488
2862
  }
2863
+ if (entityType === "TaskDependency") this.checkEdge(body);
2489
2864
  const id = this.nextId(entityType);
2490
2865
  const values = {};
2491
2866
  for (const [name, field] of Object.entries(spec)) values[name] = field.dataType === "multi_entity" ? [] : field.defaultValue ?? null;
@@ -2499,7 +2874,8 @@ var MockClient = class {
2499
2874
  values["created_at"] = isoDateTime(0);
2500
2875
  if (spec["updated_at"]) values["updated_at"] = isoDateTime(0);
2501
2876
  Object.assign(values, body, { id });
2502
- 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];
2503
2879
  values["cached_display_name"] = entityType === "Note" ? [values["subject"], values["content"]].filter(Boolean).join(" - ") : displayNameOf({
2504
2880
  ...values,
2505
2881
  cached_display_name: null
@@ -2509,14 +2885,319 @@ var MockClient = class {
2509
2885
  id,
2510
2886
  values
2511
2887
  };
2888
+ if (orphan) {
2889
+ this.unreadable.set(`${entityType}:${id}`, row);
2890
+ return this.project(row, spec);
2891
+ }
2512
2892
  const rows = this.fixtures.rows.get(entityType);
2513
2893
  if (rows) rows.push(row);
2514
2894
  else this.fixtures.rows.set(entityType, [row]);
2515
2895
  this.fixtures.index.set(`${entityType}:${id}`, row);
2516
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();
2517
2900
  return this.project(row, spec);
2518
2901
  }
2519
2902
  /**
2903
+ * Retire one row: reads skip it and a second delete is 404 (delete_entity_type_id).
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.
2909
+ */
2910
+ async delete(entityType, id) {
2911
+ await this.gate();
2912
+ this.deleteRow(entityType, id);
2913
+ }
2914
+ deleteRow(entityType, id) {
2915
+ this.schemaOf(entityType);
2916
+ const key = `${entityType}:${id}`;
2917
+ const row = this.fixtures.index.get(key) ?? this.unreadable.get(key);
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
+ }
2941
+ this.unreadable.delete(key);
2942
+ this.retire(row);
2943
+ this.derive();
2944
+ }
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
+ */
2951
+ async revive(entityType, id) {
2952
+ await this.gate();
2953
+ this.schemaOf(entityType);
2954
+ const key = `${entityType}:${id}`;
2955
+ if (this.fixtures.index.has(key)) return false;
2956
+ const row = this.retired.get(key);
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}`;
2982
+ this.retired.delete(key);
2983
+ const rows = this.fixtures.rows.get(row.type) ?? [];
2984
+ const at = rows.findIndex((r) => r.id > row.id);
2985
+ rows.splice(at < 0 ? rows.length : at, 0, row);
2986
+ this.fixtures.rows.set(row.type, rows);
2987
+ this.fixtures.index.set(key, row);
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);
3125
+ }
3126
+ /**
3127
+ * Apply the requests in order, and undo all of them when one fails (recipes/002).
3128
+ * A create or update row carries the record under `data`; a delete row is flat.
3129
+ */
3130
+ async batch(requests) {
3131
+ await this.gate();
3132
+ const restore = this.snapshot();
3133
+ try {
3134
+ return requests.map((request, i) => this.batchOne(request, i));
3135
+ } catch (error) {
3136
+ restore();
3137
+ throw error;
3138
+ }
3139
+ }
3140
+ batchOne(request, i) {
3141
+ const invalid = (source) => new SgApiError(400, { errors: [{
3142
+ status: 400,
3143
+ code: 103,
3144
+ title: "Request Parameters invalid.",
3145
+ source
3146
+ }] }, "Request Parameters invalid.");
3147
+ const kind = request.request_type;
3148
+ if (kind !== "create" && kind !== "update" && kind !== "delete") throw invalid({ requests: { [i]: { request_type: ["request_type must be one of: create, update, delete"] } } });
3149
+ if (!request.entity) throw invalid({ requests: { [i]: { entity: ["entity is missing"] } } });
3150
+ if (!SPECS[request.entity]) throw new SgApiError(400, null, "Invalid entity type: entity type [] does not exist.");
3151
+ const id = "record_id" in request ? request.record_id ?? 0 : 0;
3152
+ if (request.request_type === "delete") {
3153
+ this.deleteRow(request.entity, id);
3154
+ this.deletes += 1;
3155
+ const uuid = `00000000-0000-4000-8000-${this.deletes.toString(16).padStart(12, "0")}`;
3156
+ return {
3157
+ request_type: "delete",
3158
+ type: request.entity,
3159
+ id,
3160
+ uuid,
3161
+ did_delete: true
3162
+ };
3163
+ }
3164
+ const data = request.data;
3165
+ if (data === null || typeof data !== "object") throw invalid({ data: ["data hash containing field/value pairs is required for the given request"] });
3166
+ if (request.request_type === "create") return {
3167
+ request_type: "create",
3168
+ data: this.createRow(request.entity, data, true)
3169
+ };
3170
+ return {
3171
+ request_type: "update",
3172
+ data: this.updateRow(request.entity, id, data, true)
3173
+ };
3174
+ }
3175
+ /** Everything a batch can change, and a function that puts it back. */
3176
+ snapshot() {
3177
+ const rows = new Map([...this.fixtures.rows].map(([type, list]) => [type, [...list]]));
3178
+ const index = new Map(this.fixtures.index);
3179
+ const values = new Map([...index.values()].map((row) => [row, structuredClone(row.values)]));
3180
+ const retired = new Map(this.retired);
3181
+ const unreadable = new Map(this.unreadable);
3182
+ const cascades = new Map(this.cascades);
3183
+ const issued = new Map(this.issued);
3184
+ const deletes = this.deletes;
3185
+ const refill = (target, from) => {
3186
+ target.clear();
3187
+ for (const [k, v] of from) target.set(k, v);
3188
+ };
3189
+ return () => {
3190
+ refill(this.fixtures.rows, rows);
3191
+ refill(this.fixtures.index, index);
3192
+ for (const [row, saved] of values) row.values = saved;
3193
+ refill(this.retired, retired);
3194
+ refill(this.unreadable, unreadable);
3195
+ refill(this.cascades, cascades);
3196
+ refill(this.issued, issued);
3197
+ this.deletes = deletes;
3198
+ };
3199
+ }
3200
+ /**
2520
3201
  * Put a file on a row, as the three-call handshake leaves the site.
2521
3202
  *
2522
3203
  * The field in the path picks the kind: `image` a Thumbnail, another field an
@@ -2527,7 +3208,7 @@ var MockClient = class {
2527
3208
  await this.gate();
2528
3209
  const spec = this.schemaOf(entityType);
2529
3210
  const target = this.fixtures.index.get(`${entityType}:${id}`);
2530
- 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.`);
2531
3212
  if (!file.filename) throw new SgApiError(400, { filename: ["filename is missing"] }, "Request Parameters invalid.");
2532
3213
  if (file.field !== void 0 && !spec[file.field]) throw new SgApiError(404, null, `Field '${entityType}.${file.field}' does not exist.`);
2533
3214
  const attachmentId = this.nextId("Attachment");
@@ -2601,9 +3282,33 @@ var MockClient = class {
2601
3282
  }
2602
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)}`);
2603
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
+ }
2604
3305
  /** The next free id of a type, which is what a create takes. */
2605
3306
  nextId(entityType) {
2606
- return (this.fixtures.rows.get(entityType) ?? []).reduce((max, row) => Math.max(max, row.id), 0) + 1;
3307
+ let taken = (this.fixtures.rows.get(entityType) ?? []).reduce((max, row) => Math.max(max, row.id), 0);
3308
+ for (const row of [...this.retired.values(), ...this.unreadable.values()]) if (row.type === entityType) taken = Math.max(taken, row.id);
3309
+ const id = Math.max(taken, this.issued.get(entityType) ?? 0) + 1;
3310
+ this.issued.set(entityType, id);
3311
+ return id;
2607
3312
  }
2608
3313
  /** The reverse view of a link the server fills in: a Reply lands in `Note.replies`. */
2609
3314
  linkBack(row) {
@@ -2898,6 +3603,19 @@ function entityTypeNamed(name) {
2898
3603
  const wanted = name.toLowerCase();
2899
3604
  return Object.keys(SPECS).find((type) => type.toLowerCase() === wanted || pluralPath(type) === wanted) ?? null;
2900
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
+ }
2901
3619
  function isNullish(v) {
2902
3620
  return v === null || v === void 0 || v === "";
2903
3621
  }