@frontera-sdk/cli 1.52.39 → 1.52.40

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 (225) hide show
  1. package/README.md +9 -0
  2. package/package.json +6 -4
  3. package/src/adopt.ts +6 -2
  4. package/src/api/apps-api.ts +86 -4
  5. package/src/api/blueprint-authoring-api.ts +35 -11
  6. package/src/api/dataset-api.ts +29 -0
  7. package/src/api/governed-action-api.ts +90 -2
  8. package/src/api/platform-api.ts +87 -2
  9. package/src/api/workflow-api.ts +33 -2
  10. package/src/app-contract.ts +156 -0
  11. package/src/app-lock.ts +125 -0
  12. package/src/args.ts +14 -3
  13. package/src/auth-verify.ts +10 -0
  14. package/src/automation-template.ts +12 -1
  15. package/src/blueprint/action-rules.ts +516 -0
  16. package/src/blueprint/action-semantic-fit.ts +205 -0
  17. package/src/blueprint/compile.ts +51 -2
  18. package/src/blueprint/diff.ts +34 -2
  19. package/src/blueprint/metric-query.ts +215 -0
  20. package/src/blueprint/metric-rules.ts +122 -0
  21. package/src/blueprint/object-sets.ts +116 -0
  22. package/src/blueprint/ontology-edit-plan.ts +48 -4
  23. package/src/blueprint/projection.ts +24 -1
  24. package/src/blueprint/scaffold.ts +84 -42
  25. package/src/commands/action/available.ts +1 -1
  26. package/src/commands/action/criteria-refusal.ts +84 -0
  27. package/src/commands/action/decide.ts +5 -2
  28. package/src/commands/action/deploy.ts +13 -4
  29. package/src/commands/action/list.ts +134 -19
  30. package/src/commands/action/requests.ts +34 -6
  31. package/src/commands/action/submit.ts +28 -13
  32. package/src/commands/agent/index-commands.ts +69 -7
  33. package/src/commands/agent/resolve.ts +2 -0
  34. package/src/commands/app/checks.ts +44 -0
  35. package/src/commands/app/deploy.ts +243 -60
  36. package/src/commands/app/deployments.ts +97 -0
  37. package/src/commands/app/dev.ts +103 -11
  38. package/src/commands/app/init.ts +36 -3
  39. package/src/commands/app/promote.ts +45 -19
  40. package/src/commands/app/pull.ts +3 -1
  41. package/src/commands/app/rollback.ts +46 -0
  42. package/src/commands/app/sdk.ts +54 -8
  43. package/src/commands/app/unpin.ts +27 -0
  44. package/src/commands/app/versions.ts +1 -1
  45. package/src/commands/automation/bindings.ts +4 -2
  46. package/src/commands/automation/build-entry.ts +4 -1
  47. package/src/commands/automation/index-commands.ts +3 -1
  48. package/src/commands/automation/pull.ts +6 -1
  49. package/src/commands/automation/run.ts +34 -9
  50. package/src/commands/blueprint/bind.ts +2 -1
  51. package/src/commands/blueprint/declarative.ts +331 -31
  52. package/src/commands/blueprint/get.ts +6 -2
  53. package/src/commands/blueprint/grants.ts +64 -10
  54. package/src/commands/blueprint/metric.ts +108 -0
  55. package/src/commands/blueprint/object-sets.ts +71 -0
  56. package/src/commands/blueprint/query.ts +155 -31
  57. package/src/commands/dataset/index-commands.ts +156 -7
  58. package/src/commands/knowledge/index-commands.ts +122 -20
  59. package/src/commands/local/check.ts +657 -44
  60. package/src/commands/local/data.ts +33 -12
  61. package/src/commands/local/dev.ts +875 -88
  62. package/src/commands/local/docs-mcp.ts +10 -18
  63. package/src/commands/local/index-commands.ts +300 -63
  64. package/src/commands/local/init.ts +18 -5
  65. package/src/commands/local/key.ts +0 -3
  66. package/src/commands/local/providers.ts +0 -7
  67. package/src/commands/local/remove.ts +116 -0
  68. package/src/commands/local/schema.ts +3 -4
  69. package/src/commands/local/sdk-refresh.ts +59 -0
  70. package/src/commands/local/snapshot.ts +100 -0
  71. package/src/commands/local/sync-request.ts +320 -13
  72. package/src/commands/local/sync.ts +102 -5
  73. package/src/commands/mcp/serve.ts +34 -0
  74. package/src/commands/registry.ts +52 -8
  75. package/src/commands/types.ts +29 -0
  76. package/src/commands/workflow/archive.ts +46 -0
  77. package/src/commands/workflow/get.ts +2 -1
  78. package/src/commands/workflow/index-commands.ts +5 -0
  79. package/src/commands/workflow/push.ts +49 -2
  80. package/src/commands/workflow/run.ts +65 -7
  81. package/src/commands/workflow/runs.ts +9 -6
  82. package/src/commands/workflow/webhook-test.ts +166 -0
  83. package/src/config.ts +43 -6
  84. package/src/dev-broker.ts +52 -0
  85. package/src/exit.ts +103 -4
  86. package/src/flag-help.ts +77 -17
  87. package/src/forge/touches.ts +15 -12
  88. package/src/function-outside-imports.ts +89 -0
  89. package/src/harness.ts +42 -24
  90. package/src/heal.ts +112 -1
  91. package/src/local/fix-words.ts +12 -0
  92. package/src/local/function-bundle-recovery.ts +775 -0
  93. package/src/local/local-actions.ts +103 -10
  94. package/src/local/local-agent-action-grants.ts +210 -0
  95. package/src/local/local-agent-checks.ts +232 -0
  96. package/src/local/local-agent-grants.ts +29 -7
  97. package/src/local/local-agent-lean-plugins.ts +68 -0
  98. package/src/local/local-agent-missing-tools.ts +68 -0
  99. package/src/local/local-agent-name.ts +27 -0
  100. package/src/local/local-app-code.ts +191 -0
  101. package/src/local/local-app-dev-files.ts +186 -0
  102. package/src/local/local-app-dev-session.ts +88 -0
  103. package/src/local/local-app-lock-install.ts +114 -0
  104. package/src/local/local-app-pulled.ts +167 -0
  105. package/src/local/local-app-restore.ts +111 -0
  106. package/src/local/local-app-runtime.ts +68 -0
  107. package/src/local/local-app-sdk-paths.ts +149 -0
  108. package/src/local/local-app-sdk.ts +509 -0
  109. package/src/local/local-applied.ts +211 -21
  110. package/src/local/local-apply-history.ts +84 -0
  111. package/src/local/local-apply-summary.ts +86 -0
  112. package/src/local/local-apply-watchdog.ts +95 -0
  113. package/src/local/local-apps.ts +180 -7
  114. package/src/local/local-blueprint-errors.ts +198 -0
  115. package/src/local/local-blueprint-outline.ts +31 -12
  116. package/src/local/local-blueprint-schemas.ts +582 -0
  117. package/src/local/local-build-inputs.ts +48 -0
  118. package/src/local/local-build-summary.ts +495 -28
  119. package/src/local/local-case-evidence.ts +120 -0
  120. package/src/local/local-case-not-run.ts +153 -0
  121. package/src/local/local-chat-apps.ts +93 -16
  122. package/src/local/local-check-inherited.ts +83 -0
  123. package/src/local/local-cities.ts +89 -0
  124. package/src/local/local-component-health.ts +119 -0
  125. package/src/local/local-control.ts +34 -7
  126. package/src/local/local-db-snapshot.ts +348 -0
  127. package/src/local/local-dev-references.ts +346 -35
  128. package/src/local/local-dev-sync.ts +136 -7
  129. package/src/local/local-docker.ts +35 -1
  130. package/src/local/local-docs-search.ts +1 -1
  131. package/src/local/local-docs.ts +281 -2
  132. package/src/local/local-empty-workflows.ts +92 -0
  133. package/src/local/local-env.ts +16 -1
  134. package/src/local/local-file-checks.ts +104 -0
  135. package/src/local/local-function-ids.ts +141 -0
  136. package/src/local/local-function-recovery.ts +141 -0
  137. package/src/local/local-function-state.ts +87 -0
  138. package/src/local/local-keys.ts +7 -5
  139. package/src/local/local-knowledge-ingestion.ts +225 -0
  140. package/src/local/local-knowledge-uploads.ts +217 -0
  141. package/src/local/local-media-placeholders.ts +182 -0
  142. package/src/local/local-media-sets.ts +90 -10
  143. package/src/local/local-model-parameters.ts +89 -0
  144. package/src/local/local-object-sets.ts +18 -6
  145. package/src/local/local-operating.ts +35 -1
  146. package/src/local/local-org-settings.ts +205 -7
  147. package/src/local/local-orphans.ts +321 -0
  148. package/src/local/local-personas.ts +296 -0
  149. package/src/local/local-portability.ts +141 -16
  150. package/src/local/local-ports.ts +99 -7
  151. package/src/local/local-project-notices.ts +234 -0
  152. package/src/local/local-project-secrets.ts +133 -0
  153. package/src/local/local-project.ts +12 -0
  154. package/src/local/local-provider-refusal.ts +66 -0
  155. package/src/local/local-request-evidence.ts +112 -0
  156. package/src/local/local-runtime-health.ts +123 -0
  157. package/src/local/local-runtime-history.ts +50 -0
  158. package/src/local/local-runtime-target.ts +42 -0
  159. package/src/local/local-sample-data.ts +141 -0
  160. package/src/local/local-sample-remake.ts +138 -0
  161. package/src/local/local-scaffold.ts +336 -53
  162. package/src/local/local-schema-check.ts +148 -0
  163. package/src/local/local-schemas.ts +742 -25
  164. package/src/local/local-secret-carry.ts +107 -0
  165. package/src/local/local-secrets.ts +38 -8
  166. package/src/local/local-seed.ts +102 -26
  167. package/src/local/local-stand-ins.ts +1 -1
  168. package/src/local/local-state.ts +43 -2
  169. package/src/local/local-studio-server.ts +170 -7
  170. package/src/local/local-supervisor.ts +44 -6
  171. package/src/local/local-sync-names.ts +2 -0
  172. package/src/local/local-sync-plan.ts +11 -0
  173. package/src/local/local-sync-push.ts +10 -7
  174. package/src/local/local-sync-refs.ts +49 -3
  175. package/src/local/local-sync-remote.ts +343 -32
  176. package/src/local/local-sync-snapshot.ts +15 -10
  177. package/src/local/local-synthetic-data.ts +2116 -99
  178. package/src/local/local-test-baseline.ts +113 -0
  179. package/src/local/local-test-cases.ts +76 -1
  180. package/src/local/local-test-runner.ts +582 -91
  181. package/src/local/local-test-runs.ts +13 -5
  182. package/src/local/local-vendored-sdk.ts +183 -0
  183. package/src/local/local-workflow-state.ts +16 -3
  184. package/src/local/local-workflow-tests.ts +482 -0
  185. package/src/local/local-workspace-grants.ts +81 -0
  186. package/src/main.ts +50 -9
  187. package/src/mcp/a11y-scan.ts +55 -0
  188. package/src/mcp/browser/ax-snapshot.ts +181 -0
  189. package/src/mcp/browser/credentials.ts +78 -0
  190. package/src/mcp/browser/frames.ts +336 -0
  191. package/src/mcp/browser/headless-session.ts +413 -0
  192. package/src/mcp/browser/headless-tab.ts +575 -0
  193. package/src/mcp/browser/keys.ts +70 -0
  194. package/src/mcp/browser/page-scripts.ts +129 -0
  195. package/src/mcp/browser/wait.ts +73 -0
  196. package/src/mcp/forge-link.ts +126 -0
  197. package/src/mcp/headless.ts +199 -0
  198. package/src/mcp/mcp-apps.ts +435 -0
  199. package/src/mcp/mcp-chat.ts +226 -0
  200. package/src/mcp/mcp-cli.ts +53 -0
  201. package/src/mcp/mcp-protocol.ts +122 -0
  202. package/src/mcp/mcp-runtime.ts +113 -0
  203. package/src/mcp/mcp-server.ts +108 -0
  204. package/src/mcp/mcp-traces.ts +409 -0
  205. package/src/mcp/page-observation.ts +193 -0
  206. package/src/mcp/tools/agent-tools.ts +700 -0
  207. package/src/mcp/tools/app-tools.ts +241 -0
  208. package/src/mcp/tools/browser-tools.ts +310 -0
  209. package/src/mcp/tools/docs-tools.ts +32 -0
  210. package/src/mcp/tools/project-tools.ts +205 -0
  211. package/src/mcp/tools/test-tools.ts +339 -0
  212. package/src/mcp/tools/workflow-tools.ts +611 -0
  213. package/src/output.ts +1 -1
  214. package/src/pack.ts +8 -3
  215. package/src/packaging.ts +1 -1
  216. package/src/project-bootstrap.ts +107 -4
  217. package/src/project-context.ts +18 -1
  218. package/src/project.ts +27 -2
  219. package/src/provenance.ts +25 -0
  220. package/src/render-evidence.ts +65 -7
  221. package/src/template.ts +14 -8
  222. package/src/templates/next-app-files.ts +35 -6
  223. package/src/templates/next-skills.ts +61 -27
  224. package/src/vendor/kit-assets.json +6 -6
  225. package/src/vendor/sdk-sources.json +24 -21
@@ -1,23 +1,51 @@
1
1
  import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs'
2
2
  import { join } from 'node:path'
3
3
 
4
- import { base, en, Faker } from '@faker-js/faker'
4
+ import { base, de, en, en_AU, en_GB, en_IN, es, Faker, fr, id_ID, ja, nl, pt_BR, type LocaleDefinition } from '@faker-js/faker'
5
5
  import { parse as parseYaml, stringify as stringifyYaml } from 'yaml'
6
6
 
7
+ import { cityNear } from './local-cities'
8
+ import { defaultPlaceholderPaths } from './local-media-placeholders'
9
+ import { objectTypeFit } from './local-portability'
10
+ import { isSampleDataset, readCsvRows, sampleStamp, stampedDescription } from './local-sample-data'
11
+ import { readSnapshot } from './local-sync-snapshot'
12
+
7
13
  /**
8
14
  * Sample rows for a project's Blueprint, so a Blueprint pulled from a
9
15
  * workspace (which brings no records) can be explored, queried and used by
10
- * Agents without copying customer data to a laptop.
16
+ * Agents and Apps without copying customer data to a laptop.
11
17
  *
12
18
  * Each object type gets a CSV under `datasets/` with the definition beside
13
- * it. Values fit each property: its type, its name (an `email` gets an email,
14
- * an `amount` money) and the values its description lists ("open, pending or
15
- * closed"). Links stay consistent: a property another type's records are
16
- * linked by gets a value from those records, so every relation resolves.
17
- * The same seed gives the same rows.
19
+ * it. A value fits its property, most specific first:
20
+ *
21
+ * 1. the values the Blueprint allows: the property's semantic type (its own,
22
+ * or its shared field's) — an enum, a number's bounds, a text's length —
23
+ * or values listed on the property itself;
24
+ * 2. the values its description lists ("open, pending or closed");
25
+ * 3. for a property a governed Action writes from an input with an enum
26
+ * (`set status ← input status` with `enum: [open, escalated]`), those values,
27
+ * so every record is one the Action can act on;
28
+ * 4. its type and its name (an `email` gets an email, an `amount` money).
29
+ *
30
+ * Links stay consistent: a property that holds another type's key gets a
31
+ * value from those records (always, when the property is required), and the
32
+ * side holding the key is the one that is not its type's primary key.
33
+ * Timestamps in a row follow the lifecycle their names say (created, then
34
+ * assigned, then resolved; updated last), a record still open has no
35
+ * optional resolution time, and an open record's due time falls around
36
+ * today (some overdue). Text reads like the record, from the row's other
37
+ * values: a title names its category and the place it links to, a
38
+ * resolution what was done. A unit follows the row's reading type, and its
39
+ * value that kind's range. Geopoints, coordinates and phone numbers fall
40
+ * within a region: `--region`, else the project's `locale` (frontera.yaml),
41
+ * else the workspace's timezone, else the United States. The same seed gives
42
+ * the same rows on the same day.
18
43
  *
19
44
  * `data/generate.ts` in the project, when present, adjusts it: rows per type
20
45
  * and a function per property, for scenarios ("five tickets breaching SLA").
46
+ * A field function sees the whole generated row, and the rows already
47
+ * generated for other types (`ScenarioContext`). `SCENARIO_CONTRACT` says
48
+ * all of it in words, for `local data --help`.
21
49
  */
22
50
 
23
51
  interface Property {
@@ -27,15 +55,31 @@ interface Property {
27
55
  valueType?: string
28
56
  required?: boolean
29
57
  propertyType?: string
58
+ semanticType?: string | null
59
+ sharedField?: string | null
60
+ /** Values a property may list itself. */
61
+ allowedValues?: unknown[]
62
+ enum?: unknown[]
63
+ /** A calculated field (a location from latitude and longitude): computed by the Blueprint, never a column. */
64
+ calculation?: unknown
30
65
  }
31
66
 
67
+ /**
68
+ * Whether a record may leave a property empty: unless it says `required:
69
+ * true`, as the Blueprint reads it (a property without `required` is
70
+ * optional), so the state rules apply to it too.
71
+ */
72
+ const optional = (p: Pick<Property, 'required'> | undefined) => !!p && p.required !== true
73
+
32
74
  interface ObjectType {
33
75
  apiName: string
34
76
  displayName?: string
35
77
  pluralDisplayName?: string
36
78
  primaryKey?: string
79
+ /** The property a record is called by. */
80
+ title?: string
37
81
  properties?: Property[]
38
- backing?: { dataset?: string; mapping?: Record<string, string> }
82
+ backing?: { dataset?: string; mapping?: Record<string, string | { column: string }> }
39
83
  }
40
84
 
41
85
  interface LinkType {
@@ -45,10 +89,52 @@ interface LinkType {
45
89
  to?: { objectType?: string; property?: string }
46
90
  }
47
91
 
92
+ interface SemanticType {
93
+ apiName: string
94
+ dataType?: string
95
+ rule?: { enum?: unknown[]; minimum?: string | number; maximum?: string | number; minLength?: number; maxLength?: number }
96
+ }
97
+
98
+ interface SharedField {
99
+ apiName: string
100
+ description?: string
101
+ semanticType?: string | null
102
+ }
103
+
104
+ interface MetricFile {
105
+ apiName: string
106
+ displayName?: string
107
+ objectType?: string
108
+ definition?: { measures?: Array<{ property?: string; agg?: string; label?: string; apiName?: string; filters?: unknown }> }
109
+ }
110
+
111
+ interface ActionFile {
112
+ apiName: string
113
+ subject?: { objectType?: string }
114
+ inputs?: Array<{ apiName?: string; valueType?: { kind?: string; enum?: unknown[] } }>
115
+ businessOutcomes?: Array<{ expectedChanges?: Array<{ kind?: string; property?: string; value?: { kind?: string; field?: string; value?: unknown } }> }>
116
+ }
117
+
118
+ /**
119
+ * A field function's third argument: the rows already generated for other
120
+ * types. Types are generated in link order (a type before the types that
121
+ * point at it), so the types a row links to are always there; its own type
122
+ * and the types generated after it give none.
123
+ */
124
+ export interface ScenarioContext {
125
+ /** The generated rows of a type, read-only; [] for its own type or one not generated yet. */
126
+ rowsOf: (objectType: string) => ReadonlyArray<Readonly<Record<string, unknown>>>
127
+ /** The generated row of a type whose primary key (or `property`) is `value`; undefined when none is. */
128
+ lookup: (objectType: string, value: unknown, property?: string) => Readonly<Record<string, unknown>> | undefined
129
+ }
130
+
131
+ /** A field function: the row so far, its position, and the rows of other types. */
132
+ export type FieldFunction = (row: Record<string, unknown>, index: number, ctx: ScenarioContext) => unknown
133
+
48
134
  /** What `data/generate.ts` may return. */
49
135
  export interface Scenario {
50
136
  rows?: number
51
- types?: Record<string, { rows?: number; fields?: Record<string, (row: Record<string, unknown>, index: number) => unknown> }>
137
+ types?: Record<string, { rows?: number; fields?: Record<string, FieldFunction> }>
52
138
  }
53
139
 
54
140
  export interface GeneratedDataset {
@@ -57,14 +143,33 @@ export interface GeneratedDataset {
57
143
  rows: number
58
144
  csvPath: string
59
145
  definitionPath: string
146
+ /**
147
+ * The dataset is not sample data (someone wrote or edited it, a Source
148
+ * loads it): its files were left as they are, and the sample rows of other
149
+ * types link to its records.
150
+ */
151
+ kept?: true
152
+ /**
153
+ * Fields whose description makes them conditional on something the record
154
+ * has nothing for ("empty unless escalated" with no `escalated` property
155
+ * and no value "escalated"): the condition never holds, so an "empty
156
+ * unless" field stays empty. Said, so the description can name it.
157
+ */
158
+ unresolved?: Array<{ property: string; condition: string }>
60
159
  }
61
160
 
62
161
  export interface SyntheticOptions {
63
162
  rows?: number
64
163
  seed?: number
65
- /** Dates are spread over the months before this day; fixed by default so a seed gives the same rows. */
164
+ /**
165
+ * Dates are spread over the months before this day, and open records fall
166
+ * due around it. By default the day it runs (midnight UTC), so a seed gives
167
+ * the same rows all day and an overdue record is overdue now.
168
+ */
66
169
  today?: Date
67
170
  scenario?: Scenario
171
+ /** Where geopoints fall: a region (`resolveRegion`). Defaults to the project's. */
172
+ region?: Region
68
173
  }
69
174
 
70
175
  function readTree<T>(dir: string): Array<{ file: string; doc: T }> {
@@ -92,16 +197,44 @@ function plural(name: string): string {
92
197
  export function backingOf(type: ObjectType): { dataset: string; column: (property: string) => string } {
93
198
  const dataset = type.backing?.dataset || plural(snake(type.apiName))
94
199
  const mapping = type.backing?.mapping ?? {}
95
- return { dataset, column: (p) => mapping[p] ?? snake(p) }
200
+ return {
201
+ dataset,
202
+ column: (p) => {
203
+ const m = mapping[p]
204
+ return typeof m === 'string' ? m : m && typeof m === 'object' && typeof m.column === 'string' ? m.column : snake(p)
205
+ },
206
+ }
96
207
  }
97
208
 
98
- /** "open, pending, resolved or closed" → the four values, when a description lists them. */
209
+ /**
210
+ * "open, pending, resolved or closed" → the four values, when a description
211
+ * lists them: the whole text, what follows its last colon or dash ("The
212
+ * dispatch region — north, central or south."), or its last sentence ("The
213
+ * operating district a record belongs to. North, south, east, west or
214
+ * central."), with a leading "One of" or "Either" dropped.
215
+ */
99
216
  export function listedValues(description: string | undefined): string[] | null {
100
217
  if (!description) return null
101
- const text = description.replace(/^[^:]*:\s*/, '').trim()
218
+ // After the last colon or dash: a spaced hyphen counts as one ("belongs to - north, south or east").
219
+ const separators = [...description.matchAll(/:|—|–|\s-\s/g)]
220
+ const lastSeparator = separators[separators.length - 1]
221
+ const tail = lastSeparator ? description.slice(lastSeparator.index! + lastSeparator[0].length).trim() || undefined : undefined
222
+ const sentences = description.trim().split(/(?<=[.!?;])\s+/).filter(Boolean)
223
+ const last = sentences.length > 1 ? sentences[sentences.length - 1] : undefined
224
+ for (const candidate of [tail, last, description.replace(/^[^:]*:\s*/, '')]) {
225
+ if (!candidate) continue
226
+ const found = listedIn(candidate.replace(/^\s*(one of|either|any of)\s+/i, ''))
227
+ if (found) return found
228
+ }
229
+ return null
230
+ }
231
+
232
+ function listedIn(raw: string): string[] | null {
233
+ const text = raw.trim()
102
234
  if (!/,|\bor\b/.test(text) || text.length > 160) return null
103
235
  const parts = text.split(/\s*,\s*|\s+or\s+|\s+and\s+/).map((p) => p.trim().replace(/^["'`]|["'`.]$/g, ''))
104
- if (parts.length < 2 || parts.some((p) => !/^[A-Za-z0-9_-]{1,32}$/.test(p))) return null
236
+ // A word, a code or a unit symbol (`%`, `°C`, `m3/h`): never a phrase.
237
+ if (parts.length < 2 || parts.some((p) => !/^[\p{L}\p{N}_%°/³²µ$€£¥-]{1,32}$/u.test(p))) return null
105
238
  return [...new Set(parts)]
106
239
  }
107
240
 
@@ -116,10 +249,29 @@ interface Reference {
116
249
  /** The type whose records this property points at. */
117
250
  objectType: string
118
251
  property: string
252
+ /**
253
+ * Said by the property's name, not by a link type (`inferReferences`): the
254
+ * property takes that type's keys, but it is only a guess at a link, so it
255
+ * never makes another link of the row agree with it.
256
+ */
257
+ inferred?: true
258
+ /**
259
+ * An inferred link back along a declared one (a service request's
260
+ * `dispatchDecisionId`, when each dispatch decision links to its request):
261
+ * filled once the other type has rows, with the record that links back.
262
+ */
263
+ after?: true
119
264
  }
120
265
 
121
- /** Which property of which type points at which other type's key, from the link types. */
122
- export function referencesFrom(links: LinkType[]): Map<string, Map<string, Reference>> {
266
+ /**
267
+ * Which property of which type points at which other type's key, from the
268
+ * link types. The side that holds the key is the one whose property is not
269
+ * its type's primary key; when that cannot tell (neither or both are), the
270
+ * cardinality says it: for one_to_many and one_to_one the `to` side holds
271
+ * the `from` side's key, for many_to_one the other way round. many_to_many
272
+ * links hold no key on either side.
273
+ */
274
+ export function referencesFrom(links: LinkType[], primaryKeys: ReadonlyMap<string, string> = new Map()): Map<string, Map<string, Reference>> {
123
275
  const out = new Map<string, Map<string, Reference>>()
124
276
  const add = (type: string, property: string, ref: Reference) => {
125
277
  if (!out.has(type)) out.set(type, new Map())
@@ -129,13 +281,73 @@ export function referencesFrom(links: LinkType[]): Map<string, Map<string, Refer
129
281
  const from = link.from
130
282
  const to = link.to
131
283
  if (!from?.objectType || !from.property || !to?.objectType || !to.property) continue
132
- if (link.cardinality === 'many_to_one') add(from.objectType, from.property, { objectType: to.objectType, property: to.property })
133
- else if (link.cardinality === 'many_to_many') continue
134
- else add(to.objectType, to.property, { objectType: from.objectType, property: from.property })
284
+ if (link.cardinality === 'many_to_many') continue
285
+ const fromIsKey = primaryKeys.get(from.objectType) === from.property
286
+ const toIsKey = primaryKeys.get(to.objectType) === to.property
287
+ const toHolds = fromIsKey !== toIsKey ? fromIsKey : link.cardinality !== 'many_to_one'
288
+ if (toHolds) add(to.objectType, to.property, { objectType: from.objectType, property: from.property })
289
+ else add(from.objectType, from.property, { objectType: to.objectType, property: to.property })
135
290
  }
136
291
  return out
137
292
  }
138
293
 
294
+ const squash = (text: string) => text.toLowerCase().replace(/[^a-z0-9]/g, '')
295
+
296
+ /** A type whose records do the work on other records: a staff member, a technician, an agent. */
297
+ const WORKER = /\b(staff|technicians?|engineers?|agents?|employees?|workers?|operators?|dispatchers?|officers?|inspectors?|crew|team members?|users?|members?)\b/
298
+
299
+ /**
300
+ * Links the Blueprint doesn't declare but a property's name says, added to
301
+ * `refs`: `propertyId` holds a Property's key, `dispatchDecisionId` a
302
+ * DispatchDecision's (its key `decisionId`), `homePropertyId` and
303
+ * `chosenStaffId` the type their name ends with, `staffId` the type whose
304
+ * key it is, and `assignedTo` (an assignee, an owner) the type of the people
305
+ * who do the work. Only a property no declared link holds, that lists no
306
+ * values, of the same value type as the key, and never its own type's key or
307
+ * a link to its own type. A name two types could answer is left alone.
308
+ */
309
+ export function inferReferences(types: ObjectType[], refs: Map<string, Map<string, Reference>>, primaryKeys: ReadonlyMap<string, string>, hasValues: (type: ObjectType, p: Property) => boolean): void {
310
+ const keyOf = (t: ObjectType) => (t.properties ?? []).find((x) => x.apiName === primaryKeys.get(t.apiName))
311
+ const sameKind = (p: Property, t: ObjectType) => ((keyOf(t)?.valueType ?? 'string') === 'number') === ((p.valueType ?? 'string') === 'number')
312
+ const only = (list: ObjectType[]) => (list.length === 1 ? list[0] : undefined)
313
+ const workers = types.filter((t) => entityOf(t) === 'person' && WORKER.test(`${words(t.apiName)} ${(t.displayName ?? '').toLowerCase()}`))
314
+ // Where the declared links lead from each type: an inferred link the other way closes a loop.
315
+ const declared = new Map([...refs].map(([t, m]) => [t, [...m.values()].map((r) => r.objectType)] as const))
316
+ const leadsTo = (from: string, to: string) => {
317
+ const seen = new Set<string>([from])
318
+ const queue = [from]
319
+ while (queue.length) {
320
+ for (const next of declared.get(queue.shift()!) ?? []) {
321
+ if (next === to) return true
322
+ if (!seen.has(next)) (seen.add(next), queue.push(next))
323
+ }
324
+ }
325
+ return false
326
+ }
327
+ for (const type of types) {
328
+ const own = refs.get(type.apiName)
329
+ for (const p of type.properties ?? []) {
330
+ if (p.calculation || p.apiName === primaryKeys.get(type.apiName) || own?.has(p.apiName) || !['string', 'number'].includes(p.valueType ?? 'string') || hasValues(type, p)) continue
331
+ const others = types.filter((t) => t.apiName !== type.apiName && sameKind(p, t))
332
+ const name = squash(p.apiName)
333
+ // Another type's key by name: `staffId`, `decisionId`.
334
+ let target = only(others.filter((t) => squash(primaryKeys.get(t.apiName) ?? '') === name && name !== 'id'))
335
+ const stem = /^(.+?)(?:_?id|_?key|_?ref)$/i.exec(p.apiName)?.[1]
336
+ if (!target && stem) {
337
+ const s = squash(stem)
338
+ // The type it names: `propertyId` → Property; then the type its name ends with: `homePropertyId` → Property.
339
+ target = only(others.filter((t) => squash(t.apiName) === s))
340
+ ?? only(others.filter((t) => squash(t.apiName).length >= 4 && s.endsWith(squash(t.apiName)) && !others.some((u) => u !== t && squash(u.apiName).length > squash(t.apiName).length && s.endsWith(squash(u.apiName)))))
341
+ }
342
+ // Who it is assigned to: one of the people who do the work.
343
+ if (!target && /^(assigned to|assignee|assignee id|assigned (staff|technician|agent|engineer)( id)?|owner|owner id)$/.test(words(p.apiName))) target = workers.find((t) => t.apiName !== type.apiName && sameKind(p, t))
344
+ if (!target) continue
345
+ if (!refs.has(type.apiName)) refs.set(type.apiName, new Map())
346
+ refs.get(type.apiName)!.set(p.apiName, { objectType: target.apiName, property: primaryKeys.get(target.apiName)!, inferred: true, ...(leadsTo(target.apiName, type.apiName) ? { after: true as const } : {}) })
347
+ }
348
+ }
349
+ }
350
+
139
351
  /** Types in an order where every referenced type comes before the types that point at it. */
140
352
  function dependencyOrder(types: ObjectType[], refs: Map<string, Map<string, Reference>>): ObjectType[] {
141
353
  const byName = new Map(types.map((t) => [t.apiName, t]))
@@ -144,7 +356,7 @@ function dependencyOrder(types: ObjectType[], refs: Map<string, Map<string, Refe
144
356
  const visit = (name: string, trail: Set<string>) => {
145
357
  if (done.has(name) || trail.has(name) || !byName.has(name)) return
146
358
  trail.add(name)
147
- for (const ref of refs.get(name)?.values() ?? []) if (ref.objectType !== name) visit(ref.objectType, trail)
359
+ for (const ref of refs.get(name)?.values() ?? []) if (ref.objectType !== name && !ref.after) visit(ref.objectType, trail)
148
360
  trail.delete(name)
149
361
  done.add(name)
150
362
  out.push(byName.get(name)!)
@@ -157,77 +369,1150 @@ function words(name: string): string {
157
369
  return name.replace(/([a-z])([A-Z])/g, '$1 $2').toLowerCase()
158
370
  }
159
371
 
160
- /** A value for one property, from its type, its name and its description. */
161
- function valueFor(faker: Faker, type: ObjectType, p: Property, row: Record<string, unknown>, state: { lastTime: number | null; today: Date }): unknown {
372
+ // ── Regions: where geopoints fall ────────────────────────────────────────────
373
+
374
+ /** A circle on the map, and what a record there reads like. */
375
+ export interface Region {
376
+ /** A country code, or `custom` for a point given by hand. */
377
+ code: string
378
+ lat: number
379
+ lon: number
380
+ radiusKm: number
381
+ currency: string
382
+ locale: LocaleDefinition[]
383
+ /** The country its records are in: its own code, or the known country a custom point lies in. */
384
+ country?: string
385
+ /** The city its points are in: a known area's own, or the one a custom point is near. */
386
+ city?: string
387
+ }
388
+
389
+ /** A metropolitan area per country: records cluster where people and assets are, not across a whole country's ocean. */
390
+ const REGIONS: Record<string, Omit<Region, 'code'>> = {
391
+ US: { lat: 40.73, lon: -73.94, radiusKm: 35, currency: 'USD', city: 'New York', locale: [en, base] },
392
+ ID: { lat: -6.2, lon: 106.82, radiusKm: 30, currency: 'IDR', city: 'Jakarta', locale: [id_ID, en, base] },
393
+ GB: { lat: 51.51, lon: -0.12, radiusKm: 30, currency: 'GBP', city: 'London', locale: [en_GB, en, base] },
394
+ AU: { lat: -33.87, lon: 151.21, radiusKm: 35, currency: 'AUD', city: 'Sydney', locale: [en_AU, en, base] },
395
+ IN: { lat: 19.08, lon: 72.88, radiusKm: 30, currency: 'INR', city: 'Mumbai', locale: [en_IN, en, base] },
396
+ SG: { lat: 1.35, lon: 103.82, radiusKm: 15, currency: 'SGD', city: 'Singapore', locale: [en, base] },
397
+ MY: { lat: 3.14, lon: 101.69, radiusKm: 25, currency: 'MYR', city: 'Kuala Lumpur', locale: [en, base] },
398
+ PH: { lat: 14.6, lon: 120.98, radiusKm: 25, currency: 'PHP', city: 'Manila', locale: [en, base] },
399
+ DE: { lat: 52.52, lon: 13.4, radiusKm: 25, currency: 'EUR', city: 'Berlin', locale: [de, en, base] },
400
+ FR: { lat: 48.86, lon: 2.35, radiusKm: 25, currency: 'EUR', city: 'Paris', locale: [fr, en, base] },
401
+ ES: { lat: 40.42, lon: -3.7, radiusKm: 25, currency: 'EUR', city: 'Madrid', locale: [es, en, base] },
402
+ NL: { lat: 52.37, lon: 4.9, radiusKm: 20, currency: 'EUR', city: 'Amsterdam', locale: [nl, en, base] },
403
+ JP: { lat: 35.68, lon: 139.69, radiusKm: 30, currency: 'JPY', city: 'Tokyo', locale: [ja, en, base] },
404
+ BR: { lat: -23.55, lon: -46.63, radiusKm: 35, currency: 'BRL', city: 'São Paulo', locale: [pt_BR, en, base] },
405
+ AE: { lat: 25.2, lon: 55.27, radiusKm: 30, currency: 'AED', city: 'Dubai', locale: [en, base] },
406
+ }
407
+
408
+ /** A timezone's country, for the ones a workspace commonly runs in. */
409
+ const TIMEZONE_COUNTRY: Record<string, string> = {
410
+ 'Asia/Jakarta': 'ID', 'Asia/Makassar': 'ID', 'Asia/Jayapura': 'ID', 'Asia/Pontianak': 'ID',
411
+ 'Asia/Singapore': 'SG', 'Asia/Kuala_Lumpur': 'MY', 'Asia/Manila': 'PH', 'Asia/Tokyo': 'JP',
412
+ 'Asia/Kolkata': 'IN', 'Asia/Calcutta': 'IN', 'Asia/Dubai': 'AE',
413
+ 'Europe/London': 'GB', 'Europe/Berlin': 'DE', 'Europe/Paris': 'FR', 'Europe/Madrid': 'ES', 'Europe/Amsterdam': 'NL',
414
+ 'Australia/Sydney': 'AU', 'Australia/Melbourne': 'AU', 'America/Sao_Paulo': 'BR',
415
+ }
416
+
417
+ export const REGION_CODES = Object.keys(REGIONS)
418
+
419
+ /**
420
+ * Per known country: how its mobile numbers are written (`#` a digit, `N` a
421
+ * digit from 2 to 9) and a box around it (south, north, west, east), to tell
422
+ * which country a custom point is in.
423
+ */
424
+ const COUNTRIES: Record<string, { phone: string[]; box: [number, number, number, number] }> = {
425
+ US: { phone: ['+1 N## N## ####'], box: [24, 50, -125, -66] },
426
+ ID: { phone: ['+62 81# #### ####', '+62 82# #### ####', '+62 85# #### ####', '+62 87# #### ####'], box: [-11, 6, 95, 141.5] },
427
+ GB: { phone: ['+44 7### ######'], box: [49.8, 61, -8.7, 1.8] },
428
+ AU: { phone: ['+61 4## ### ###'], box: [-44, -10, 112.5, 154] },
429
+ IN: { phone: ['+91 9#### #####', '+91 8#### #####', '+91 7#### #####'], box: [6, 36, 68, 97.5] },
430
+ SG: { phone: ['+65 8### ####', '+65 9### ####'], box: [1.15, 1.48, 103.6, 104.1] },
431
+ MY: { phone: ['+60 1N-### ####'], box: [0.85, 7.4, 99.6, 119.3] },
432
+ PH: { phone: ['+63 9## ### ####'], box: [4.5, 21.2, 116.9, 126.7] },
433
+ DE: { phone: ['+49 15N #######', '+49 17N #######'], box: [47.2, 55.1, 5.8, 15.1] },
434
+ FR: { phone: ['+33 6 ## ## ## ##', '+33 7 ## ## ## ##'], box: [41.3, 51.1, -5.2, 9.6] },
435
+ ES: { phone: ['+34 6## ### ###'], box: [36, 43.8, -9.4, 3.4] },
436
+ NL: { phone: ['+31 6 ########'], box: [50.7, 53.6, 3.3, 7.3] },
437
+ JP: { phone: ['+81 90 #### ####', '+81 80 #### ####'], box: [24, 45.6, 122.9, 146] },
438
+ BR: { phone: ['+55 11 9#### ####'], box: [-33.8, 5.3, -74, -34.8] },
439
+ AE: { phone: ['+971 5# ### ####'], box: [22.6, 26.1, 51.5, 56.4] },
440
+ }
441
+
442
+ /** The known country a point lies in: the smallest box that holds it, the nearest centre on a tie; undefined when none does. */
443
+ export function countryAt(lat: number, lon: number): string | undefined {
444
+ const holding = Object.entries(COUNTRIES)
445
+ .filter(([, { box: [s, n, w, e] }]) => lat >= s && lat <= n && lon >= w && lon <= e)
446
+ .map(([code, { box: [s, n, w, e] }]) => ({ code, area: (n - s) * (e - w), distance: Math.hypot(lat - REGIONS[code]!.lat, lon - REGIONS[code]!.lon) }))
447
+ .sort((a, b) => a.area - b.area || a.distance - b.distance)
448
+ return holding[0]?.code
449
+ }
450
+
451
+ /** A compass word's bearing in degrees, or `centre` for the middle: English and Indonesian. */
452
+ const COMPASS: Record<string, number | 'centre'> = {
453
+ north: 0, northeast: 45, east: 90, southeast: 135, south: 180, southwest: 225, west: 270, northwest: 315,
454
+ central: 'centre', centre: 'centre', center: 'centre', middle: 'centre',
455
+ utara: 0, timurlaut: 45, timur: 90, tenggara: 135, selatan: 180, baratdaya: 225, barat: 270, baratlaut: 315, pusat: 'centre', tengah: 'centre',
456
+ }
457
+ const compassKey = (value: string) => value.toLowerCase().replace(/[\s_-]+/g, '')
458
+
459
+ /** Values that are all compass regions (`north, south, east, west or central`), at least two directions among them. */
460
+ export function isCompassList(values: readonly string[]): boolean {
461
+ return values.length >= 2 && values.every((v) => compassKey(v) in COMPASS) && values.filter((v) => COMPASS[compassKey(v)] !== 'centre').length >= 2
462
+ }
463
+
464
+ /**
465
+ * The compass region a point is in, of those listed, seen from the region's
466
+ * centre: the middle quarter of the radius is the centre (when one is
467
+ * listed), else the listed direction nearest the point's bearing.
468
+ */
469
+ export function compassOf(point: { lat: number; lon: number }, region: Pick<Region, 'lat' | 'lon' | 'radiusKm'>, values: readonly string[]): string {
470
+ const centre = values.find((v) => COMPASS[compassKey(v)] === 'centre')
471
+ if (centre && distanceKm(region, point) <= region.radiusKm / 4) return centre
472
+ const φ1 = rad(region.lat)
473
+ const φ2 = rad(point.lat)
474
+ const Δλ = rad(point.lon - region.lon)
475
+ const bearing = (deg(Math.atan2(Math.sin(Δλ) * Math.cos(φ2), Math.cos(φ1) * Math.sin(φ2) - Math.sin(φ1) * Math.cos(φ2) * Math.cos(Δλ))) + 360) % 360
476
+ const directions = values.filter((v) => typeof COMPASS[compassKey(v)] === 'number')
477
+ const apart = (v: string) => {
478
+ const d = Math.abs((COMPASS[compassKey(v)] as number) - bearing) % 360
479
+ return Math.min(d, 360 - d)
480
+ }
481
+ return directions.reduce((best, v) => (apart(v) < apart(best) ? v : best), directions[0]!)
482
+ }
483
+
484
+ /** One city per region an area has no city for: a circle of tens of kilometres is one place. */
485
+ const drawnCities = new WeakMap<Region, string>()
486
+
487
+ /** The city a region's records are in: its own, else one drawn once (a wide circle may span several). */
488
+ function cityOf(faker: Faker, region: Region): string {
489
+ if (region.city) return region.city
490
+ if (region.radiusKm > 80) return faker.location.city()
491
+ let city = drawnCities.get(region)
492
+ if (!city) drawnCities.set(region, (city = faker.location.city()))
493
+ return city
494
+ }
495
+
496
+ /** A region's country: the one it names or a custom point lies in. */
497
+ const countryOf = (region: Region): string | undefined => region.country ?? (REGIONS[region.code] ? region.code : undefined)
498
+
499
+ /** A mobile number written as the region's country writes it; Faker's own when the country is unknown. */
500
+ function phoneFor(faker: Faker, region: Region): string {
501
+ const formats = COUNTRIES[countryOf(region) ?? '']?.phone
502
+ if (!formats) return faker.phone.number({ style: 'international' })
503
+ return faker.helpers.arrayElement(formats).replace(/[#N]/g, (c) => String(faker.number.int({ min: c === 'N' ? 2 : 0, max: 9 })))
504
+ }
505
+
506
+ /**
507
+ * `ID`, `id-ID`, `en_GB`, or `<lat>,<lon>[,<radius km>]` → a region; null
508
+ * when it is none of those. A point in a known country reads like it: its
509
+ * names, currency and phone numbers.
510
+ */
511
+ export function parseRegion(raw: string): Region | null {
512
+ const text = raw.trim()
513
+ const point = /^(-?\d+(?:\.\d+)?)\s*,\s*(-?\d+(?:\.\d+)?)(?:\s*,\s*(\d+(?:\.\d+)?))?$/.exec(text)
514
+ if (point) {
515
+ const lat = Number(point[1])
516
+ const lon = Number(point[2])
517
+ const radiusKm = point[3] ? Number(point[3]) : 25
518
+ if (Math.abs(lat) > 90 || Math.abs(lon) > 180 || radiusKm <= 0 || radiusKm > 2000) return null
519
+ const country = countryAt(lat, lon)
520
+ const known = country ? REGIONS[country] : undefined
521
+ // A point near a known area is in its city: within that area's own reach and the circle's.
522
+ // Else the large city it is near (Denver, Surabaya, Lyon), so a record's city agrees with its point.
523
+ const near = Object.values(REGIONS).find((r) => distanceKm(r, { lat, lon }) <= r.radiusKm + Math.min(radiusKm, 60))?.city ?? cityNear({ lat, lon }, radiusKm, distanceKm)?.name
524
+ return { code: 'custom', lat, lon, radiusKm, currency: known?.currency ?? 'USD', locale: known?.locale ?? [en, base], ...(country ? { country } : {}), ...(near ? { city: near } : {}) }
525
+ }
526
+ // A country code, or the country of a locale tag (`id-ID`, `en_GB`).
527
+ const code = (/^[a-z]{2,3}[-_]([a-z]{2})$/i.exec(text)?.[1] ?? text).toUpperCase()
528
+ const known = REGIONS[code]
529
+ return known ? { code, ...known, country: code } : null
530
+ }
531
+
532
+ /** The project's region: frontera.yaml's `locale` (or `region`), else the workspace's timezone, else the United States. */
533
+ export function projectRegion(root: string): Region {
534
+ for (const file of ['frontera.yaml', 'frontera.solution.yaml']) {
535
+ try {
536
+ const doc = parseYaml(readFileSync(join(root, file), 'utf8')) as { locale?: unknown; region?: unknown } | null
537
+ for (const v of [doc?.region, doc?.locale]) {
538
+ const region = typeof v === 'string' ? parseRegion(v) : null
539
+ if (region) return region
540
+ }
541
+ } catch {
542
+ // No project file, or not YAML: the next source says.
543
+ }
544
+ }
545
+ const timezone = readSnapshot(root)?.settings?.timezone
546
+ const country = timezone ? TIMEZONE_COUNTRY[timezone] : undefined
547
+ if (country) return { code: country, ...REGIONS[country]!, country }
548
+ return { code: 'US', ...REGIONS.US!, country: 'US' }
549
+ }
550
+
551
+ const EARTH_RADIUS_KM = 6371.0088
552
+ const rad = (deg: number) => (deg * Math.PI) / 180
553
+ const deg = (r: number) => (r * 180) / Math.PI
554
+
555
+ /** The great-circle distance between two points, in kilometres. */
556
+ export function distanceKm(a: { lat: number; lon: number }, b: { lat: number; lon: number }): number {
557
+ const dLat = rad(b.lat - a.lat)
558
+ const dLon = rad(b.lon - a.lon)
559
+ const h = Math.sin(dLat / 2) ** 2 + Math.cos(rad(a.lat)) * Math.cos(rad(b.lat)) * Math.sin(dLon / 2) ** 2
560
+ return 2 * EARTH_RADIUS_KM * Math.asin(Math.min(1, Math.sqrt(h)))
561
+ }
562
+
563
+ /**
564
+ * A point within the region's radius, spread evenly over its circle: a
565
+ * distance (the square root of a uniform draw, so the area is even, not the
566
+ * centre crowded) along a bearing, on the sphere. One point per row: its
567
+ * latitude and longitude come from the same draw, never one each.
568
+ */
569
+ export function pointIn(faker: Faker, region: Pick<Region, 'lat' | 'lon' | 'radiusKm'>): { lat: number; lon: number } {
570
+ // A hair inside the radius, so rounding to six places never carries a point past it.
571
+ const reach = Math.max(0, region.radiusKm - 0.001)
572
+ const angular = (reach * Math.sqrt(faker.number.float({ min: 0, max: 1 }))) / EARTH_RADIUS_KM
573
+ const bearing = faker.number.float({ min: 0, max: 2 * Math.PI })
574
+ const φ1 = rad(region.lat)
575
+ const λ1 = rad(region.lon)
576
+ const φ2 = Math.asin(Math.sin(φ1) * Math.cos(angular) + Math.cos(φ1) * Math.sin(angular) * Math.cos(bearing))
577
+ const λ2 = λ1 + Math.atan2(Math.sin(bearing) * Math.sin(angular) * Math.cos(φ1), Math.cos(angular) - Math.sin(φ1) * Math.sin(φ2))
578
+ const lon = ((deg(λ2) + 540) % 360) - 180
579
+ return { lat: Number(deg(φ2).toFixed(6)), lon: Number(lon.toFixed(6)) }
580
+ }
581
+
582
+ // ── What a property allows ───────────────────────────────────────────────────
583
+
584
+ /** What the Blueprint says a property's values are: allowed values, bounds, a length. */
585
+ interface Constraint {
586
+ values?: string[]
587
+ min?: number
588
+ max?: number
589
+ minLength?: number
590
+ maxLength?: number
591
+ /** Where the values came from, for the reader of the code: semantic type, description, an Action's input. */
592
+ from?: 'property' | 'semantic-type' | 'description' | 'action' | 'metric'
593
+ }
594
+
595
+ const asStrings = (v: unknown): string[] | null => (Array.isArray(v) && v.length && v.every((x) => typeof x === 'string' || typeof x === 'number') ? [...new Set(v.map(String))] : null)
596
+
597
+ const asNumber = (v: unknown): number | undefined => {
598
+ const n = typeof v === 'number' ? v : typeof v === 'string' && v.trim() ? Number(v) : NaN
599
+ return Number.isFinite(n) ? n : undefined
600
+ }
601
+
602
+ /**
603
+ * The values an Action allows into a property, per type: `property_set
604
+ * <property> ← input <field>` where the input's string value type has an enum.
605
+ */
606
+ export function actionEnums(actions: ActionFile[]): Map<string, string[]> {
607
+ const out = new Map<string, string[]>()
608
+ for (const a of actions) {
609
+ const type = a.subject?.objectType
610
+ if (!type) continue
611
+ const inputs = new Map((a.inputs ?? []).flatMap((i) => (i.apiName ? [[i.apiName, i] as const] : [])))
612
+ for (const outcome of a.businessOutcomes ?? []) {
613
+ for (const change of outcome.expectedChanges ?? []) {
614
+ if (change.kind !== 'property_set' || !change.property) continue
615
+ const key = `${type}.${change.property}`
616
+ const values = change.value?.kind === 'input' && change.value.field ? asStrings(inputs.get(change.value.field)?.valueType?.enum) : change.value?.kind === 'literal' && change.value.value !== undefined ? [String(change.value.value)] : null
617
+ if (values) out.set(key, [...new Set([...(out.get(key) ?? []), ...values])])
618
+ }
619
+ }
620
+ }
621
+ return out
622
+ }
623
+
624
+ /** What the metrics say of a type's properties: the values their filters compare with, and the range a share is in. */
625
+ export interface MetricHints {
626
+ /** `Type.property` → the values a metric filter keeps (`metricKey eq occupancy_pct`). */
627
+ values: Map<string, string[]>
628
+ /** `Type.property` → the range a metric reads it in: a percent averaged is between 0 and 100. */
629
+ ranges: Map<string, { min: number; max: number }>
630
+ }
631
+
632
+ const SHARE_WORDS = /\b(pct|percent|percentage|rate|ratio|share|occupancy|utili[sz]ation|conversion|churn|uptime)\b/
633
+
634
+ /**
635
+ * The metrics' hints for sample data. A filter `{ property, op: eq, value }`
636
+ * names a value the property takes (else a metric over it counts nothing),
637
+ * and an average, minimum or maximum of a property over every row, in a
638
+ * metric that reads as a share (`occupancyRate`, "Occupancy percent"), keeps
639
+ * that property between 0 and 100. A measure with filters hints nothing
640
+ * about the range: only the rows it keeps are shares (a unit property and
641
+ * the kind it follows say those; `unitPlanOf`).
642
+ */
643
+ export function metricHints(metrics: MetricFile[]): MetricHints {
644
+ const values = new Map<string, string[]>()
645
+ const ranges = new Map<string, { min: number; max: number }>()
646
+ const literals = (type: string, node: unknown) => {
647
+ if (Array.isArray(node)) return node.forEach((n) => literals(type, n))
648
+ if (!node || typeof node !== 'object') return
649
+ const o = node as Record<string, unknown>
650
+ if (typeof o.property === 'string' && (o.op === 'eq' || o.op === 'in')) {
651
+ const said = (Array.isArray(o.value) ? o.value : [o.value]).filter((v): v is string => typeof v === 'string' && v.length > 0 && v.length <= 64)
652
+ if (said.length) values.set(`${type}.${o.property}`, [...new Set([...(values.get(`${type}.${o.property}`) ?? []), ...said])])
653
+ }
654
+ for (const key of ['and', 'or', 'not']) if (key in o) literals(type, o[key])
655
+ }
656
+ for (const m of metrics) {
657
+ const type = m.objectType
658
+ if (!type) continue
659
+ for (const measure of m.definition?.measures ?? []) {
660
+ if (measure.filters !== undefined) literals(type, measure.filters)
661
+ if (!measure.property || measure.filters !== undefined || !/^(avg|average|mean|min|max|median)$/i.test(measure.agg ?? '')) continue
662
+ const said = `${words(m.apiName)} ${(m.displayName ?? '').toLowerCase()} ${(measure.label ?? '').toLowerCase()} ${words(measure.property)}`
663
+ if (SHARE_WORDS.test(said)) ranges.set(`${type}.${measure.property}`, { min: 0, max: 100 })
664
+ }
665
+ }
666
+ return { values, ranges }
667
+ }
668
+
669
+ function constraintFor(type: ObjectType, p: Property, semantic: Map<string, SemanticType>, shared: Map<string, SharedField>, fromActions: Map<string, string[]>, fromMetrics: MetricHints = { values: new Map(), ranges: new Map() }): Constraint {
670
+ const c = ownConstraint(type, p, semantic, shared, fromActions, fromMetrics)
671
+ // A share a metric averages stays a share, unless the Blueprint bounds it itself.
672
+ const range = (p.valueType ?? 'string') === 'number' ? fromMetrics.ranges.get(`${type.apiName}.${p.apiName}`) : undefined
673
+ if (range && c.min === undefined && c.max === undefined && !c.values) return { ...c, min: range.min, max: range.max, from: c.from ?? 'metric' }
674
+ return c
675
+ }
676
+
677
+ function ownConstraint(type: ObjectType, p: Property, semantic: Map<string, SemanticType>, shared: Map<string, SharedField>, fromActions: Map<string, string[]>, fromMetrics: MetricHints): Constraint {
678
+ const own = asStrings(p.allowedValues) ?? asStrings(p.enum)
679
+ if (own) return { values: own, from: 'property' }
680
+ const st = semantic.get(p.semanticType ?? '') ?? semantic.get((p.sharedField && shared.get(p.sharedField)?.semanticType) || '')
681
+ const rule = st?.rule
682
+ if (rule) {
683
+ const values = asStrings(rule.enum)
684
+ if (values) return { values, from: 'semantic-type' }
685
+ const c: Constraint = { from: 'semantic-type' }
686
+ const min = asNumber(rule.minimum)
687
+ const max = asNumber(rule.maximum)
688
+ if (min !== undefined) c.min = min
689
+ if (max !== undefined) c.max = max
690
+ if (typeof rule.minLength === 'number') c.minLength = rule.minLength
691
+ if (typeof rule.maxLength === 'number') c.maxLength = rule.maxLength
692
+ if (Object.keys(c).length > 1) return c
693
+ }
694
+ // The property's own words first, then the shared field's it implements.
695
+ const sharedDescription = p.sharedField ? shared.get(p.sharedField)?.description : undefined
696
+ // A number whose description gives its range ("How many hours … (1 to 12)") keeps to it.
697
+ const range = (p.valueType ?? 'string') === 'number' ? (describedRange(p.description) ?? describedRange(sharedDescription)) : null
698
+ if (range) return { ...range, from: 'description' }
699
+ const listed = (p.valueType ?? 'string') === 'string' ? (listedValues(p.description) ?? listedValues(sharedDescription)) : null
700
+ if (listed) return { values: listed, from: 'description' }
701
+ const action = fromActions.get(`${type.apiName}.${p.apiName}`)
702
+ if (action?.length) return { values: action, from: 'action' }
703
+ const metric = (p.valueType ?? 'string') === 'string' ? fromMetrics.values.get(`${type.apiName}.${p.apiName}`) : undefined
704
+ if (metric?.length) return { values: metric, from: 'metric' }
705
+ return {}
706
+ }
707
+
708
+ /**
709
+ * The range a description gives a number: in parentheses ("(1 to 12)",
710
+ * "(1-12 hours)", "(0–100)", "(between 1 and 5)") or said as "between 1 and
711
+ * 5". Null when it gives none, or the two ends are not low then high.
712
+ */
713
+ export function describedRange(description: string | undefined): { min: number; max: number } | null {
714
+ if (!description) return null
715
+ const num = '(-?(?:\\d{1,3}(?:,\\d{3})+|\\d+)(?:\\.\\d+)?)'
716
+ const patterns = [
717
+ new RegExp(`\\(\\s*(?:from\\s+|between\\s+)?${num}\\s*(?:to|-|–|—|\\.\\.|and)\\s*${num}\\b[^()]*\\)`, 'i'),
718
+ new RegExp(`\\bbetween\\s+${num}\\s+and\\s+${num}\\b`, 'i'),
719
+ ]
720
+ for (const re of patterns) {
721
+ const m = re.exec(description)
722
+ if (!m) continue
723
+ const min = Number(m[1]!.replace(/,/g, ''))
724
+ const max = Number(m[2]!.replace(/,/g, ''))
725
+ if (Number.isFinite(min) && Number.isFinite(max) && min < max) return { min, max }
726
+ }
727
+ return null
728
+ }
729
+
730
+ /** A number that keeps to the bounds, near the guess the name gave. */
731
+ function bounded(faker: Faker, guess: number, c: Constraint, integer: boolean): number {
732
+ if (c.min === undefined && c.max === undefined) return guess
733
+ const min = c.min ?? Math.min(guess, (c.max ?? 0) - 100)
734
+ const max = c.max ?? Math.max(guess, min + 1000)
735
+ if (guess >= min && guess <= max) return guess
736
+ // A whole number between bounds no whole number lies in (0.2 to 0.8) is a fraction.
737
+ return integer && Math.ceil(min) <= Math.floor(max) ? faker.number.int({ min: Math.ceil(min), max: Math.floor(max) }) : faker.number.float({ min, max, fractionDigits: 2 })
738
+ }
739
+
740
+ function fitLength(faker: Faker, text: string, c: Constraint): string {
741
+ let out = text
742
+ if (c.maxLength !== undefined && out.length > c.maxLength) out = out.slice(0, Math.max(0, c.maxLength)).trimEnd()
743
+ if (c.minLength !== undefined && out.length < c.minLength) out = out + faker.string.alphanumeric({ length: c.minLength - out.length, casing: 'upper' })
744
+ return out
745
+ }
746
+
747
+ // ── Time: a row's timestamps follow its lifecycle ────────────────────────────
748
+
749
+ /** Where a time sits in a record's life, from its name: created first, updated last. */
750
+ export function lifecycleRank(name: string): number {
751
+ const n = words(name)
752
+ if (/\b(birth|dob|born)\b/.test(n)) return -1
753
+ if (/\b(created|opened|submitted|registered|reported|requested|placed|received|started|start|joined|signed up|logged)\b/.test(n)) return 0
754
+ if (/\b(assigned|accepted|acknowledged|scheduled|dispatched|answered|responded|first response|approved|shipped|picked|confirmed|paid)\b/.test(n)) return 1
755
+ if (/\b(due|deadline|expires?|expiry|sla|target|expected|promised|eta)\b/.test(n)) return 2
756
+ if (/\b(resolved|closed|completed|finished|ended|end|delivered|cancell?ed|rejected|fulfilled|returned)\b/.test(n)) return 3
757
+ if (/\b(updated|modified|changed|last|synced|seen)\b/.test(n)) return 4
758
+ return 1.5
759
+ }
760
+
761
+ /**
762
+ * A time from before a record's own life, by its name: when an asset was
763
+ * installed or bought, a company founded, a person hired. Years back, not
764
+ * the last 120 days: the range in years before today; null for any other.
765
+ */
766
+ export function yearsBack(p: Pick<Property, 'apiName' | 'displayName'>): { min: number; max: number } | null {
767
+ const n = `${words(p.apiName)} ${(p.displayName ?? '').toLowerCase()}`
768
+ if (/\b(installed|installation|install date|commissioned|commissioning|built|constructed|manufactured|purchased|purchase date|acquired|bought|in service since|erected|fitted|mounted|laid)\b/.test(n)) return { min: 0.5, max: 15 }
769
+ if (/\b(founded|established|incorporated|inception)\b/.test(n)) return { min: 2, max: 60 }
770
+ if (/\b(hired|hire date|employed since|employment start|start of employment)\b/.test(n)) return { min: 0.25, max: 20 }
771
+ return null
772
+ }
773
+
774
+ const ENDS_LIFE = 3
775
+ /** A status that says the record has not reached its end yet. */
776
+ const STILL_OPEN = /^(open|new|pending|in[_ -]?progress|active|assigned|waiting|on[_ -]?hold|queued|draft|todo|submitted|escalated|triage|scheduled)$/i
777
+
778
+ // ── State: what a record's state allows the rest of it to hold ──────────────
779
+
780
+ /** Where a record is in its life, by its state: not started, under way, or ended. */
781
+ export type Phase = 'initial' | 'active' | 'terminal'
782
+
783
+ /** A state nobody has started on. */
784
+ const INITIAL_STATE = /^(new|draft|pending|submitted|created|requested|queued|todo|to[_ -]?do|unassigned|received|backlog|reported|raised|triage)$/i
785
+ /** A state that ends a record's life. */
786
+ const TERMINAL_STATE = /^(closed|resolved|done|complete|completed|cancell?ed|rejected|finished|archived|delivered|fulfilled|paid|won|lost|expired|retired|declined|refunded|returned|settled|withdrawn|abandoned|voided|dismissed|no[_ -]?show)$/i
787
+ /**
788
+ * An end without the work done: the record ended (it has a closing time),
789
+ * but nothing was fixed, so a field that says what was done stays empty.
790
+ */
791
+ const UNFINISHED_END = /^(cancell?ed|rejected|declined|lost|expired|withdrawn|abandoned|void|voided|dismissed|refused|no[_ -]?show|duplicate)$/i
792
+
793
+ /**
794
+ * Each of a state property's values, as a phase: a value whose name says it
795
+ * (new, pending; closed, resolved, cancelled) is that phase, every other one
796
+ * is under way. With three or more values and no name that says so, the
797
+ * first is taken as the start and the last as the end, the order a semantic
798
+ * type usually lists a life in.
799
+ */
800
+ export function statePhases(values: readonly string[]): Map<string, Phase> {
801
+ const out = new Map<string, Phase>(values.map((v) => [v, INITIAL_STATE.test(v) ? 'initial' : TERMINAL_STATE.test(v) ? 'terminal' : 'active']))
802
+ if (values.length >= 3) {
803
+ if (![...out.values()].includes('initial') && out.get(values[0]!) === 'active') out.set(values[0]!, 'initial')
804
+ if (![...out.values()].includes('terminal') && out.get(values[values.length - 1]!) === 'active') out.set(values[values.length - 1]!, 'terminal')
805
+ }
806
+ return out
807
+ }
808
+
809
+ /** A value's phase when the state has no listed values: by its name alone. */
810
+ function phaseOf(value: string, phases: Map<string, Phase> | null): Phase | null {
811
+ if (!value) return null
812
+ const known = phases?.get(value)
813
+ if (known) return known
814
+ if (INITIAL_STATE.test(value)) return 'initial'
815
+ if (TERMINAL_STATE.test(value)) return 'terminal'
816
+ return STILL_OPEN.test(value) ? 'active' : null
817
+ }
818
+
819
+ /** A field that is set as a record ends: its resolution, its outcome, why it was closed. */
820
+ function endsLife(p: Property): boolean {
821
+ const n = words(p.apiName)
822
+ return /\b(resolution|resolved|outcome|closure|closing|closed|completion|completed|cancell?ation|cancell?ed|root cause|fix|final)\b/.test(n) || /\b(once|when|after)\b[^.]*\b(closed|resolved|completed|done|finished)\b/i.test(p.description ?? '')
823
+ }
824
+
825
+ /**
826
+ * A field that is set once someone takes the record on: who it is assigned
827
+ * to, the technician sent, its owner; or one its description says is empty
828
+ * while the record is new.
829
+ */
830
+ function startsWork(p: Property, ref: Reference | undefined, entities: ReadonlyMap<string, Entity | null>): boolean {
831
+ const n = words(p.apiName)
832
+ if (/\b(empty|blank|unset|none)\b[^.]*\b(while|until|when)\b[^.]*\b(new|unassigned|open|pending|created|raised)\b/i.test(p.description ?? '')) return true
833
+ if (/\b(assigned|assignee|technician|handler|responder|engineer|driver|courier|dispatcher|worker|owner)\b/.test(n)) return true
834
+ return !!ref && entities.get(ref.objectType) === 'person' && /\b(assigned|assignee|technician|handler|responder|engineer|driver|courier|agent|rep|owner|worker|staff)\b/.test(n)
835
+ }
836
+
837
+ /** A phrase standing on its own in a text: "escalated" in "unless escalated", not in "de-escalated". */
838
+ const says = (text: string, phrase: string) => !!phrase && new RegExp(`(?<![\\w-])${phrase.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(?![\\w-])`, 'i').test(text)
839
+
840
+ /**
841
+ * When a field holds a value, from its description: "empty unless
842
+ * escalated", "only when the work order is critical", "set only if the
843
+ * customer is a business", "empty while on hold". A test on the row (and
844
+ * its phase), or null when the description says no such thing or names
845
+ * nothing the row has. What the condition names, in order: a yes/no
846
+ * property ("escalated"), a value of a property that lists values ("on
847
+ * hold", "critical"), the record ending or still open ("closed", "open"),
848
+ * then another property by name (set when that one is). "not" turns it.
849
+ */
850
+ /**
851
+ * The condition a description makes a field's value depend on, as written:
852
+ * "escalated" in "empty unless escalated" (`emptyWhen` false: the field is
853
+ * set while it holds), "on hold" in "empty while on hold" (`emptyWhen`
854
+ * true). Null when the description makes the field conditional on nothing.
855
+ */
856
+ export function conditionOf(description: string | undefined): { text: string; emptyWhen: boolean } | null {
857
+ const d = (description ?? '').replace(/\s+/g, ' ')
858
+ let emptyWhen = false
859
+ let m = /\b(?:empty|blank|unset|null|none|left empty)\s+(?:unless|until|except (?:when|if|once))\s+([^.;]+)/i.exec(d)
860
+ m ??= /\bonly (?:when|if|once|while|for)\s+([^.;]+)/i.exec(d)
861
+ if (!m) {
862
+ m = /\b(?:empty|blank|unset|null|none|left empty)\s+(?:when|while|if|for)\s+([^.;]+)/i.exec(d)
863
+ emptyWhen = !!m
864
+ }
865
+ return m ? { text: m[1]!.trim(), emptyWhen } : null
866
+ }
867
+
868
+ export function filledWhen(
869
+ p: Pick<Property, 'apiName' | 'description'>,
870
+ properties: Property[],
871
+ statusProperty: Property | undefined,
872
+ phases: Map<string, Phase> | null,
873
+ valuesOf: (property: string) => readonly string[] | undefined = () => undefined,
874
+ ): ((row: Row, phase: Phase | null) => boolean) | null {
875
+ const condition = conditionOf(p.description)
876
+ if (!condition) return null
877
+ const { text, emptyWhen: negate } = condition
878
+ const not = /\b(not|no longer|isn't|is not|never|hasn't|has not)\b/i.test(text)
879
+ const flip = negate !== not
880
+ const others = properties.filter((x) => x.apiName !== p.apiName)
881
+ const done = (holds: (row: Row, phase: Phase | null) => boolean) => (flip ? (row: Row, phase: Phase | null) => !holds(row, phase) : holds)
882
+ // A yes/no property: `escalated`, `isEscalated`, "Escalated".
883
+ for (const b of others.filter((x) => x.valueType === 'boolean')) {
884
+ const named = words(b.apiName).replace(/^(is|has|was) /, '')
885
+ if (says(text, named) || says(text, words(b.apiName)) || (b.displayName && says(text, b.displayName.toLowerCase()))) return done((row) => row[b.apiName] === true || row[b.apiName] === 'true')
886
+ }
887
+ // A value of a property that lists values, the state first.
888
+ const listing = [...(statusProperty ? [statusProperty] : []), ...others.filter((x) => x !== statusProperty)]
889
+ for (const x of listing) {
890
+ const values = x === statusProperty && phases ? [...phases.keys()] : valuesOf(x.apiName)
891
+ const named = values?.filter((v) => says(text, valueWords(v)) || says(text, v))
892
+ if (named?.length) return done((row) => named.includes(String(row[x.apiName] ?? '')))
893
+ }
894
+ // The record's end, or its being open.
895
+ if (statusProperty && /\b(closed|resolved|done|completed|finished|ended|cancell?ed)\b/i.test(text)) return done((_row, phase) => phase === 'terminal')
896
+ if (statusProperty && /\b(open|active|in progress|under way|ongoing)\b/i.test(text)) return done((_row, phase) => phase !== null && phase !== 'terminal')
897
+ // Another property by name: set when that one is.
898
+ for (const x of others) {
899
+ const named = words(x.apiName).replace(/ id$/, '')
900
+ if (named.length > 2 && says(text, named)) return done((row) => filled(row[x.apiName]))
901
+ }
902
+ return null
903
+ }
904
+
905
+ // ── Values ───────────────────────────────────────────────────────────────────
906
+
907
+ /**
908
+ * A property that says which record a row is, or what its figure means: its
909
+ * title, a period, a fiscal year, a key or code (`metricKey`), a case or
910
+ * scope, a currency or unit. Sample data never leaves it empty at random,
911
+ * only by the state rules.
912
+ */
913
+ export function defining(type: Pick<ObjectType, 'title' | 'primaryKey'>, p: Pick<Property, 'apiName'>): boolean {
914
+ if (p.apiName === type.title && p.apiName !== type.primaryKey) return true
915
+ return /\b(period|fiscal year|year|month|quarter|week|key|code|scenario|case|scope|currency|unit|uom)$/.test(words(p.apiName))
916
+ }
917
+
918
+ /** This year, or the one before or after it. */
919
+ const yearNear = (faker: Faker) => new Date().getUTCFullYear() + faker.number.int({ min: -1, max: 1 })
920
+
921
+ /** How many of something a record has, by its name: rooms, floors, people. Null when the name doesn't count anything. */
922
+ function smallCount(faker: Faker, name: string, description: string): number | null {
923
+ if (/\b(bedrooms?|bathrooms?|beds|baths|toilets)\b/.test(name)) return faker.number.int({ min: 1, max: 4 })
924
+ if (/\b(floors?|storeys?|stories|levels)\b/.test(name)) return faker.number.int({ min: 1, max: 30 })
925
+ if (/\b(rooms|doors|windows|lanes|spaces|parking|bays|pets|children|kids|adults|guests|occupants|residents|people|persons|headcount|attendees|visits|attempts|retries|stops|vehicles|reminders)\b/.test(name)) return faker.number.int({ min: 1, max: 8 })
926
+ // A count a description introduces ("How many leases…") stays small; one of hours, days or visits is at least one.
927
+ if (/^\s*how many\s+(minutes|hours|days|weeks|months|years|visits|times|steps)\b/i.test(description)) return faker.number.int({ min: 1, max: 10 })
928
+ if (/^\s*(how many|the number of|number of|count of)\b/i.test(description) || /^(num|number of|no of|count of) /.test(name)) return faker.number.int({ min: 0, max: 10 })
929
+ return null
930
+ }
931
+
932
+ /** A value for one property, from what it allows, its type and its name. */
933
+ function valueFor(faker: Faker, type: ObjectType, p: Property, c: Constraint, region: Region): unknown {
162
934
  const name = words(p.apiName)
163
- const listed = listedValues(p.description)
164
- if (listed && (p.valueType ?? 'string') === 'string') return faker.helpers.arrayElement(listed)
165
- switch (p.valueType ?? 'string') {
935
+ const valueType = p.valueType ?? 'string'
936
+ // Values a metric's filter names are some of a property's, not all of them: other rows read as they would.
937
+ if (c.values?.length && (valueType === 'string' || valueType === 'number') && (c.from !== 'metric' || faker.datatype.boolean({ probability: 0.7 }))) {
938
+ const v = faker.helpers.arrayElement(c.values)
939
+ return valueType === 'number' && Number.isFinite(Number(v)) ? Number(v) : v
940
+ }
941
+ switch (valueType) {
166
942
  case 'boolean':
167
943
  return faker.datatype.boolean({ probability: /^(is|has) /.test(name) ? 0.35 : 0.5 })
168
944
  case 'number': {
169
- if (/csat|rating|stars|score/.test(name)) return faker.number.int({ min: 1, max: 5 })
170
- if (/percent|rate|ratio|share/.test(name)) return faker.number.float({ min: 0, max: 100, fractionDigits: 1 })
171
- if (/hours?\b|sla/.test(name)) return faker.helpers.arrayElement([4, 8, 24, 48, 72])
172
- if (/days?\b/.test(name)) return faker.number.int({ min: 1, max: 30 })
173
- if (/quantity|qty|count|units|items/.test(name)) return faker.number.int({ min: 1, max: 6 })
174
- if (/age\b/.test(name)) return faker.number.int({ min: 18, max: 80 })
175
- if (/price|amount|total|revenue|cost|value|fee|refund|balance|spend|discount/.test(name)) return Number(faker.commerce.price({ min: 5, max: 900 }))
176
- if (/weight|kg/.test(name)) return faker.number.float({ min: 0.1, max: 25, fractionDigits: 2 })
177
- if (/lat/.test(name)) return faker.location.latitude()
178
- if (/lon|lng/.test(name)) return faker.location.longitude()
179
- return faker.number.int({ min: 1, max: 1000 })
180
- }
181
- case 'date':
182
- return faker.date.past({ years: 2, refDate: state.today }).toISOString().slice(0, 10)
183
- case 'timestamp': {
184
- // Times in a row follow one another (opened, then answered, then resolved).
185
- const at = state.lastTime === null
186
- ? faker.date.recent({ days: 120, refDate: state.today }).getTime()
187
- : state.lastTime + faker.number.int({ min: 20, max: 72 * 60 }) * 60_000
188
- const clamped = Math.min(at, state.today.getTime())
189
- state.lastTime = clamped
190
- return new Date(clamped).toISOString().replace(/\.\d{3}Z$/, 'Z')
945
+ const point = /\blat(itude)?\b/.test(name) || /\b(lon|lng|long|longitude)\b/.test(name) ? pointIn(faker, region) : null
946
+ if (point) return /\blat/.test(name) ? point.lat : point.lon
947
+ let guess: number
948
+ let integer = true
949
+ const counted = smallCount(faker, name, p.description ?? '')
950
+ if (/\b(fiscal year|year)$/.test(name) && !/\byears\b/.test(name)) guess = yearNear(faker)
951
+ else if (counted !== null) guess = counted
952
+ else if (/csat|rating|stars|score/.test(name)) guess = faker.number.int({ min: 1, max: 5 })
953
+ else if (/\b(probability|likelihood|confidence)\b/.test(name)) ((guess = faker.number.float({ min: 0.05, max: 0.98, fractionDigits: 2 })), (integer = false))
954
+ else if (/percent|\bpct\b|rate|ratio|share/.test(name)) ((guess = faker.number.float({ min: 0, max: 100, fractionDigits: 1 })), (integer = false))
955
+ else if (/\bmonths?\b/.test(name)) guess = faker.number.int({ min: 1, max: 36 })
956
+ else if (/\byears\b/.test(name)) guess = faker.number.int({ min: 1, max: 30 })
957
+ else if (/\b(minutes?|mins?)\b/.test(name)) guess = faker.helpers.arrayElement([15, 30, 30, 45, 60, 60, 90, 120])
958
+ else if (/\b(capacity|seats|max guests|max people|max occupants)\b/.test(name)) guess = faker.number.int({ min: 2, max: 60 })
959
+ else if (/hours?\b|sla/.test(name)) guess = faker.helpers.arrayElement([4, 8, 24, 48, 72])
960
+ else if (/days?\b/.test(name)) guess = faker.number.int({ min: 1, max: 30 })
961
+ else if (/quantity|qty|count|units|items/.test(name)) guess = faker.number.int({ min: 1, max: 6 })
962
+ else if (/age\b/.test(name)) guess = faker.number.int({ min: 18, max: 80 })
963
+ else if (/price|amount|total|revenue|cost|value|fee|refund|balance|spend|discount|\brent\b|income|\bnoi\b|salary|wage|budget|premium|deposit|payment|charge|\b(sgd|usd|eur|gbp|idr|aud|myr)\b/.test(name)) {
964
+ // A year's worth reads as a year's: annual rent is twelve months of it.
965
+ const yearly = /\b(annual|annualised|annualized|yearly|per year|ltm)\b/.test(name) ? 12 : 1
966
+ guess = Number((Number(faker.commerce.price({ min: 5, max: 900 })) * yearly).toFixed(2))
967
+ integer = false
968
+ }
969
+ else if (/weight|kg/.test(name)) ((guess = faker.number.float({ min: 0.1, max: 25, fractionDigits: 2 })), (integer = false))
970
+ else guess = faker.number.int({ min: 1, max: 1000 })
971
+ return bounded(faker, guess, c, integer)
191
972
  }
192
973
  case 'json':
193
974
  return JSON.stringify({})
194
975
  case 'geopoint':
195
- return JSON.stringify({ lat: faker.location.latitude(), lon: faker.location.longitude() })
976
+ return JSON.stringify(pointIn(faker, region))
196
977
  case 'media_reference':
197
978
  return ''
979
+ case 'date':
980
+ case 'timestamp':
981
+ // Set with the row's other times, in lifecycle order (`fillTimes`).
982
+ return undefined
198
983
  default:
199
984
  break
200
985
  }
986
+ return fitLength(faker, textFor(faker, type, p, region), c)
987
+ }
988
+
989
+ /**
990
+ * A time of day a text property holds, by its name: when something opens or
991
+ * starts (`openTime`, `opensAt`, `start time`), or closes or ends
992
+ * (`closeTime`, `end time`). Null for any other text.
993
+ */
994
+ export function timeOfDayRole(p: Pick<Property, 'apiName' | 'valueType'>): 'open' | 'close' | null {
995
+ if ((p.valueType ?? 'string') !== 'string') return null
996
+ const n = words(p.apiName)
997
+ const timed = /\b(time|hour)$/.test(n) || /^(opens?|closes?|starts?|ends?)( at)?$/.test(n)
998
+ if (!timed) return null
999
+ if (/\b(open|opens|opening|start|starts|starting|from)\b/.test(n)) return 'open'
1000
+ if (/\b(close|closes|closing|end|ends|ending|until)\b/.test(n)) return 'close'
1001
+ return null
1002
+ }
1003
+
1004
+ /** A time of day, `HH:MM`, on the half hour: an opening in the morning, a closing in the evening, so it closes after it opens. */
1005
+ function timeOfDay(faker: Faker, role: 'open' | 'close'): string {
1006
+ return timeOfDayFrom(() => faker.number.float({ min: 0, max: 0.999999 }), role)
1007
+ }
1008
+
1009
+ function timeOfDayFrom(draw: () => number, role: 'open' | 'close'): string {
1010
+ // 06:00–10:30 to open, 17:00–23:00 to close.
1011
+ const halfHours = role === 'open' ? 12 + Math.floor(draw() * 10) : 34 + Math.floor(draw() * 13)
1012
+ return `${String(Math.floor(halfHours / 2)).padStart(2, '0')}:${halfHours % 2 ? '30' : '00'}`
1013
+ }
1014
+
1015
+ function textFor(faker: Faker, type: ObjectType, p: Property, region: Region): string {
1016
+ const name = words(p.apiName)
1017
+ const role = timeOfDayRole(p)
1018
+ if (role) return timeOfDay(faker, role)
1019
+ // Opening hours: from a morning to an evening time.
1020
+ if (/^(hours|opening hours|business hours|operating hours|open hours)$/.test(name)) return `${timeOfDay(faker, 'open')}–${timeOfDay(faker, 'close')}`
1021
+ // A reporting period a month long: `2026-09`, in the last year.
1022
+ if (/^(period|month|reporting period|billing period|fiscal period|period month)$/.test(name)) {
1023
+ const at = new Date(Date.UTC(new Date().getUTCFullYear(), new Date().getUTCMonth() - faker.number.int({ min: 0, max: 11 }), 1))
1024
+ return `${at.getUTCFullYear()}-${String(at.getUTCMonth() + 1).padStart(2, '0')}`
1025
+ }
1026
+ // A fiscal year as finance says it (FY2027); a year, a quarter.
1027
+ if (/\bfiscal year$|^fy$/.test(name)) return `FY${yearNear(faker)}`
1028
+ if (/^(year|calendar year|reporting year)$/.test(name)) return String(yearNear(faker))
1029
+ if (/^(quarter|fiscal quarter|reporting quarter)$/.test(name)) return `${new Date().getUTCFullYear()}-Q${faker.number.int({ min: 1, max: 4 })}`
201
1030
  if (/e ?mail/.test(name)) return faker.internet.email({ provider: 'example.com' }).toLowerCase()
202
- if (/phone|mobile/.test(name)) return faker.phone.number({ style: 'international' })
1031
+ if (/phone|mobile/.test(name)) return phoneFor(faker, region)
203
1032
  if (/first name|given name/.test(name)) return faker.person.firstName()
204
1033
  if (/last name|surname|family name/.test(name)) return faker.person.lastName()
205
- if (/(^|\b)(name|full name|customer name|contact)$/.test(name) && /customer|person|user|contact|employee|member|agent/i.test(type.apiName + name)) return faker.person.fullName()
1034
+ if (/\bjob title\b|\bposition\b/.test(name)) return faker.person.jobTitle()
1035
+ // A record's name reads like what the record is: a person's, a company's, a place's.
1036
+ const named = nameFor(faker, type, name, p)
1037
+ if (named !== null) return named
206
1038
  if (/company|organi[sz]ation|account name|merchant|vendor|supplier/.test(name)) return faker.company.name()
207
1039
  if (/product|item name/.test(name)) return faker.commerce.productName()
208
- if (/sku|code|reference/.test(name)) return faker.string.alphanumeric({ length: 8, casing: 'upper' })
209
- if (/city/.test(name)) return faker.location.city()
210
- if (/country/.test(name)) return faker.location.countryCode()
1040
+ if (/\b(idempotency|uuid|guid|request id|correlation id|trace id)\b/.test(name)) return faker.string.uuid()
1041
+ // Another record's key, of a type the Blueprint doesn't have: a key like one (`portfolioId` → POR-00003), from a few, so rows share them.
1042
+ const idOf = /^([a-z][a-z ]*?) (id|key)$/.exec(name)
1043
+ if (idOf) return `${keyPrefix(idOf[1]!.replace(/\s+/g, ''))}-${String(faker.number.int({ min: 1, max: 12 })).padStart(5, '0')}`
1044
+ if (/sku|code|reference|serial|\bnumber\b|\bno\b/.test(name)) return faker.string.alphanumeric({ length: 8, casing: 'upper' })
1045
+ // The city the points are in: a record's city agrees with its coordinates, never a town across the country.
1046
+ if (/city|town/.test(name)) return cityOf(faker, region)
1047
+ // The region's own: a record's country and currency agree with where its points are.
1048
+ if (/country/.test(name)) return countryOf(region) ?? faker.location.countryCode()
211
1049
  if (/postal|zip/.test(name)) return faker.location.zipCode()
212
1050
  if (/address|street/.test(name)) return faker.location.streetAddress()
213
- if (/currency/.test(name)) return 'USD'
1051
+ if (/currency/.test(name)) return region.currency
214
1052
  if (/url|website|link/.test(name)) return faker.internet.url()
215
1053
  if (/carrier|courier/.test(name)) return faker.helpers.arrayElement(['DHL', 'UPS', 'FedEx', 'Royal Mail', 'PostNL'])
216
- if (/assignee|owner|agent|rep/.test(name)) return `agent-${faker.person.firstName().toLowerCase()}`
1054
+ // Who did it: a person's name (prepared by, decided by, assigned to).
1055
+ if (/\b(prepared|decided|approved|created|submitted|reviewed|requested|reported|signed|handled|updated|modified|assigned|raised|completed|closed|resolved|entered|recorded|written|authored|checked|verified|owned) by$|^assigned to$/.test(name)) return `${faker.person.firstName()} ${faker.person.lastName()}`
1056
+ if (/\b(assignee|owner|agent|rep)\b/.test(name)) return `agent-${faker.person.firstName().toLowerCase()}`
217
1057
  if (/segment|tier|plan/.test(name)) return faker.helpers.arrayElement(['standard', 'plus', 'enterprise'])
218
- // English words, not lorem ipsum: sample rows are read by people and by Agents.
219
- if (/subject|title|summary|headline/.test(name)) return phrase(faker)
220
- if (/description|notes?|body|comment|message|reason|details?/.test(name)) return `${phrase(faker)}. ${faker.hacker.phrase()}`
221
- if (/name/.test(name)) return faker.commerce.productName()
1058
+ // A title, a description, a resolution or a note is written from the rest of the row (`proseFor`).
1059
+ if (/name/.test(name)) return `${title(faker.word.adjective())} ${typeWords(type)}`
222
1060
  if (/status|state|stage/.test(name)) return faker.helpers.arrayElement(['open', 'in_progress', 'done'])
1061
+ if (/priority|severity|urgency/.test(name)) return faker.helpers.weightedArrayElement([{ value: 'low', weight: 3 }, { value: 'medium', weight: 4 }, { value: 'high', weight: 2 }, { value: 'critical', weight: 1 }])
223
1062
  if (/category|type|kind/.test(name)) return faker.helpers.arrayElement(['general', 'billing', 'delivery', 'product'])
224
1063
  return faker.word.noun()
225
1064
  }
226
1065
 
227
- /** "Replace damaged charger": a short line that reads like a title. */
228
- function phrase(faker: Faker): string {
229
- const verb = faker.word.verb()
230
- return `${verb[0]!.toUpperCase()}${verb.slice(1)} ${faker.word.adjective()} ${faker.word.noun()}`
1066
+ // ── What a record is: a person, an organization, a place, a thing ───────────
1067
+
1068
+ /** The kind of record an object type (or a property's own words) says it is. */
1069
+ export type Entity = 'person' | 'organization' | 'place' | 'thing' | 'product'
1070
+
1071
+ const ENTITY_WORDS: Array<[Entity, RegExp]> = [
1072
+ ['person', /\b(customer|person|people|user|contact|employee|member|agent|technician|engineer|driver|staff|worker|doctor|patient|student|teacher|nurse|courier|representative|rep|owner|manager|operator|dispatcher|inspector|officer|resident|guest|passenger|candidate|applicant|volunteer|author|buyer|seller|subscriber|lead|clinician|physician|caller|requester|reporter|assignee|approver|reviewer|traveller|traveler|player|client|recipient|signatory|holder|beneficiary)s?\b/],
1073
+ ['organization', /\b(company|companies|organi[sz]ations?|accounts?|vendors?|suppliers?|merchants?|partners?|business(es)?|firms?|carriers?|banks?|insurers?|providers?|manufacturers?|distributors?|agenc(y|ies)|institutions?|employers?|brands?|retailers?)\b/],
1074
+ ['place', /\b(sites?|locations?|places?|stores?|branch(es)?|facilit(y|ies)|warehouses?|depots?|offices?|buildings?|propert(y|ies)|premises|venues?|stations?|substations?|plants?|campus(es)?|zones?|areas?|outlets?|hubs?|ports?|airports?|terminals?|clinics?|hospitals?|schools?|addresses)\b/],
1075
+ ['thing', /\b(assets?|equipment|devices?|machines?|vehicles?|meters?|sensors?|units?|components?|parts?|tools?|instruments?|appliances?|transformers?|pumps?|valves?)\b/],
1076
+ ['product', /\b(products?|items?|skus?|articles?|goods|offerings?|packages?)\b/],
1077
+ ]
1078
+
1079
+ /** What an object type's records are, from its name; null when the name does not say. */
1080
+ export function entityOf(type: Pick<ObjectType, 'apiName' | 'displayName'>): Entity | null {
1081
+ const text = `${words(type.apiName)} ${(type.displayName ?? '').toLowerCase()}`
1082
+ return ENTITY_WORDS.find(([, re]) => re.test(text))?.[0] ?? null
1083
+ }
1084
+
1085
+ const title = (text: string) => (text ? text[0]!.toUpperCase() + text.slice(1) : text)
1086
+
1087
+ /** The type's name in words, as a record's name ends: "Site", "Work order". */
1088
+ const typeWords = (type: ObjectType) => title((type.displayName ?? words(type.apiName)).toLowerCase())
1089
+
1090
+ /**
1091
+ * A record's name, when the property is its name (`name`, `full name`, or
1092
+ * `<what> name`): a person's for a customer or technician, a company's for
1093
+ * a supplier, a street and the type for a site, the type and a serial for
1094
+ * an asset. Null when the property is not a name.
1095
+ */
1096
+ function nameFor(faker: Faker, type: ObjectType, name: string, p?: Property): string | null {
1097
+ const m = /^(?:(.+) )?(name|full name|display name)$/.exec(name)
1098
+ if (!m) return null
1099
+ // `company name` on a Contact names the company; `name` names the record.
1100
+ const said = m[1] ? ENTITY_WORDS.find(([, re]) => re.test(m[1]!))?.[0] ?? null : null
1101
+ if (m[1] && !said && !/^(full|display|legal|contact)$/.test(m[1])) return null
1102
+ switch (said ?? (m[1] === 'contact' ? 'person' : entityOf(type))) {
1103
+ case 'person':
1104
+ // A first and a last name: no "Dr." or "Jr." to carry into an email address.
1105
+ return `${faker.person.firstName()} ${faker.person.lastName()}`
1106
+ case 'organization':
1107
+ return faker.company.name()
1108
+ case 'place':
1109
+ // A place's name its property calls an address is one: "4 Green Walk".
1110
+ if (!said && /\baddress\b/i.test(`${p?.displayName ?? ''} ${p?.description ?? ''}`)) return faker.location.streetAddress()
1111
+ return `${faker.location.street()} ${said ? m[1]! : typeWords(type).toLowerCase()}`
1112
+ case 'thing':
1113
+ return `${said ? title(m[1]!) : typeWords(type)} ${faker.string.alpha({ length: 1, casing: 'upper' })}-${faker.string.numeric(3)}`
1114
+ case 'product':
1115
+ return faker.commerce.productName()
1116
+ default:
1117
+ return null
1118
+ }
1119
+ }
1120
+
1121
+ /**
1122
+ * A draw of its own for one value of one row: the same seed, row and
1123
+ * property give the same value however often it is written again, so a
1124
+ * value made from the rest of the row changes only when the row does.
1125
+ */
1126
+ function drawFor(seed: number, key: string): () => number {
1127
+ let h = (2166136261 ^ seed) >>> 0
1128
+ for (let i = 0; i < key.length; i++) h = Math.imul(h ^ key.charCodeAt(i), 16777619)
1129
+ let a = h >>> 0
1130
+ return () => {
1131
+ a = (a + 0x6d2b79f5) >>> 0
1132
+ let t = a
1133
+ t = Math.imul(t ^ (t >>> 15), t | 1)
1134
+ t ^= t + Math.imul(t ^ (t >>> 7), t | 61)
1135
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296
1136
+ }
1137
+ }
1138
+
1139
+ const pickWith = <T>(draw: () => number, list: readonly T[]): T => list[Math.floor(draw() * list.length)]!
1140
+
1141
+ /** A value's words: `line_damage` → "line damage", `meterFault` → "meter fault". */
1142
+ const valueWords = (value: string) => value.replace(/([a-z])([A-Z])/g, '$1 $2').replace(/[_-]+/g, ' ').toLowerCase().trim()
1143
+
1144
+ // ── Text: a title, a description, a resolution that read like the record ────
1145
+
1146
+ /** The kind of free text a property holds, from its name; null when it is not free text. */
1147
+ export type Prose = 'title' | 'description' | 'resolution' | 'notes' | 'reason'
1148
+
1149
+ export function proseKind(p: Pick<Property, 'apiName' | 'valueType' | 'description'>): Prose | null {
1150
+ if ((p.valueType ?? 'string') !== 'string') return null
1151
+ const n = words(p.apiName)
1152
+ if (/\bjob title\b/.test(n) || /\b(id|ids|type|kind|category|code|status|state|stage|number|no|key|ref|url|uri|email|phone|name|priority|severity|level|count)$/.test(n)) return null
1153
+ if (/\b(resolution|outcome|fix|fixed|remedy|root cause|closure|closing|action taken|work done|work performed)\b/.test(n) || /\bwhat was done\b|\bhow it was (fixed|resolved)\b/i.test(p.description ?? '')) return 'resolution'
1154
+ if (/\b(reason|cause|justification|rationale)\b/.test(n) || /^\s*(why|the reason)\b/i.test(p.description ?? '')) return 'reason'
1155
+ if (/\b(notes?|comments?|remarks?|memo|follow up)\b/.test(n)) return 'notes'
1156
+ if (/\b(title|subject|summary|headline|topic|caption)\b/.test(n)) return 'title'
1157
+ if (/\b(description|desc|details?|body|message|text|problem|issue|fault|complaint|symptoms?|report|narrative)\b/.test(n)) return 'description'
1158
+ return null
1159
+ }
1160
+
1161
+ /**
1162
+ * Plain words for the kind of work a category names, matched on the
1163
+ * category's own words: what a reporter says, what was done about it, the
1164
+ * part it is usually about. `{thing}` is the asset the record is about.
1165
+ */
1166
+ interface Vocabulary {
1167
+ match: RegExp
1168
+ details: string[]
1169
+ fixes: string[]
1170
+ part: string
1171
+ /** Planned work (an inspection): nobody reports it. */
1172
+ planned?: boolean
1173
+ }
1174
+
1175
+ const VOCABULARIES: Vocabulary[] = [
1176
+ {
1177
+ match: /\b(outage|power|supply|electric\w*|blackout|trip\w*)\b/,
1178
+ details: ['no supply since early morning', 'neighbouring premises are affected too', 'supply drops every few minutes', 'lights flicker before the power goes'],
1179
+ fixes: ['Replaced the blown fuse on the {thing} and tested; supply restored', 'Reset the tripped breaker at the {thing}; supply restored', 'Re-terminated a loose connection at the {thing}; supply restored'],
1180
+ part: 'service cable',
1181
+ },
1182
+ {
1183
+ match: /\b(leak\w*|water|pipe|gas|drip\w*|burst)\b/,
1184
+ details: ['water pooling around the meter box', 'a faint smell near the regulator', 'a steady drip from the valve', 'a damp patch spreading along the wall'],
1185
+ fixes: ['Replaced the {thing} seal and pressure-tested; no further leak', 'Tightened the {thing} coupling and leak-tested', 'Replaced the damaged section of pipe and tested'],
1186
+ part: 'valve',
1187
+ },
1188
+ {
1189
+ match: /\b(meter\w*|reading)\b/,
1190
+ details: ['the display has been blank since Monday', 'the reading has not changed in two weeks', 'readings stopped arriving', 'the meter shows an error code'],
1191
+ fixes: ['Replaced the faulty meter and verified the readings', 'Re-paired the meter communications module; readings arriving', 'Reseated the meter and verified the reading'],
1192
+ part: 'meter',
1193
+ },
1194
+ {
1195
+ match: /\b(line|lines|cable|wire|pole|damage|damaged|down)\b/,
1196
+ details: ['a branch brought the line down across the road', 'the line is sagging over a roof', 'a contractor hit a cable while digging', 'sparks seen near the pole', 'visible damage to the {thing}', 'the {thing} was struck by a vehicle'],
1197
+ fixes: ['Repaired the damaged line and re-tensioned the span', 'Jointed the damaged cable and tested', 'Cleared vegetation and repaired the {thing} connection'],
1198
+ part: 'line',
1199
+ },
1200
+ {
1201
+ match: /\b(inspection|survey|audit|check|maintenance|preventive|routine)\b/,
1202
+ details: ['routine inspection is due', 'requested by the asset team ahead of replacement', 'the annual safety check', 'a follow-up from the last visit'],
1203
+ fixes: ['Inspected the {thing}; no defects found', 'Inspected and cleaned the {thing} contacts', 'Inspected the {thing}; minor corrosion treated'],
1204
+ part: 'equipment',
1205
+ planned: true,
1206
+ },
1207
+ {
1208
+ match: /\b(noise|vibration|hum\w*|smell|smoke|heat|overheat\w*)\b/,
1209
+ details: ['loud humming at night', 'vibration felt through the floor', 'a burning smell near the cabinet', 'the casing is hot to the touch'],
1210
+ fixes: ['Tightened the {thing} mounting and tested', 'Replaced the worn {thing} bearing and tested', 'Cleaned the {thing} vents and checked the load'],
1211
+ part: 'unit',
1212
+ },
1213
+ {
1214
+ match: /\b(repair|broken|fault|failure|failed|defect|malfunction|breakdown)\b/,
1215
+ details: ['it stopped working this morning', 'it works only intermittently', 'the error light is on', 'it makes a grinding noise'],
1216
+ fixes: ['Replaced the faulty {thing} part and tested', 'Repaired the {thing} and ran a full test', 'Reset the {thing} and confirmed normal operation'],
1217
+ part: 'unit',
1218
+ },
1219
+ {
1220
+ match: /\b(billing|bill|invoice|payment|charge|charges|refund|price|fee)\b/,
1221
+ details: ['charged twice on the last invoice', 'the amount is higher than expected', 'a payment is not showing', 'asking for a refund'],
1222
+ fixes: ['Corrected the invoice and issued a credit', 'Applied the missing payment and confirmed the balance', 'Issued the refund and confirmed by email'],
1223
+ part: 'account',
1224
+ },
1225
+ {
1226
+ match: /\b(delivery|deliveries|shipping|shipment|parcel|package|courier|return|returns)\b/,
1227
+ details: ['the parcel has not arrived', 'tracking has not updated in three days', 'the package arrived damaged', 'it was delivered to the wrong address'],
1228
+ fixes: ['Re-shipped the order with tracking', 'Located the parcel with the carrier; delivered', 'Sent a replacement and arranged a return'],
1229
+ part: 'parcel',
1230
+ },
1231
+ {
1232
+ match: /\b(account|login|log in|access|password|sign in|signin)\b/,
1233
+ details: ['cannot sign in since the update', 'locked out after a password reset', 'needs access for a new colleague'],
1234
+ fixes: ['Reset access and confirmed sign-in', 'Unlocked the account and confirmed sign-in', 'Granted access and confirmed with the requester'],
1235
+ part: 'account',
1236
+ },
1237
+ {
1238
+ match: /\b(product|quality|item|wrong|missing)\b/,
1239
+ details: ['the item does not match the description', 'it arrived with a part missing', 'it stopped working after a week'],
1240
+ fixes: ['Replaced the item and confirmed with the customer', 'Sent the missing part', 'Refunded and arranged a return'],
1241
+ part: 'item',
1242
+ },
1243
+ ]
1244
+
1245
+ const GENERAL: Vocabulary = {
1246
+ match: /$^/,
1247
+ details: ['reported by phone this morning', 'needs someone to take a look', 'reported again after an earlier visit', 'details confirmed with the requester'],
1248
+ fixes: ['Investigated and fixed the issue; the requester confirmed', 'Resolved on the first visit and tested', 'Handled and confirmed with the requester'],
1249
+ part: 'equipment',
1250
+ }
1251
+
1252
+ const GENERAL_ISSUES = ['service request', 'reported problem', 'follow-up request', 'general enquiry']
1253
+ const FOLLOW_UPS = ['Follow-up: call the {reporter} to confirm it is fixed', 'Follow-up: check again on the next visit', 'Follow-up: parts ordered, waiting for delivery', 'Follow-up: photos added to the record', 'Follow-up: access arranged with the site contact']
1254
+ const REASONS = ['Duplicate of an earlier request', 'Requested by the {reporter}', 'Waiting on parts', 'No access on the first visit', 'Outside the service area', 'Raised by routine monitoring']
1255
+ /** Reasons for what a field's name says happened: why it was escalated, cancelled, put on hold. */
1256
+ const REASONS_FOR: Array<[RegExp, string[]]> = [
1257
+ [/\bescalat/, ['The {reporter} called again; still not fixed', 'At risk of breaching the SLA', 'Repeat fault within 30 days', 'Critical load at the site', 'Safety risk reported on site']],
1258
+ [/\bcancel/, ['Duplicate of an earlier request', 'Withdrawn by the {reporter}', 'Raised in error']],
1259
+ [/\b(hold|pause|suspend|block)/, ['Waiting on parts', 'No access on the first visit', 'Waiting for the {reporter} to confirm a time']],
1260
+ [/\b(reject|declin|refus)/, ['Outside the service area', 'Not covered by the contract', 'Not enough detail to act on']],
1261
+ ]
1262
+
1263
+ /** A category that says nothing of the work. */
1264
+ const GENERIC_TOPIC = /^(other|others|general|misc|miscellaneous|unknown|none|n a|unspecified|default)$/
1265
+
1266
+ /** What a row is about, from its own values and the records it links to. */
1267
+ export interface Scene {
1268
+ /** Is the record a piece of work (a ticket, a work order) rather than a thing or a place? */
1269
+ work: boolean
1270
+ /** "line damage", "meter fault"; for a record that is not work, what it is ("transformer"). */
1271
+ issue: string
1272
+ vocabulary: Vocabulary
1273
+ place?: string
1274
+ /** The asset it is about, as its kind ("transformer") and its name ("Transformer A-104"). */
1275
+ thing?: string
1276
+ thingName?: string
1277
+ /** Who reported it, as their type: "Customer". */
1278
+ reporter?: string
1279
+ /** Whose it is, by name: a customer, a company. */
1280
+ owner?: string
1281
+ }
1282
+
1283
+ /** Equipment a detail or a fix can name: one that names another kind than the record's is not said of it. */
1284
+ const THING_WORDS = ['pole', 'line', 'cable', 'wire', 'meter', 'valve', 'regulator', 'pipe', 'cabinet', 'transformer', 'pump', 'breaker', 'fuse', 'switchgear', 'span']
1285
+ const THING_ALIASES: Record<string, string[]> = { line: ['line', 'cable', 'wire', 'pole', 'span'], cable: ['cable', 'line', 'wire', 'pole', 'span'], wire: ['wire', 'line', 'cable'], pipe: ['pipe', 'valve'], meter: ['meter'] }
1286
+
1287
+ /**
1288
+ * The texts of a vocabulary that fit the record's thing (its asset's kind):
1289
+ * "sparks seen near the pole" is not said of a meter. A text naming no
1290
+ * equipment, or `{thing}`, fits any. None fitting, the general ones.
1291
+ */
1292
+ export function forThing(texts: readonly string[], thing: string | undefined, fallback: readonly string[]): readonly string[] {
1293
+ if (!thing) return texts
1294
+ const kind = thing.toLowerCase()
1295
+ const own = new Set(Object.entries(THING_ALIASES).flatMap(([k, words]) => (new RegExp(`\\b${k}\\b`).test(kind) ? words : [])).concat(kind.split(/\s+/)))
1296
+ const fits = texts.filter((t) => THING_WORDS.every((w) => own.has(w) || !new RegExp(`\\b${w}s?\\b`, 'i').test(t.replace(/\{thing\}/g, ''))))
1297
+ return fits.length ? fits : fallback
1298
+ }
1299
+
1300
+ /** A line of free text of a kind, for a row's scene; `about` is the property's name, which may say what a reason is for. */
1301
+ export function proseFor(kind: Prose, scene: Scene, draw: () => number, about = ''): string {
1302
+ const at = scene.place ? ` at ${scene.place}` : scene.thingName ? ` on ${scene.thingName}` : ''
1303
+ const fill = (text: string) => text.replace(/\{thing\}/g, scene.thing ?? scene.vocabulary.part).replace(/\{reporter\}/g, (scene.reporter ?? 'requester').toLowerCase())
1304
+ switch (kind) {
1305
+ case 'title':
1306
+ return `${title(scene.issue)}${at}`
1307
+ case 'description': {
1308
+ if (!scene.work) return `${title(scene.issue)}${at}${scene.owner ? ` for ${scene.owner}` : ''}.`
1309
+ const detail = fill(pickWith(draw, forThing(scene.vocabulary.details, scene.thing, GENERAL.details)))
1310
+ const what = `${scene.issue}${at}${scene.place && scene.thingName ? ` (${scene.thingName})` : ''}`
1311
+ return scene.vocabulary.planned ? `${title(what)}: ${detail}.` : `${scene.reporter ?? 'Caller'} reports ${what}; ${detail}.`
1312
+ }
1313
+ case 'resolution':
1314
+ return fill(pickWith(draw, forThing(scene.vocabulary.fixes, scene.thing, GENERAL.fixes)))
1315
+ case 'notes':
1316
+ return fill(pickWith(draw, FOLLOW_UPS))
1317
+ case 'reason':
1318
+ return fill(pickWith(draw, REASONS_FOR.find(([re]) => re.test(words(about)))?.[1] ?? REASONS))
1319
+ }
1320
+ }
1321
+
1322
+ // ── Readings: a unit that follows the reading's kind, a value in its range ──
1323
+
1324
+ interface MeasureUnit {
1325
+ unit: string
1326
+ aliases?: string[]
1327
+ min: number
1328
+ max: number
1329
+ digits: number
1330
+ }
1331
+
1332
+ /** What a reading measures, by the words of its kind, and the units and ranges it is read in (the first is the usual one). */
1333
+ const MEASURES: Array<{ kind: RegExp; units: MeasureUnit[] }> = [
1334
+ { kind: /\b(power factor|cos ?phi)\b/, units: [{ unit: 'PF', min: 0.8, max: 1, digits: 2 }] },
1335
+ { kind: /\b(volt|volts|voltage)\b/, units: [{ unit: 'V', min: 205, max: 245, digits: 1 }, { unit: 'kV', min: 10, max: 22, digits: 2 }, { unit: 'mV', min: 50, max: 900, digits: 0 }] },
1336
+ { kind: /\b(current|amps?|amperage)\b/, units: [{ unit: 'A', min: 0.5, max: 60, digits: 2 }, { unit: 'mA', min: 4, max: 20, digits: 1 }] },
1337
+ { kind: /\b(energy|consumption|usage)\b/, units: [{ unit: 'kWh', min: 50, max: 4000, digits: 1 }, { unit: 'Wh', min: 500, max: 50000, digits: 0 }, { unit: 'MWh', min: 0.05, max: 4, digits: 3 }] },
1338
+ { kind: /\b(power|load|demand)\b/, units: [{ unit: 'kW', min: 0.5, max: 150, digits: 2 }, { unit: 'W', min: 50, max: 5000, digits: 0 }, { unit: 'MW', min: 0.5, max: 40, digits: 2 }, { unit: '%', aliases: ['percent', 'pct'], min: 10, max: 95, digits: 1 }] },
1339
+ { kind: /\b(temperature|temp)\b/, units: [{ unit: '°C', aliases: ['C', 'degC', 'celsius'], min: 20, max: 95, digits: 1 }, { unit: '°F', aliases: ['F', 'degF', 'fahrenheit'], min: 68, max: 203, digits: 1 }, { unit: 'K', min: 293, max: 368, digits: 1 }] },
1340
+ { kind: /\bpressure\b/, units: [{ unit: 'bar', min: 0.5, max: 6, digits: 2 }, { unit: 'kPa', min: 50, max: 600, digits: 0 }, { unit: 'psi', min: 7, max: 90, digits: 1 }] },
1341
+ { kind: /\bflow\b/, units: [{ unit: 'm3/h', aliases: ['m³/h', 'm3/hr'], min: 1, max: 120, digits: 1 }, { unit: 'L/min', aliases: ['lpm'], min: 5, max: 500, digits: 1 }, { unit: 'L/s', min: 0.1, max: 30, digits: 2 }] },
1342
+ { kind: /\bhumidity\b/, units: [{ unit: '%', aliases: ['%RH', 'RH', 'percent'], min: 30, max: 90, digits: 1 }] },
1343
+ { kind: /\bfrequency\b/, units: [{ unit: 'Hz', min: 49.8, max: 50.2, digits: 2 }] },
1344
+ { kind: /\b(level|fill)\b/, units: [{ unit: '%', aliases: ['percent'], min: 5, max: 100, digits: 1 }, { unit: 'm', min: 0.2, max: 10, digits: 2 }] },
1345
+ { kind: /\bvibration\b/, units: [{ unit: 'mm/s', min: 0.5, max: 12, digits: 2 }] },
1346
+ { kind: /\b(speed|rpm|rotation)\b/, units: [{ unit: 'rpm', min: 600, max: 3600, digits: 0 }, { unit: 'km/h', min: 0, max: 120, digits: 1 }] },
1347
+ { kind: /\bph\b/, units: [{ unit: 'pH', min: 6.5, max: 8.5, digits: 2 }] },
1348
+ { kind: /\bvolume\b/, units: [{ unit: 'm3', aliases: ['m³'], min: 1, max: 500, digits: 1 }, { unit: 'L', min: 10, max: 5000, digits: 0 }] },
1349
+ { kind: /\b(weight|mass)\b/, units: [{ unit: 'kg', min: 0.5, max: 500, digits: 1 }, { unit: 't', min: 0.5, max: 40, digits: 2 }] },
1350
+ // Business measures a KPI row carries (`occupancy_pct`, `median_resolution_hours`): a share, a duration, a count, money.
1351
+ { kind: /\b(pct|percent|percentage|rate|ratio|share|occupancy|utili[sz]ation|conversion|churn|uptime|availability)\b/, units: [{ unit: '%', aliases: ['percent', 'pct', 'percentage'], min: 55, max: 98, digits: 1 }] },
1352
+ { kind: /\b(hours?|hrs)\b/, units: [{ unit: 'hours', aliases: ['h', 'hr', 'hrs', 'hour'], min: 1, max: 72, digits: 1 }] },
1353
+ { kind: /\b(minutes?|mins)\b/, units: [{ unit: 'minutes', aliases: ['min', 'mins', 'minute'], min: 5, max: 240, digits: 0 }] },
1354
+ { kind: /\b(days?)\b/, units: [{ unit: 'days', aliases: ['d', 'day'], min: 1, max: 30, digits: 0 }] },
1355
+ { kind: /\b(rent|revenue|amount|price|cost|fee|fees|balance|spend|income|sales|arrears|budget|payment|payments)\b/, units: [{ unit: '¤', min: 500, max: 50000, digits: 2 }] },
1356
+ // A count is said as one (`count`, `number of`) or as things that happened (`requests opened`), never a bare state word.
1357
+ { kind: /\b(count|counts|number of|total)\b|\b[a-z]+s (opened|closed|created|raised|resolved|logged|received|sent|made)\b/, units: [{ unit: 'count', aliases: ['#', 'n', 'number', 'items'], min: 0, max: 60, digits: 0 }] },
1358
+ ]
1359
+
1360
+ /** A currency code (`SGD`) or a money word: what the money measure (`¤`) is written in. */
1361
+ const isCurrencyUnit = (unit: string) => /^[A-Z]{3}$/.test(unit) || /^(currency|money|[$€£¥])$/i.test(unit)
1362
+
1363
+ const unitKey = (unit: string) => unit.toLowerCase().replace(/[°º\s]/g, '').replace(/³/g, '3')
1364
+
1365
+ function measureOf(kind: string): (typeof MEASURES)[number] | null {
1366
+ const text = valueWords(kind)
1367
+ return MEASURES.find((m) => m.kind.test(text)) ?? null
1368
+ }
1369
+
1370
+ function unitIn(measure: (typeof MEASURES)[number], unit: string): MeasureUnit | null {
1371
+ const key = unitKey(unit)
1372
+ const found = measure.units.find((u) => unitKey(u.unit) === key || u.aliases?.some((a) => unitKey(a) === key))
1373
+ if (found) return found
1374
+ // Money is written in a currency: `SGD` is the money measure's unit.
1375
+ const money = measure.units.find((u) => u.unit === '¤')
1376
+ return money && isCurrencyUnit(unit) ? { ...money, unit } : null
1377
+ }
1378
+
1379
+ /**
1380
+ * How a type's readings are written: its unit property, the property that
1381
+ * says what a reading is (its values a kind of measure: voltage, energy),
1382
+ * and its value. Null when the type has no unit property.
1383
+ */
1384
+ interface UnitPlan {
1385
+ unit: string
1386
+ kind: string | null
1387
+ value: string | null
1388
+ /** The unit for a kind: by position when the unit's values line up with the kinds, else the one the kind is read in. */
1389
+ unitFor: (kind: string) => string | null
1390
+ /** The range a value of that kind in that unit falls in. */
1391
+ rangeFor: (kind: string, unit: string) => MeasureUnit | null
1392
+ }
1393
+
1394
+ function unitPlanOf(properties: Property[], constraints: Map<string, Constraint>, pk: string, currency = 'USD'): UnitPlan | null {
1395
+ const isText = (p: Property) => (p.valueType ?? 'string') === 'string'
1396
+ const unitProperty = properties.find((p) => isText(p) && /^(?:(?:value|reading|measure|measurement|metric) )?(?:unit|units|uom|unit of measure|unit of measurement)$/.test(words(p.apiName)))
1397
+ if (!unitProperty) return null
1398
+ const unitValues = constraints.get(unitProperty.apiName)?.values ?? null
1399
+ const kindNamed = (p: Property) => /\b(reading|metric|type|kind|measure|measurement|parameter|quantity|channel|sensor|signal)\b/.test(words(p.apiName))
1400
+ const scored = properties
1401
+ .filter((p) => p !== unitProperty && p.apiName !== pk && isText(p) && constraints.get(p.apiName)?.values?.length)
1402
+ .map((p) => {
1403
+ const values = constraints.get(p.apiName)!.values!
1404
+ const lined = !!unitValues && unitValues.length === values.length
1405
+ return { p, values, score: values.filter((v) => measureOf(v)).length + (kindNamed(p) ? 0.5 : 0) + (lined ? 0.25 : 0), lined }
1406
+ })
1407
+ .filter((s) => s.score >= 1 || (s.lined && kindNamed(s.p)))
1408
+ .sort((a, b) => b.score - a.score)
1409
+ const kind = scored[0] ?? null
1410
+ const valueProperty = properties.find((p) => p.valueType === 'number' && /^(value|reading|measurement|measured value|reading value|level|magnitude)$|\b(value|reading)$/.test(words(p.apiName)))
1411
+ const rangeFor = (k: string, unit: string): MeasureUnit | null => {
1412
+ const measure = k ? measureOf(k) : null
1413
+ if (measure) return (unit ? unitIn(measure, unit) : null) ?? (unit ? null : measure.units[0]!)
1414
+ for (const m of MEASURES) {
1415
+ const u = unit ? unitIn(m, unit) : null
1416
+ if (u) return u
1417
+ }
1418
+ return null
1419
+ }
1420
+ return {
1421
+ unit: unitProperty.apiName,
1422
+ kind: kind?.p.apiName ?? null,
1423
+ value: valueProperty?.apiName ?? null,
1424
+ unitFor: (k) => {
1425
+ if (kind?.lined) {
1426
+ const i = kind.values.indexOf(k)
1427
+ if (i >= 0) return unitValues![i]!
1428
+ }
1429
+ const measure = measureOf(k)
1430
+ if (!measure) return null
1431
+ if (unitValues) return unitValues.find((u) => unitIn(measure, u)) ?? null
1432
+ const usual = measure.units[0]!.unit
1433
+ return usual === '¤' ? currency : usual
1434
+ },
1435
+ rangeFor,
1436
+ }
1437
+ }
1438
+
1439
+ const HOUR = 3_600_000
1440
+ const DAY = 86_400_000
1441
+
1442
+ /** A time a record must be done by: due, a deadline, an SLA or target time, expected by. */
1443
+ const DUE_BY = /\b(due|deadline|sla|target|expected|promised|eta)\b/
1444
+
1445
+ /** The share of open records with a due time that are past it. */
1446
+ const OVERDUE_SHARE = 0.28
1447
+
1448
+ const iso = (ms: number) => new Date(ms).toISOString().replace(/\.\d{3}Z$/, 'Z')
1449
+
1450
+ /** Midnight UTC of the day it runs: the default "today". */
1451
+ const startOfUtcDay = (at: Date) => new Date(Date.UTC(at.getUTCFullYear(), at.getUTCMonth(), at.getUTCDate()))
1452
+
1453
+ /**
1454
+ * A row's dates and times, in the order its names say: the first one in the
1455
+ * last 120 days, each later one after it, an update last, nothing after
1456
+ * today except a due date. Within a date or time semantic type's bounds. An
1457
+ * optional end of life (resolvedAt) stays empty while the row's status says
1458
+ * it is still open. An open record with a due time is current work: raised
1459
+ * in the last ten days and due around today, some of it overdue by hours or
1460
+ * days, the rest due within the week. A closed one was due days after it
1461
+ * started.
1462
+ */
1463
+ function fillTimes(faker: Faker, row: Record<string, unknown>, times: Property[], constraints: Map<string, Constraint>, today: Date, open: boolean, notStarted = false): void {
1464
+ const now = today.getTime()
1465
+ const sorted = [...times].sort((a, b) => lifecycleRank(a.apiName) - lifecycleRank(b.apiName))
1466
+ const dueSoon = open && sorted.some((p) => lifecycleRank(p.apiName) === 2 && DUE_BY.test(words(p.apiName)))
1467
+ let at = dueSoon ? now - faker.number.int({ min: 2 * 60, max: 10 * 24 * 60 }) * 60_000 : faker.date.recent({ days: 120, refDate: today }).getTime()
1468
+ const start = at
1469
+ let latest = at
1470
+ // A time that would fall after today falls between the last one and today instead, not all on today.
1471
+ const notAfter = (value: number) => (value <= now ? value : latest + Math.floor((now - latest) * faker.number.float({ min: 0.2, max: 0.95 })))
1472
+ let first = true
1473
+ for (const p of sorted) {
1474
+ const rank = lifecycleRank(p.apiName)
1475
+ const c = constraints.get(p.apiName) ?? {}
1476
+ if (rank === -1) {
1477
+ const birth = faker.date.birthdate({ min: 18, max: 80, mode: 'age', refDate: today })
1478
+ row[p.apiName] = p.valueType === 'date' ? birth.toISOString().slice(0, 10) : iso(birth.getTime())
1479
+ continue
1480
+ }
1481
+ // Installed, bought, founded, hired: years before the record's own life, within a semantic type's bounds.
1482
+ const back = yearsBack(p)
1483
+ if (back) {
1484
+ let value = now - Math.round(faker.number.float({ min: back.min, max: back.max }) * 365.25 * DAY)
1485
+ if (c.min !== undefined && value < c.min) value = c.min
1486
+ if (c.max !== undefined && value > c.max) value = c.max
1487
+ row[p.apiName] = p.valueType === 'date' ? new Date(value).toISOString().slice(0, 10) : iso(value)
1488
+ continue
1489
+ }
1490
+ if (rank >= ENDS_LIFE && rank < 4 && open && optional(p)) {
1491
+ row[p.apiName] = ''
1492
+ continue
1493
+ }
1494
+ // Nobody has taken it on yet: no optional time it was assigned, accepted or scheduled.
1495
+ if (rank === 1 && notStarted && optional(p)) {
1496
+ row[p.apiName] = ''
1497
+ continue
1498
+ }
1499
+ let value: number
1500
+ if (rank === 2 && dueSoon && DUE_BY.test(words(p.apiName))) {
1501
+ // Around now: overdue by an hour to three days, or due in half a day to a week; never before it started.
1502
+ const overdue = faker.datatype.boolean({ probability: OVERDUE_SHARE })
1503
+ value = Math.max(start + HOUR, overdue ? now - faker.number.int({ min: 1, max: 72 }) * HOUR : now + faker.number.int({ min: 12, max: 7 * 24 }) * HOUR)
1504
+ } else if (rank === 2) value = at + faker.number.int({ min: 1, max: 14 }) * DAY // a due date may be ahead
1505
+ else if (rank === 4) value = notAfter(latest + faker.number.int({ min: 5, max: 6 * 60 }) * 60_000)
1506
+ else if (first) value = at
1507
+ else value = notAfter(latest + faker.number.int({ min: 20, max: 72 * 60 }) * 60_000)
1508
+ first = false
1509
+ // A date or time semantic type's bounds, when it has them.
1510
+ if (c.min !== undefined && value < c.min) value = c.min
1511
+ if (c.max !== undefined && value > c.max) value = c.max
1512
+ if (rank !== 2) latest = Math.max(latest, value)
1513
+ at = rank === 2 ? at : value
1514
+ row[p.apiName] = p.valueType === 'date' ? new Date(value).toISOString().slice(0, 10) : iso(value)
1515
+ }
231
1516
  }
232
1517
 
233
1518
  function csvCell(value: unknown): string {
@@ -236,85 +1521,777 @@ function csvCell(value: unknown): string {
236
1521
  return /[",\n\r]/.test(text) ? `"${text.replace(/"/g, '""')}"` : text
237
1522
  }
238
1523
 
1524
+ /** A date or timestamp bound written as a date string, as epoch milliseconds. */
1525
+ function timeBound(c: Constraint, rule: SemanticType['rule'] | undefined): Constraint {
1526
+ const min = typeof rule?.minimum === 'string' ? Date.parse(rule.minimum) : NaN
1527
+ const max = typeof rule?.maximum === 'string' ? Date.parse(rule.maximum) : NaN
1528
+ return { ...c, ...(Number.isFinite(min) ? { min } : {}), ...(Number.isFinite(max) ? { max } : {}) }
1529
+ }
1530
+
1531
+ /** The properties a type's dataset holds a column for: not a calculated field, and in its mapping when it has one. */
1532
+ export function storedProperties(type: ObjectType): Property[] {
1533
+ const mapping = type.backing?.mapping
1534
+ const mapped = mapping && Object.keys(mapping).length ? new Set(Object.keys(mapping)) : null
1535
+ return (type.properties ?? []).filter((p) => !p.calculation && (!mapped || mapped.has(p.apiName)))
1536
+ }
1537
+
1538
+ type Row = Record<string, unknown>
1539
+
1540
+ const filled = (v: unknown) => v !== '' && v !== undefined && v !== null
1541
+
1542
+ /** A latitude and a longitude property of a type, by name, when it has both. */
1543
+ function coordinatesOf(properties: Property[]): { lat: string; lon: string } | null {
1544
+ const lat = properties.find((p) => p.valueType === 'number' && /^lat(itude)?$/.test(words(p.apiName).replace(/\s+/g, '')))
1545
+ const lon = properties.find((p) => p.valueType === 'number' && /^(lon|lng|long|longitude)$/.test(words(p.apiName).replace(/\s+/g, '')))
1546
+ return lat && lon ? { lat: lat.apiName, lon: lon.apiName } : null
1547
+ }
1548
+
1549
+ /** `ada.lovelace@example.com` from a person's name. */
1550
+ function emailFrom(name: string): string | null {
1551
+ const local = name.normalize('NFKD').replace(/[̀-ͯ]/g, '').toLowerCase().replace(/[^a-z0-9]+/g, '.').replace(/^\.+|\.+$/g, '')
1552
+ return local ? `${local}@example.com` : null
1553
+ }
1554
+
1555
+ /** How many more times a scenario's field functions may run for one row, while a value one read changes. */
1556
+ const SCENARIO_PASSES = 3
1557
+
239
1558
  /**
240
1559
  * Generate a CSV and a dataset definition for every object type in the
241
- * project's Blueprint tree. Returns what was written.
1560
+ * project's Blueprint tree, a media-backed one excepted. Returns what was
1561
+ * written.
1562
+ */
1563
+ /**
1564
+ * The datasets `generateSyntheticData` writes rows for: each object type's
1565
+ * but a media-backed one's (it reads a Media Set, never a sample dataset).
242
1566
  */
1567
+ export function sampleDatasetNames(root: string): Set<string> {
1568
+ return new Set(readTree<ObjectType>(join(root, 'blueprint', 'object-types'))
1569
+ .map((f) => f.doc)
1570
+ .filter((t) => t?.apiName && !objectTypeFit(t as unknown as Record<string, unknown>, () => true).leftOff && !objectTypeFit(t as unknown as Record<string, unknown>, () => true).mediaStub)
1571
+ .map((t) => backingOf(t).dataset))
1572
+ }
1573
+
243
1574
  export function generateSyntheticData(root: string, opts: SyntheticOptions = {}): GeneratedDataset[] {
244
- const types = readTree<ObjectType>(join(root, 'blueprint', 'object-types')).map((f) => f.doc).filter((t) => t?.apiName)
245
- const links = readTree<LinkType>(join(root, 'blueprint', 'link-types')).map((f) => f.doc).filter((l) => l?.apiName)
1575
+ const dir = (kind: string) => join(root, 'blueprint', kind)
1576
+ // A type over the platform's media files reads a Media Set, never a file dataset: the local runtime
1577
+ // stubs it over an empty Media Set or leaves it off (`objectTypeFit`), so it gets no sample rows; a
1578
+ // dataset of its name would take the name the stand-in Media Set's catalog needs.
1579
+ const allTypes = readTree<ObjectType>(dir('object-types')).map((f) => f.doc).filter((t) => t?.apiName)
1580
+ const fitOf = (t: ObjectType) => objectTypeFit(t as unknown as Record<string, unknown>, () => true)
1581
+ const types = allTypes.filter((t) => {
1582
+ const fit = fitOf(t)
1583
+ return !fit.leftOff && !fit.mediaStub
1584
+ })
1585
+ // The media-backed types the local runtime stubs over a stand-in Media Set, which `local dev` fills with placeholders.
1586
+ const mediaTypes = allTypes.filter((t) => fitOf(t).mediaStub && !fitOf(t).leftOff)
1587
+ const links = readTree<LinkType>(dir('link-types')).map((f) => f.doc).filter((l) => l?.apiName)
246
1588
  if (!types.length) return []
247
- const faker = new Faker({ locale: [en, base] })
1589
+ /** The conditional fields whose condition names nothing their record has (`GeneratedDataset.unresolved`). */
1590
+ const unresolved: Array<{ objectType: string; property: string; condition: string }> = []
1591
+ const semantic = new Map(readTree<SemanticType>(dir('semantic-types')).map((f) => [f.doc?.apiName, f.doc] as const).filter(([n]) => !!n) as Array<[string, SemanticType]>)
1592
+ const shared = new Map(readTree<SharedField>(dir('shared-fields')).map((f) => [f.doc?.apiName, f.doc] as const).filter(([n]) => !!n) as Array<[string, SharedField]>)
1593
+ const fromActions = actionEnums(readTree<ActionFile>(dir('actions')).map((f) => f.doc).filter((a) => a?.apiName))
1594
+ const fromMetrics = metricHints(readTree<MetricFile>(dir('metrics')).map((f) => f.doc).filter((m) => m?.apiName))
1595
+ const region = opts.region ?? projectRegion(root)
1596
+ const faker = new Faker({ locale: region.locale })
248
1597
  faker.seed(opts.seed ?? 7)
249
- const today = opts.today ?? new Date('2026-10-01T00:00:00Z')
250
- const refs = referencesFrom(links)
1598
+ const today = opts.today ?? startOfUtcDay(new Date())
1599
+ const typesByName = new Map(types.map((t) => [t.apiName, t] as const))
1600
+ const primaryKeys = new Map(types.map((t) => [t.apiName, t.primaryKey ?? t.properties?.[0]?.apiName ?? 'id'] as const))
1601
+ const entities = new Map(types.map((t) => [t.apiName, entityOf(t)] as const))
1602
+ const refs = referencesFrom(links, primaryKeys)
1603
+ // A link the name says (`propertyId`, `assignedTo`) when no link type declares it: the property holds that type's keys.
1604
+ inferReferences(types, refs, primaryKeys, (t, p) => !!constraintFor(t, p, semantic, shared, fromActions, fromMetrics).values?.length)
251
1605
  const keys = new Map<string, unknown[]>()
252
- const out: GeneratedDataset[] = []
1606
+ // A link from a record to a media-backed type's file (`Amenity.imageUrl` → the photo's `path`): the record
1607
+ // holds the path, drawn from the placeholders' paths, so the link resolves against the files `local dev` uploads.
1608
+ for (const media of mediaTypes) {
1609
+ const isPath = (property: string | undefined) => !!property && (property === 'path' || backingOf(media).column(property) === 'path')
1610
+ for (const link of links) {
1611
+ if (link.cardinality === 'many_to_many') continue
1612
+ const [mine, other] = link.from?.objectType === media.apiName ? [link.from, link.to] : link.to?.objectType === media.apiName ? [link.to, link.from] : [null, null]
1613
+ if (!mine?.property || !isPath(mine.property) || !other?.objectType || !other.property || !typesByName.has(other.objectType) || primaryKeys.get(other.objectType) === other.property) continue
1614
+ // Whichever end the link names first, the non-media end holds the reference.
1615
+ refs.get(media.apiName)?.delete(mine.property)
1616
+ if (!refs.has(other.objectType)) refs.set(other.objectType, new Map())
1617
+ refs.get(other.objectType)!.set(other.property, { objectType: media.apiName, property: mine.property })
1618
+ keys.set(`${media.apiName}.${mine.property}`, defaultPlaceholderPaths(media.apiName))
1619
+ }
1620
+ }
1621
+ // Each generated type's rows, and lookups into them: a link that agrees with the row's other links.
1622
+ const rowsOf = new Map<string, Row[]>()
1623
+ const indexes = new Map<string, Map<unknown, Row[]>>()
1624
+ const rowsWhere = (type: string, property: string, value: unknown): Row[] => {
1625
+ const key = `${type}.${property}`
1626
+ // A type not generated yet (a cycle) has no rows to look in; nothing is kept for it.
1627
+ if (!rowsOf.has(type)) return []
1628
+ let index = indexes.get(key)
1629
+ if (!index) {
1630
+ index = new Map()
1631
+ for (const r of rowsOf.get(type) ?? []) {
1632
+ const v = r[property]
1633
+ if (!filled(v)) continue
1634
+ if (!index.has(v)) index.set(v, [])
1635
+ index.get(v)!.push(r)
1636
+ }
1637
+ indexes.set(key, index)
1638
+ }
1639
+ return index.get(value) ?? []
1640
+ }
253
1641
  const baseRows = Math.max(1, Math.min(opts.scenario?.rows ?? opts.rows ?? 200, 100_000))
254
1642
  mkdirSync(join(root, 'datasets'), { recursive: true })
255
1643
 
1644
+ /**
1645
+ * Where a record leads through its links and theirs, by type: a work
1646
+ * order's asset leads to its site, and the site to its customer. The
1647
+ * nearest record of each type wins (a breadth-first walk); the record
1648
+ * itself is not included. Only types generated so far are walked, and
1649
+ * their rows are final, so each record's walk is kept.
1650
+ */
1651
+ let reached = new WeakMap<Row, Map<string, Row>>()
1652
+ const reachedFrom = (typeName: string, start: Row): Map<string, Row> => {
1653
+ const known = reached.get(start)
1654
+ if (known) return known
1655
+ const out = new Map<string, Row>()
1656
+ const seen = new Set<Row>([start])
1657
+ const queue: Array<[string, Row]> = [[typeName, start]]
1658
+ while (queue.length) {
1659
+ const [t, r] = queue.shift()!
1660
+ for (const [q, via] of refs.get(t) ?? []) {
1661
+ if (via.inferred || !filled(r[q])) continue
1662
+ const next = rowsWhere(via.objectType, via.property, r[q])[0]
1663
+ if (!next || seen.has(next)) continue
1664
+ seen.add(next)
1665
+ if (!out.has(via.objectType)) out.set(via.objectType, next)
1666
+ queue.push([via.objectType, next])
1667
+ }
1668
+ }
1669
+ reached.set(start, out)
1670
+ return out
1671
+ }
1672
+ /** The types a type's records can lead to through links, at the level of the Blueprint. */
1673
+ const typeReach = new Map<string, Set<string>>()
1674
+ const reachOf = (typeName: string): Set<string> => {
1675
+ const known = typeReach.get(typeName)
1676
+ if (known) return known
1677
+ const out = new Set<string>()
1678
+ const queue = [typeName]
1679
+ while (queue.length) {
1680
+ for (const via of refs.get(queue.shift()!)?.values() ?? []) {
1681
+ if (via.inferred || out.has(via.objectType)) continue
1682
+ out.add(via.objectType)
1683
+ queue.push(via.objectType)
1684
+ }
1685
+ }
1686
+ typeReach.set(typeName, out)
1687
+ return out
1688
+ }
1689
+ /** Per (type, reached type): the type's records by the record of the other type they lead to; `null` holds those that lead to none. */
1690
+ const byReached = new Map<string, Map<Row | null, Row[]>>()
1691
+ const recordsLeadingTo = (typeName: string, reachedType: string): Map<Row | null, Row[]> => {
1692
+ const key = `${typeName}>${reachedType}`
1693
+ let index = byReached.get(key)
1694
+ if (!index) {
1695
+ index = new Map()
1696
+ for (const r of rowsOf.get(typeName) ?? []) {
1697
+ const to = reachedFrom(typeName, r).get(reachedType) ?? null
1698
+ if (!index.has(to)) index.set(to, [])
1699
+ index.get(to)!.push(r)
1700
+ }
1701
+ byReached.set(key, index)
1702
+ }
1703
+ return index
1704
+ }
1705
+
1706
+ /**
1707
+ * The keys a link property may take in this row: those of the records that
1708
+ * agree with the row's other links (`among`, else every other filled one).
1709
+ * Two linked records agree when neither leads to a record of the other's
1710
+ * type but that one, along any path: a work order at a site gets an asset
1711
+ * at that site and the site's customer, and the other way round. Two links
1712
+ * to the same type never constrain each other (a reporter and an assignee).
1713
+ * When no record leads to the one a link fixes, a record that leads to no
1714
+ * record of that type at all still agrees.
1715
+ */
1716
+ const consistentPool = (type: ObjectType, row: Row, property: string, ref: Reference, among?: ReadonlySet<string>): unknown[] => {
1717
+ const all = keys.get(`${ref.objectType}.${ref.property}`) ?? []
1718
+ const targets = rowsOf.get(ref.objectType)
1719
+ if (!targets) return all
1720
+ let candidates: Row[] | null = null
1721
+ const narrow = (to: Row[]) => {
1722
+ const keep = new Set(to)
1723
+ candidates = candidates ? candidates.filter((r) => keep.has(r)) : to
1724
+ }
1725
+ for (const [other, otherRef] of refs.get(type.apiName) ?? []) {
1726
+ if (other === property || otherRef.inferred || (among && !among.has(other)) || !filled(row[other]) || otherRef.objectType === ref.objectType) continue
1727
+ const fixed = rowsWhere(otherRef.objectType, otherRef.property, row[other])[0]
1728
+ if (!fixed) continue
1729
+ // The other link's record leads to a record of this type: this one is that.
1730
+ const led = reachedFrom(otherRef.objectType, fixed).get(ref.objectType)
1731
+ if (led) narrow([led])
1732
+ // This type's records lead to the other link's type: those that lead to that record.
1733
+ if (reachOf(ref.objectType).has(otherRef.objectType)) {
1734
+ const index = recordsLeadingTo(ref.objectType, otherRef.objectType)
1735
+ const exact = index.get(fixed) ?? []
1736
+ narrow(exact.length ? exact : (index.get(null) ?? []))
1737
+ }
1738
+ if (candidates !== null && (candidates as Row[]).length === 0) break
1739
+ }
1740
+ if (candidates === null) return all
1741
+ return (candidates as Row[]).map((r) => r[ref.property]).filter(filled)
1742
+ }
1743
+
1744
+ /**
1745
+ * The order a row's links are chosen in: a link whose record leads to the
1746
+ * records of more of the row's other links first, so those follow from it
1747
+ * (a work order's asset, then its site, then its customer) instead of
1748
+ * being picked apart and found not to agree.
1749
+ */
1750
+ const linkOrder = (type: ObjectType): string[] => {
1751
+ const own = [...(refs.get(type.apiName) ?? new Map<string, Reference>())]
1752
+ const score = (ref: Reference) => own.filter(([, o]) => o.objectType !== ref.objectType && reachOf(ref.objectType).has(o.objectType)).length
1753
+ return own.map(([prop, ref], i) => ({ prop, s: score(ref), i })).sort((a, b) => b.s - a.s || a.i - b.i).map((x) => x.prop)
1754
+ }
1755
+
1756
+ /**
1757
+ * Shared fields along links: by type, each shared field one of its
1758
+ * properties implements (other than its key), and that property.
1759
+ */
1760
+ const sharedFieldsOf = new Map(types.map((t) => [t.apiName, new Map((t.properties ?? []).flatMap((p) => (p.sharedField && p.apiName !== primaryKeys.get(t.apiName) && !p.calculation ? [[p.sharedField, p.apiName] as const] : [])))] as const))
1761
+ /**
1762
+ * The value of a shared field on the nearest record a row links to that
1763
+ * implements it too: a work order's `serviceRegion` is its site's. Walked
1764
+ * along declared and inferred links, breadth first (the row's own links in
1765
+ * `order`), a few links deep; undefined when no linked record has a value.
1766
+ */
1767
+ const linkedSharedValue = (typeName: string, row: Row, field: string, order: readonly string[]): unknown => {
1768
+ const seen = new Set<Row>([row])
1769
+ let frontier: Array<[string, Row]> = [[typeName, row]]
1770
+ for (let depth = 0; depth < 4 && frontier.length; depth++) {
1771
+ const next: Array<[string, Row]> = []
1772
+ for (const [t, r] of frontier) {
1773
+ const own = refs.get(t) ?? new Map<string, Reference>()
1774
+ for (const q of t === typeName && r === row ? order : [...own.keys()]) {
1775
+ const via = own.get(q)
1776
+ if (!via || !filled(r[q])) continue
1777
+ const found = rowsWhere(via.objectType, via.property, r[q])[0]
1778
+ if (!found || seen.has(found)) continue
1779
+ seen.add(found)
1780
+ const holder = sharedFieldsOf.get(via.objectType)?.get(field)
1781
+ if (holder && filled(found[holder])) return found[holder]
1782
+ next.push([via.objectType, found])
1783
+ }
1784
+ }
1785
+ frontier = next
1786
+ }
1787
+ return undefined
1788
+ }
1789
+
1790
+ /** What a field function may read of the other types' rows. */
1791
+ const frozen = new Map<string, ReadonlyArray<Readonly<Row>>>()
1792
+ const scenarioContext = (own: string): ScenarioContext => ({
1793
+ rowsOf: (objectType) => {
1794
+ if (objectType === own || !rowsOf.has(objectType)) return []
1795
+ let rows = frozen.get(objectType)
1796
+ if (!rows) {
1797
+ rows = Object.freeze(rowsOf.get(objectType)!.map((r) => Object.freeze({ ...r })))
1798
+ frozen.set(objectType, rows)
1799
+ }
1800
+ return rows
1801
+ },
1802
+ lookup: (objectType, value, property) => {
1803
+ if (objectType === own || !rowsOf.has(objectType)) return undefined
1804
+ const found = rowsWhere(objectType, property ?? primaryKeys.get(objectType) ?? 'id', value)[0]
1805
+ return found ? Object.freeze({ ...found }) : undefined
1806
+ },
1807
+ })
1808
+
1809
+ const generated: Array<{ type: ObjectType; rows: Row[]; kept?: true }> = []
256
1810
  for (const type of dependencyOrder(types, refs)) {
1811
+ // A dataset that is not sample data is never rewritten: its own rows stand, and other types link to them.
1812
+ const { dataset: ownDataset, column: ownColumn } = backingOf(type)
1813
+ if (!isSampleDataset(root, ownDataset)) {
1814
+ const rows = keptRows(root, ownDataset, storedProperties(type), ownColumn)
1815
+ for (const p of type.properties ?? []) keys.set(`${type.apiName}.${p.apiName}`, rows.map((r) => r[p.apiName]).filter(filled))
1816
+ rowsOf.set(type.apiName, rows)
1817
+ reached = new WeakMap()
1818
+ byReached.clear()
1819
+ indexes.clear()
1820
+ generated.push({ type, rows, kept: true })
1821
+ continue
1822
+ }
257
1823
  const custom = opts.scenario?.types?.[type.apiName]
258
1824
  // Types others point at are the fewer ones (customers, products); the rest scale with them.
259
1825
  const referenced = [...refs.values()].some((m) => [...m.values()].some((r) => r.objectType === type.apiName))
260
- const count = custom?.rows ?? (referenced ? Math.max(5, Math.round(baseRows / 2)) : baseRows)
261
- const properties = type.properties ?? []
262
- const pk = type.primaryKey ?? properties[0]?.apiName ?? 'id'
263
- const { dataset, column } = backingOf(type)
1826
+ const wanted = custom?.rows ?? (referenced ? Math.max(5, Math.round(baseRows / 2)) : baseRows)
1827
+ // A calculated field is the Blueprint's to compute: no value, no column.
1828
+ const properties = (type.properties ?? []).filter((p) => !p.calculation)
1829
+ const pk = primaryKeys.get(type.apiName)!
264
1830
  const prefix = keyPrefix(type.apiName)
265
1831
  const ownRefs = refs.get(type.apiName) ?? new Map<string, Reference>()
266
- const rows: Array<Record<string, unknown>> = []
267
- for (let i = 0; i < count; i++) {
268
- const row: Record<string, unknown> = {}
269
- const state = { lastTime: null as number | null, today }
270
- for (const p of properties) {
271
- const fn = custom?.fields?.[p.apiName]
272
- if (fn) {
273
- row[p.apiName] = fn(row, i)
1832
+ // A key that is also another type's key (one transcript per service request): each row takes one of
1833
+ // that type's records, each once, so the link resolves and the key stays unique; no more rows than it has.
1834
+ const keyRef = ownRefs.get(pk)
1835
+ const keyPool = keyRef ? [...new Set(keys.get(`${keyRef.objectType}.${keyRef.property}`) ?? [])] : null
1836
+ const count = keyPool?.length ? Math.min(wanted, keyPool.length) : wanted
1837
+ const usedKeys = new Set<unknown>()
1838
+ const constraints = new Map(
1839
+ properties.map((p) => {
1840
+ const c = constraintFor(type, p, semantic, shared, fromActions, fromMetrics)
1841
+ const st = semantic.get(p.semanticType ?? '') ?? semantic.get((p.sharedField && shared.get(p.sharedField)?.semanticType) || '')
1842
+ return [p.apiName, p.valueType === 'date' || p.valueType === 'timestamp' ? timeBound({}, st?.rule) : c] as const
1843
+ }),
1844
+ )
1845
+ const times = properties.filter((p) => p.valueType === 'date' || p.valueType === 'timestamp')
1846
+ const statusProperty = properties.find((p) => /^(status|state|stage)$/i.test(p.apiName) || /(Status|State)$/.test(p.apiName))
1847
+ const phases = statusProperty?.apiName && constraints.get(statusProperty.apiName)?.values ? statePhases(constraints.get(statusProperty.apiName)!.values!) : null
1848
+ const coordinates = coordinatesOf(properties)
1849
+ const personType = entities.get(type.apiName) === 'person'
1850
+ const nameProperty = properties.find((p) => /^(name|full name|display name)$/.test(words(p.apiName)))
1851
+ const isTime = (p: Property) => p.valueType === 'date' || p.valueType === 'timestamp'
1852
+ const seed = opts.seed ?? 7
1853
+ const entity = entities.get(type.apiName) ?? null
1854
+ // Free text written from the rest of the row: a property with listed values keeps to them.
1855
+ const proseKinds = new Map(properties.flatMap((p) => {
1856
+ const kind = constraints.get(p.apiName)?.values?.length || p.apiName === pk || ownRefs.has(p.apiName) ? null : proseKind(p)
1857
+ return kind ? [[p.apiName, kind] as const] : []
1858
+ }))
1859
+ const unitPlan = unitPlanOf(properties, constraints, pk, region.currency)
1860
+ // The pair of times of day a record opens and closes at (`openTime`, `closeTime`), when it has both.
1861
+ const openingTimes = {
1862
+ open: properties.find((p) => !constraints.get(p.apiName)?.values && timeOfDayRole(p) === 'open'),
1863
+ close: properties.find((p) => !constraints.get(p.apiName)?.values && timeOfDayRole(p) === 'close'),
1864
+ }
1865
+ const links = linkOrder(type)
1866
+ // The properties that implement a shared field another linked type implements too (not a link or the key).
1867
+ const sharedHere = properties.filter((p) => p.sharedField && p.apiName !== pk && !ownRefs.has(p.apiName))
1868
+ // A field its description says is empty unless something holds ("empty unless escalated"). A condition
1869
+ // that names nothing the row has never holds: "empty unless X" stays empty, and the run says so.
1870
+ const conditions = new Map(properties.flatMap((p) => {
1871
+ if (!optional(p) || p.apiName === pk || p.apiName === statusProperty?.apiName || isTime(p) || startsWork(p, ownRefs.get(p.apiName), entities) || endsLife(p)) return []
1872
+ const c = filledWhen(p, properties, statusProperty, phases, (name) => constraints.get(name)?.values)
1873
+ if (c) return [[p.apiName, c] as const]
1874
+ const said = conditionOf(p.description)
1875
+ // "Empty when X", X never holding: the field is set as any other.
1876
+ if (!said || said.emptyWhen) return []
1877
+ unresolved.push({ objectType: type.apiName, property: p.apiName, condition: said.text })
1878
+ return [[p.apiName, () => false] as const]
1879
+ }))
1880
+ // The value that says what a record is about: its category, its fault, its kind.
1881
+ const topic = properties.find((p) => p !== statusProperty && p.apiName !== pk && p.apiName !== unitPlan?.unit && (p.valueType ?? 'string') === 'string' && constraints.get(p.apiName)?.values?.length && /\b(category|type|kind|issue|problem|fault|topic|reason|subject)\b/.test(words(p.apiName)) && !/\b(priority|severity)\b/.test(words(p.apiName)))
1882
+ const work = !!statusProperty || /\b(orders?|tickets?|cases?|incidents?|requests?|issues?|jobs?|tasks?|complaints?|claims?|faults?|calls?|visits?|reports?|enquir(y|ies)|inquir(y|ies)|defects?|outages?)\b/.test(words(type.apiName))
1883
+ const kindOf = (t: ObjectType, r: Row): string | undefined => {
1884
+ const k = (t.properties ?? []).find((p) => p.apiName !== primaryKeys.get(t.apiName) && (p.valueType ?? 'string') === 'string' && /\b(type|kind|category|class|model)$/.test(words(p.apiName)) && typeof r[p.apiName] === 'string' && filled(r[p.apiName]))
1885
+ return k ? valueWords(String(r[k.apiName])) : undefined
1886
+ }
1887
+ const labelOf = (t: ObjectType, r: Row): string | undefined => {
1888
+ const props = t.properties ?? []
1889
+ const named = (t.title && t.title !== primaryKeys.get(t.apiName) ? props.find((p) => p.apiName === t.title) : undefined) ?? props.find((p) => /^(name|full name|display name|title|label)$/.test(words(p.apiName))) ?? props.find((p) => /\b(name|address)$/.test(words(p.apiName)))
1890
+ const v = named ? r[named.apiName] : undefined
1891
+ return typeof v === 'string' && v ? v : undefined
1892
+ }
1893
+ /** What the row is about: its category, the place, asset and person it links to. */
1894
+ const sceneOf = (row: Row, i: number): Scene => {
1895
+ const scene: Partial<Scene> = {}
1896
+ const linked: Array<[Reference, Row]> = []
1897
+ for (const [prop, ref] of ownRefs) {
1898
+ const target = typesByName.get(ref.objectType)
1899
+ const r = filled(row[prop]) ? rowsWhere(ref.objectType, ref.property, row[prop])[0] : undefined
1900
+ if (!target || !r) continue
1901
+ linked.push([ref, r])
1902
+ const kind = entities.get(ref.objectType)
1903
+ const p = properties.find((x) => x.apiName === prop)
1904
+ if (kind === 'place') scene.place ??= labelOf(target, r)
1905
+ else if (kind === 'thing' && !scene.thing) ((scene.thing = kindOf(target, r) ?? typeWords(target).toLowerCase()), (scene.thingName = labelOf(target, r)))
1906
+ else if (kind === 'person' && p && !startsWork(p, ref, entities) && !scene.reporter) ((scene.reporter = typeWords(target)), (scene.owner ??= labelOf(target, r)))
1907
+ else if (kind === 'organization') scene.owner ??= labelOf(target, r)
1908
+ }
1909
+ // Through a linked record: a work order's site through its asset.
1910
+ for (const [ref, r] of linked) {
1911
+ if (scene.place) break
1912
+ for (const [q, via] of refs.get(ref.objectType) ?? []) {
1913
+ const target = typesByName.get(via.objectType)
1914
+ const r2 = target && entities.get(via.objectType) === 'place' && filled(r[q]) ? rowsWhere(via.objectType, via.property, r[q])[0] : undefined
1915
+ if (target && r2) scene.place ??= labelOf(target, r2)
1916
+ }
1917
+ }
1918
+ // Who reported it, through a linked record: a work order's customer through its site.
1919
+ for (const [ref, r] of linked) {
1920
+ if (scene.reporter) break
1921
+ for (const [q, via] of refs.get(ref.objectType) ?? []) {
1922
+ const target = typesByName.get(via.objectType)
1923
+ const r2 = target && entities.get(via.objectType) === 'person' && filled(r[q]) ? rowsWhere(via.objectType, via.property, r[q])[0] : undefined
1924
+ if (target && r2 && !scene.reporter) ((scene.reporter = typeWords(target)), (scene.owner ??= labelOf(target, r2)))
1925
+ }
1926
+ }
1927
+ // The record's own address.
1928
+ if (!scene.place) {
1929
+ const own = properties.find((p) => /\b(address|street|city)\b/.test(words(p.apiName)) && typeof row[p.apiName] === 'string' && filled(row[p.apiName]))
1930
+ if (own) scene.place = String(row[own.apiName])
1931
+ }
1932
+ if (!work) {
1933
+ const self = kindOf(type, row) ?? typeWords(type).toLowerCase()
1934
+ return { ...scene, work, issue: self, vocabulary: GENERAL }
1935
+ }
1936
+ const said = topic && typeof row[topic.apiName] === 'string' ? valueWords(String(row[topic.apiName])) : ''
1937
+ const generic = !said || GENERIC_TOPIC.test(said)
1938
+ const issue = !generic ? said : scene.thing ? `${scene.thing} fault` : pickWith(drawFor(seed, `${type.apiName}.${i}.issue`), GENERAL_ISSUES)
1939
+ const vocabulary = VOCABULARIES.find((v) => v.match.test(generic ? `${issue} ${words(type.apiName)}` : said)) ?? GENERAL
1940
+ return { ...scene, work, issue, vocabulary }
1941
+ }
1942
+
1943
+ /** The row's own values, from what each property allows, its type and its name; free text comes later. */
1944
+ const baseValues = (row: Row, i: number) => {
1945
+ // Links first, in the order that lets each follow from the ones before it.
1946
+ for (const prop of links) {
1947
+ const ref = ownRefs.get(prop)!
1948
+ const p = properties.find((x) => x.apiName === prop)
1949
+ if (!p) continue
1950
+ if (prop === pk && keyPool?.length) {
1951
+ // The key: a record of the other type no row has taken, one that agrees with the row's links when one does.
1952
+ const free = (list: unknown[]) => list.filter((k) => !usedKeys.has(k))
1953
+ const choice = free(consistentPool(type, row, prop, ref))
1954
+ const v = choice.length ? faker.helpers.arrayElement(choice) : faker.helpers.arrayElement(free(keyPool).length ? free(keyPool) : keyPool)
1955
+ usedKeys.add(v)
1956
+ row[prop] = v
274
1957
  continue
275
1958
  }
1959
+ const pool = consistentPool(type, row, prop, ref)
1960
+ const any = keys.get(`${ref.objectType}.${ref.property}`) ?? []
1961
+ // A required link always points at a record (one that agrees with the row's other links
1962
+ // when there is one); an optional one usually does, and only to one that agrees.
1963
+ if (!optional(p)) row[prop] = pool.length ? faker.helpers.arrayElement(pool) : any.length ? faker.helpers.arrayElement(any) : ''
1964
+ else row[prop] = pool.length && faker.datatype.boolean({ probability: 0.85 }) ? faker.helpers.arrayElement(pool) : ''
1965
+ }
1966
+ // One point per row: its latitude and its longitude are the same place, within the radius.
1967
+ let point = coordinates ? pointIn(faker, region) : null
1968
+ // A compass region that is a shared field a linked record already holds (a site of a customer served
1969
+ // in the east): the point is drawn in that region, so the region the point decides agrees along the link.
1970
+ if (point) {
1971
+ for (const p of sharedHere) {
1972
+ const values = constraints.get(p.apiName)?.values
1973
+ if (!values || !isCompassList(values)) continue
1974
+ const want = linkedSharedValue(type.apiName, row, p.sharedField!, links)
1975
+ if (typeof want !== 'string' || !values.includes(want)) continue
1976
+ for (let tries = 0; tries < 64 && compassOf(point, region, values) !== want; tries++) point = pointIn(faker, region)
1977
+ break
1978
+ }
1979
+ }
1980
+ for (const p of properties) {
1981
+ if (isTime(p) || ownRefs.has(p.apiName)) continue
276
1982
  if (p.apiName === pk) {
277
1983
  row[p.apiName] = (p.valueType ?? 'string') === 'number' ? i + 1 : `${prefix}-${String(i + 1).padStart(5, '0')}`
278
1984
  continue
279
1985
  }
280
- const ref = ownRefs.get(p.apiName)
281
- if (ref) {
282
- const pool = keys.get(`${ref.objectType}.${ref.property}`) ?? []
283
- row[p.apiName] = pool.length && (p.required !== false || faker.datatype.boolean({ probability: 0.85 })) ? faker.helpers.arrayElement(pool) : ''
1986
+ if (point && (p.apiName === coordinates!.lat || p.apiName === coordinates!.lon)) {
1987
+ row[p.apiName] = p.apiName === coordinates!.lat ? point.lat : point.lon
284
1988
  continue
285
1989
  }
286
- // Optional values are sometimes empty, as real records are.
287
- if (p.required === false && faker.datatype.boolean({ probability: 0.2 })) {
1990
+ // Optional values are sometimes empty, as real records are; never what says which record it is.
1991
+ if (optional(p) && p.apiName !== statusProperty?.apiName && !defining(type, p) && faker.datatype.boolean({ probability: 0.2 })) {
288
1992
  row[p.apiName] = ''
289
1993
  continue
290
1994
  }
291
- row[p.apiName] = valueFor(faker, type, p, row, state)
1995
+ // Free text is written once the rest of the row is (`derive`).
1996
+ if (proseKinds.has(p.apiName)) continue
1997
+ row[p.apiName] = valueFor(faker, type, p, constraints.get(p.apiName) ?? {}, region)
1998
+ }
1999
+ }
2000
+
2001
+ /**
2002
+ * What the row's state allows (`keep` names the fields a scenario function made):
2003
+ * nobody assigned while it is new, no resolution until it ends, and one once it has.
2004
+ */
2005
+ const applyState = (row: Row, keep: (name: string) => boolean): Phase | null => {
2006
+ const phase = statusProperty ? phaseOf(String(row[statusProperty.apiName] ?? ''), phases) : null
2007
+ // Cancelled, rejected, withdrawn…: it ended, with nothing done, so no text says what was done.
2008
+ const undone = (name: string) => phase === 'terminal' && UNFINISHED_END.test(String(row[statusProperty!.apiName] ?? '')) && proseKinds.get(name) === 'resolution'
2009
+ for (const p of properties) {
2010
+ if (keep(p.apiName) || p.apiName === pk || p.apiName === statusProperty?.apiName || isTime(p) || !phase || conditions.has(p.apiName)) continue
2011
+ const ref = ownRefs.get(p.apiName)
2012
+ const later = proseKinds.has(p.apiName)
2013
+ if (optional(p) && startsWork(p, ref, entities)) {
2014
+ if (phase === 'initial') row[p.apiName] = ''
2015
+ else if (!filled(row[p.apiName])) {
2016
+ if (ref) {
2017
+ const pool = consistentPool(type, row, p.apiName, ref)
2018
+ if (pool.length) row[p.apiName] = faker.helpers.arrayElement(pool)
2019
+ } else row[p.apiName] = later ? undefined : valueFor(faker, type, p, constraints.get(p.apiName) ?? {}, region)
2020
+ }
2021
+ } else if (optional(p) && endsLife(p)) {
2022
+ if (phase !== 'terminal' || undone(p.apiName)) row[p.apiName] = ''
2023
+ else if (!filled(row[p.apiName]) && !ref) row[p.apiName] = later ? undefined : valueFor(faker, type, p, constraints.get(p.apiName) ?? {}, region)
2024
+ }
2025
+ }
2026
+ // A field its description makes conditional: empty while the condition is false, set once it holds.
2027
+ for (const [name, holds] of conditions) {
2028
+ if (keep(name)) continue
2029
+ if (!holds(row, phase) || undone(name)) row[name] = ''
2030
+ else if (!filled(row[name])) {
2031
+ const p = properties.find((x) => x.apiName === name)!
2032
+ const ref = ownRefs.get(name)
2033
+ if (ref) {
2034
+ const pool = consistentPool(type, row, name, ref)
2035
+ if (pool.length) row[name] = faker.helpers.arrayElement(pool)
2036
+ } else row[name] = proseKinds.has(name) ? undefined : valueFor(faker, type, p, constraints.get(name) ?? {}, region)
2037
+ }
2038
+ }
2039
+ return phase
2040
+ }
2041
+
2042
+ /**
2043
+ * Values made from the rest of the row: links that agree again (after a scenario
2044
+ * function), a unit and value that follow the reading's kind, an asset's name from
2045
+ * its kind, free text, a point from the coordinates, an email from a person's name.
2046
+ * Each draw is the row's own (`drawFor`), so making them again changes only what
2047
+ * the row's other values changed.
2048
+ */
2049
+ const derive = (row: Row, i: number, keep: (name: string) => boolean, relink: boolean, prose: Set<string>) => {
2050
+ const draw = (name: string) => drawFor(seed, `${type.apiName}.${i}.${name}`)
2051
+ if (relink) {
2052
+ // Against the links a function set, then each link chosen before it, in link order.
2053
+ const settled = new Set([...ownRefs.keys()].filter(keep))
2054
+ for (const prop of links) {
2055
+ // The key stays the record it took: each is taken once.
2056
+ if (keep(prop) || (prop === pk && keyPool?.length)) continue
2057
+ const ref = ownRefs.get(prop)!
2058
+ const p = properties.find((x) => x.apiName === prop)
2059
+ const pool = consistentPool(type, row, prop, ref, settled)
2060
+ settled.add(prop)
2061
+ if (filled(row[prop]) ? pool.includes(row[prop]) : optional(p) || !pool.length) continue
2062
+ // None agrees: an optional link is left empty, a required one keeps the record it has.
2063
+ if (pool.length) row[prop] = faker.helpers.arrayElement(pool)
2064
+ else if (optional(p)) row[prop] = ''
2065
+ }
2066
+ }
2067
+ if (unitPlan) {
2068
+ const kind = unitPlan.kind && typeof row[unitPlan.kind] === 'string' ? String(row[unitPlan.kind]) : ''
2069
+ const unit = kind && !keep(unitPlan.unit) ? unitPlan.unitFor(kind) : null
2070
+ if (unit) row[unitPlan.unit] = unit
2071
+ if (unitPlan.value && !keep(unitPlan.value) && filled(row[unitPlan.value])) {
2072
+ const range = unitPlan.rangeFor(kind, typeof row[unitPlan.unit] === 'string' ? String(row[unitPlan.unit]) : '')
2073
+ if (range) {
2074
+ const c = constraints.get(unitPlan.value) ?? {}
2075
+ let v = Number((range.min + draw(unitPlan.value)() * (range.max - range.min)).toFixed(range.digits))
2076
+ if (c.min !== undefined) v = Math.max(v, c.min)
2077
+ if (c.max !== undefined) v = Math.min(v, c.max)
2078
+ row[unitPlan.value] = v
2079
+ }
2080
+ }
2081
+ }
2082
+ // An asset is called by its kind: "Transformer A-104".
2083
+ if (entity === 'thing' && nameProperty && !keep(nameProperty.apiName) && !constraints.get(nameProperty.apiName)?.values && filled(row[nameProperty.apiName])) {
2084
+ const kind = kindOf(type, row)
2085
+ if (kind) {
2086
+ const d = draw(nameProperty.apiName)
2087
+ row[nameProperty.apiName] = `${title(kind)} ${String.fromCharCode(65 + Math.floor(d() * 26))}-${String(Math.floor(d() * 900) + 100)}`
2088
+ }
2089
+ }
2090
+ if (proseKinds.size) {
2091
+ let scene: Scene | null = null
2092
+ for (const [name, kind] of proseKinds) {
2093
+ if (keep(name)) continue
2094
+ const v = row[name]
2095
+ // Unwritten, or written here before from values that may have changed; an empty one stays empty.
2096
+ if (v !== undefined && !(prose.has(name) && filled(v))) continue
2097
+ scene ??= sceneOf(row, i)
2098
+ row[name] = fitLength(faker, proseFor(kind, scene, draw(name), name), constraints.get(name) ?? {})
2099
+ prose.add(name)
2100
+ }
2101
+ }
2102
+ // Opening hours hold together: both times or neither, the close after the open, and an `hours` text that says them.
2103
+ if (openingTimes.open && openingTimes.close) {
2104
+ const [o, c] = [openingTimes.open.apiName, openingTimes.close.apiName]
2105
+ if (!keep(o) && !keep(c) && (filled(row[o]) || filled(row[c]))) {
2106
+ const d = draw(`${o}.${c}`)
2107
+ if (!filled(row[o])) row[o] = timeOfDayFrom(d, 'open')
2108
+ if (!filled(row[c])) row[c] = timeOfDayFrom(d, 'close')
2109
+ }
2110
+ const hours = properties.find((p) => /^(hours|opening hours|business hours|operating hours|open hours)$/.test(words(p.apiName)) && (p.valueType ?? 'string') === 'string')
2111
+ if (hours && !keep(hours.apiName) && filled(row[o]) && filled(row[c])) row[hours.apiName] = `${row[o]}–${row[c]}`
2112
+ }
2113
+ // A point agrees with the row's own latitude and longitude, and so does a compass region (north, central…).
2114
+ const fromPoint = new Set<string>()
2115
+ if (coordinates && typeof row[coordinates.lat] === 'number' && typeof row[coordinates.lon] === 'number') {
2116
+ const at = { lat: row[coordinates.lat] as number, lon: row[coordinates.lon] as number }
2117
+ for (const p of properties) if (p.valueType === 'geopoint' && !keep(p.apiName)) row[p.apiName] = JSON.stringify(at)
2118
+ for (const p of properties) {
2119
+ const values = constraints.get(p.apiName)?.values
2120
+ if (keep(p.apiName) || !values || !filled(row[p.apiName]) || !isCompassList(values)) continue
2121
+ row[p.apiName] = compassOf(at, region, values)
2122
+ fromPoint.add(p.apiName)
2123
+ }
2124
+ }
2125
+ // A person's email reads like their name.
2126
+ if (personType && nameProperty && typeof row[nameProperty.apiName] === 'string') {
2127
+ for (const p of properties) {
2128
+ if (keep(p.apiName) || !/e ?mail/.test(words(p.apiName)) || !filled(row[p.apiName])) continue
2129
+ row[p.apiName] = emailFrom(String(row[nameProperty.apiName])) ?? row[p.apiName]
2130
+ }
2131
+ }
2132
+ // A shared field agrees along links: the value of the nearest linked record that implements it too
2133
+ // (a work order's service region is its site's). A region the row's own point decided stays: the
2134
+ // point is the record's own fact, and the records that link to it take the region from it.
2135
+ for (const p of sharedHere) {
2136
+ if (keep(p.apiName) || fromPoint.has(p.apiName) || !filled(row[p.apiName])) continue
2137
+ const v = linkedSharedValue(type.apiName, row, p.sharedField!, links)
2138
+ if (v !== undefined) row[p.apiName] = v
2139
+ }
2140
+ }
2141
+
2142
+ const openOf = (phase: Phase | null, row: Row) => (phase ? phase !== 'terminal' : statusProperty ? STILL_OPEN.test(String(row[statusProperty.apiName] ?? '')) : false)
2143
+ const nothing = () => false
2144
+ // The scenario's field functions: every other value first, then the times, as the type lists them.
2145
+ const fieldFns = custom?.fields ?? {}
2146
+ const withFn = properties.filter((p) => typeof fieldFns[p.apiName] === 'function')
2147
+ const fnOrder = [...withFn.filter((p) => !isTime(p)), ...withFn.filter(isTime)].map((p) => p.apiName)
2148
+ const hasFn = new Set(fnOrder)
2149
+ const keep = (name: string) => hasFn.has(name)
2150
+ const context = scenarioContext(type.apiName)
2151
+
2152
+ const rows: Row[] = []
2153
+ for (let i = 0; i < count; i++) {
2154
+ // Every value first, as if there were no scenario.
2155
+ const row: Row = {}
2156
+ const prose = new Set<string>()
2157
+ baseValues(row, i)
2158
+ let phase = applyState(row, nothing)
2159
+ derive(row, i, nothing, false, prose)
2160
+ let open = openOf(phase, row)
2161
+ let notStarted = phase === 'initial'
2162
+ fillTimes(faker, row, times, constraints, today, open, notStarted)
2163
+
2164
+ if (fnOrder.length) {
2165
+ // Then the scenario's functions, each seeing the whole row (its own default included).
2166
+ const defaults = new Map(fnOrder.map((name) => [name, row[name]]))
2167
+ const seen = new Map<string, Map<string, unknown>>()
2168
+ const call = (name: string) => {
2169
+ row[name] = defaults.get(name)
2170
+ const reads = new Map<string, unknown>()
2171
+ const view = new Proxy(row, {
2172
+ get: (target, key, receiver) => {
2173
+ const v = Reflect.get(target, key, receiver)
2174
+ if (typeof key === 'string' && key !== name && !reads.has(key)) reads.set(key, v)
2175
+ return v
2176
+ },
2177
+ })
2178
+ row[name] = fieldFns[name]!(view, i, context)
2179
+ seen.set(name, reads)
2180
+ }
2181
+ const stale = (name: string) => [...(seen.get(name) ?? [])].some(([k, v]) => !Object.is(row[k], v))
2182
+ for (const name of fnOrder) call(name)
2183
+ // The rules again for the fields without a function, now that the functions' values are in;
2184
+ // a function that read a value changed since is run again, until what each read is final.
2185
+ for (let pass = 0; ; pass++) {
2186
+ phase = applyState(row, keep)
2187
+ derive(row, i, keep, true, prose)
2188
+ const nextOpen = openOf(phase, row)
2189
+ if (nextOpen !== open || (phase === 'initial') !== notStarted) {
2190
+ open = nextOpen
2191
+ notStarted = phase === 'initial'
2192
+ fillTimes(faker, row, times.filter((p) => !keep(p.apiName)), constraints, today, open, notStarted)
2193
+ }
2194
+ const dirty = fnOrder.filter(stale)
2195
+ if (!dirty.length || pass === SCENARIO_PASSES) break
2196
+ for (const name of dirty) call(name)
2197
+ }
292
2198
  }
293
2199
  rows.push(row)
294
2200
  }
295
- for (const p of properties) keys.set(`${type.apiName}.${p.apiName}`, rows.map((r) => r[p.apiName]).filter((v) => v !== '' && v !== undefined))
2201
+ for (const p of properties) keys.set(`${type.apiName}.${p.apiName}`, rows.map((r) => r[p.apiName]).filter(filled))
2202
+ rowsOf.set(type.apiName, rows)
2203
+ // A walk that stopped at this type before it had rows (a cycle) is walked again.
2204
+ reached = new WeakMap()
2205
+ byReached.clear()
2206
+ generated.push({ type, rows })
2207
+ }
296
2208
 
2209
+ // A link inside a cycle (two types pointing at each other) found no records the first time: filled now.
2210
+ for (const { type, rows } of generated) {
2211
+ for (const [property, ref] of refs.get(type.apiName) ?? []) {
2212
+ const p = type.properties?.find((x) => x.apiName === property)
2213
+ const pool = keys.get(`${ref.objectType}.${ref.property}`) ?? []
2214
+ if (!p || !pool.length || opts.scenario?.types?.[type.apiName]?.fields?.[property]) continue
2215
+ for (const row of rows) if (row[property] === '' && !optional(p)) row[property] = faker.helpers.arrayElement(pool)
2216
+ }
2217
+ }
2218
+
2219
+ // An inferred link back along a declared one: the record of the other type that links to this row.
2220
+ for (const { type, rows, kept } of generated) {
2221
+ if (kept) continue
2222
+ const pk = primaryKeys.get(type.apiName)!
2223
+ for (const [property, ref] of refs.get(type.apiName) ?? []) {
2224
+ if (!ref.after || opts.scenario?.types?.[type.apiName]?.fields?.[property]) continue
2225
+ const back = [...(refs.get(ref.objectType) ?? [])].filter(([, r]) => !r.inferred && r.objectType === type.apiName && r.property === pk).map(([q]) => q)
2226
+ for (const row of rows) {
2227
+ if (filled(row[property])) continue
2228
+ const linked = back.flatMap((q) => rowsWhere(ref.objectType, q, row[pk]))
2229
+ if (linked.length) row[property] = linked[0]![ref.property]
2230
+ }
2231
+ }
2232
+ }
2233
+
2234
+ const out: GeneratedDataset[] = []
2235
+ for (const { type, rows, kept } of generated) {
2236
+ // Only the columns the dataset defines: a calculated field and a property its mapping leaves out have none.
2237
+ const properties = storedProperties(type)
2238
+ const pk = primaryKeys.get(type.apiName)!
2239
+ const { dataset, column } = backingOf(type)
2240
+ if (kept) {
2241
+ out.push({ objectType: type.apiName, dataset, rows: rows.length, csvPath: join('datasets', `${dataset}.csv`), definitionPath: join('datasets', `${dataset}.json`), kept: true })
2242
+ continue
2243
+ }
297
2244
  const columns = properties.map((p) => column(p.apiName))
298
2245
  const csv = [columns.join(','), ...rows.map((r) => properties.map((p) => csvCell(r[p.apiName])).join(','))].join('\n') + '\n'
299
2246
  const csvPath = join('datasets', `${dataset}.csv`)
300
2247
  writeFileSync(join(root, csvPath), csv)
301
2248
  const definitionPath = join('datasets', `${dataset}.json`)
2249
+ const before = readDefinition(join(root, definitionPath))
2250
+ // Every column a record may leave empty is declared nullable, so a CSV made again (other
2251
+ // rows, another seed, a scenario) still fits the dataset's first contract.
2252
+ const declared = new Map<string, Record<string, unknown>>((Array.isArray(before?.columns) ? (before.columns as Array<Record<string, unknown>>) : []).flatMap((c) => (typeof c?.name === 'string' && columns.includes(c.name) ? [[c.name, c] as const] : [])))
2253
+ for (const p of properties) {
2254
+ const name = column(p.apiName)
2255
+ if (p.apiName !== pk && p.required !== true && !declared.has(name)) declared.set(name, { name, nullable: true })
2256
+ }
302
2257
  const definition = {
303
2258
  apiName: dataset,
304
- displayName: type.pluralDisplayName ?? plural(type.displayName ?? type.apiName),
305
- description: `Sample ${(type.pluralDisplayName ?? plural(type.apiName)).toLowerCase()} made by \`frontera local data --synthetic\`: not real records.`,
2259
+ displayName: typeof before?.displayName === 'string' ? before.displayName : type.pluralDisplayName ?? plural(type.displayName ?? type.apiName),
2260
+ // Stamped with the rule set its rows were made by, so rows an older generator made are told apart (`local-sample-data.ts`).
2261
+ description: stampedDescription(typeof before?.description === 'string' ? before.description : undefined, `Sample ${(type.pluralDisplayName ?? plural(type.apiName)).toLowerCase()} ${sampleStamp()}: not real records.`),
306
2262
  kind: 'file',
307
2263
  path: `./${dataset}.csv`,
308
2264
  includedColumns: columns,
2265
+ ...(declared.size ? { columns: columns.flatMap((c) => (declared.has(c) ? [declared.get(c)!] : [])) } : {}),
309
2266
  keyColumns: [column(pk)],
310
2267
  deterministicKeyConfirmed: true,
311
2268
  }
312
2269
  writeFileSync(join(root, definitionPath), `${JSON.stringify(definition, null, 2)}\n`)
313
- out.push({ objectType: type.apiName, dataset, rows: rows.length, csvPath, definitionPath })
2270
+ const vague = unresolved.filter((u) => u.objectType === type.apiName).map(({ property, condition }) => ({ property, condition }))
2271
+ out.push({ objectType: type.apiName, dataset, rows: rows.length, csvPath, definitionPath, ...(vague.length ? { unresolved: vague } : {}) })
314
2272
  }
315
2273
  return out
316
2274
  }
317
2275
 
2276
+ /** The rows of a dataset sample data keeps, by property, as its CSV holds them; none when it has no CSV. */
2277
+ function keptRows(root: string, dataset: string, properties: Property[], column: (property: string) => string): Row[] {
2278
+ const definition = readDefinition(join(root, 'datasets', `${dataset}.json`))
2279
+ const rel = typeof definition?.path === 'string' ? definition.path.replace(/^\.\//, '') : `${dataset}.csv`
2280
+ const path = join(root, 'datasets', rel)
2281
+ if (!existsSync(path)) return []
2282
+ return readCsvRows(readFileSync(path, 'utf8')).map((r) => Object.fromEntries(properties.map((p) => [p.apiName, r[column(p.apiName)] ?? ''])) as Row)
2283
+ }
2284
+
2285
+ /** A dataset definition already in the project, or null. */
2286
+ function readDefinition(path: string): Record<string, unknown> | null {
2287
+ try {
2288
+ const doc = JSON.parse(readFileSync(path, 'utf8')) as unknown
2289
+ return doc && typeof doc === 'object' && !Array.isArray(doc) ? (doc as Record<string, unknown>) : null
2290
+ } catch {
2291
+ return null
2292
+ }
2293
+ }
2294
+
318
2295
  /**
319
2296
  * Give each object type without a dataset the one sample data writes, so the
320
2297
  * Blueprint reads its rows. A type pulled with its own backing keeps it.
@@ -326,7 +2303,8 @@ export function addMissingBackings(root: string): string[] {
326
2303
  for (const { file, doc } of readTree<ObjectType>(dir)) {
327
2304
  if (!doc?.apiName || doc.backing?.dataset) continue
328
2305
  const { dataset, column } = backingOf(doc)
329
- const next = { ...doc, backing: { dataset, mapping: Object.fromEntries((doc.properties ?? []).map((p) => [p.apiName, column(p.apiName)])) } }
2306
+ // A calculated field reads no column.
2307
+ const next = { ...doc, backing: { dataset, mapping: Object.fromEntries((doc.properties ?? []).filter((p) => !p.calculation).map((p) => [p.apiName, column(p.apiName)])) } }
330
2308
  const path = join(dir, file)
331
2309
  writeFileSync(path, file.endsWith('.json') ? `${JSON.stringify(next, null, 2)}\n` : stringifyYaml(next, { lineWidth: 0 }))
332
2310
  changed.push(join('blueprint', 'object-types', file))
@@ -334,18 +2312,51 @@ export function addMissingBackings(root: string): string[] {
334
2312
  return changed
335
2313
  }
336
2314
 
2315
+ /**
2316
+ * How sample data is made and what `data/generate.ts` may change, in words:
2317
+ * `frontera local data --help` prints these, and the scenario template and
2318
+ * the project's AGENTS.md say the same.
2319
+ */
2320
+ export const SCENARIO_CONTRACT: readonly string[] = [
2321
+ 'Every object type in blueprint/object-types/ gets datasets/<dataset>.csv and its definition, one column per property its backing maps (a calculated field, such as a location from latitude and longitude, has none), except a type with a media property (a file in a Media Set): it reads a Media Set, not a dataset, so it gets no sample rows (the local runtime gives it a stand-in Media Set, which `frontera local dev` fills with placeholder files named after the paths the linked records give, or leaves it off), and a link to it is left empty. A value fits its property: the semantic type it carries (its own, or its shared field\'s), the values its description or its shared field\'s description lists ("low, medium, high or critical", alone or after a colon or dash: "The region - north, south or central"), an enum a governed Action writes into it, the values a metric\'s filter compares it with (some rows, so the metric counts something), then its type and name. A number a metric averages as a share (occupancyRate, "Occupancy percent") stays between 0 and 100. A time of day (openTime, closeTime, opensAt) is HH:MM, the close after the open, and both or neither; an `hours` text says the two; a `period` is a month (2026-09). A record\'s name reads like what the record is: a person\'s for a customer or technician, a company\'s for a supplier, a street for a site, the type and a serial for an asset; a person\'s email follows their name, and an asset\'s name its kind ("Transformer A-104"). Free text is written from the rest of the row: a title or summary names the record\'s category and the place or asset it links to ("Line damage at 4 Green Walk"), a description says who reported what and a detail, a resolution or outcome what was done (only once the record has ended, and not when it ended undone: cancelled, rejected, withdrawn…); a detail or a fix that names equipment (a pole, a cable, a meter) is used only for a record about that equipment, notes a follow-up, a reason a plain reason. A unit property (unit, uom) follows the row\'s reading kind (a sibling whose values are voltage, energy, temperature…, or measures such as occupancy_pct, annualised_rent, requests_opened, resolution_hours): by position when the unit\'s listed values line up with the kinds ("V, A, C or %" for "voltage, current, temperature or load"), else the unit among those listed that the kind is read in (percent, a currency code, count, hours), else the kind\'s usual unit; the value falls in that kind\'s range. Latitude, longitude and phone numbers follow --region: a country code, or a <lat>,<lon>[,<radius km>] point and radius (a true radius: every point lies within that many kilometres of it, spread evenly over the circle; a point takes the known country it lies in, for its names, currency, country and phone numbers). A row\'s latitude and longitude are one point, and a geopoint agrees with them; a compass region (values north, south, east, west, central) is the one the point lies in seen from the region\'s centre, and a city is the city of --region (one known area, or the large city a custom point is near: 39.74,-104.99 is Denver).',
2322
+ 'Links stay consistent: a property that holds another type\'s key gets one of its records (a primary key that is also a link key, as a transcript keyed by its service request, takes each record once, so the link resolves and the key stays unique: such a type has no more rows than the other), and when a row has two links whose records lead to each other along any path of links, they agree (a work order\'s asset is at the work order\'s site, and its customer is the customer that owns that site). A link whose record leads to more of the row\'s other links is chosen first, and the others follow from it. Two links to the same type (a reporter and an assignee) do not constrain each other. An optional link with no record that agrees is left empty. A property no link type declares but whose name says it holds another type\'s key (`propertyId` a Property\'s, `dispatchDecisionId` a DispatchDecision\'s even when its key is `decisionId`, `homePropertyId` the type its name ends with, `staffId` the type whose key it is, `assignedTo` or `owner` one of the people who do the work, such as Staff or Technician) takes that type\'s keys too, agreeing with the declared links but never constraining them; one that points back along a declared link (a request\'s dispatch decision, when each decision links to its request) takes the record that links to it. An id of a type the Blueprint doesn\'t have reads like a key (`portfolioId` → PRT-00003), from a few that rows share.',
2323
+ 'A property is optional unless it says `required: true`, as the Blueprint reads it: an optional value is sometimes empty, as real records are, except one that says which record it is or what its figure means (its title, a period, a fiscal year, a key or code, a case or scope, a currency or unit). Counts and sizes stay small (bedrooms 1–4, floors up to 30, a count a description introduces with "How many": up to 10), a number keeps to the range its description gives, in parentheses or as "between" ("How many hours the job takes (1 to 12)", "between 1 and 5"), months and years in a plausible span, a percent (pct) between 0 and 100, a probability between 0 and 1, an annual figure a year\'s worth, a fiscal year like FY2027, and who did something (prepared by, decided by) a person\'s name.',
2324
+ 'A record\'s state decides what else it holds. The state property is the one named status, state or stage (or ending in Status/State); its values (from its semantic type or description) each have a phase: new, pending, draft, submitted… start a life, closed, resolved, done, cancelled… end one, and every other value is under way; with three or more values and none named so, the first starts and the last ends. While a record has not started, an optional field for who took it on (assigned, assignee, technician, owner, or one whose description says it is empty while the record is new) is empty, and set once it has. Until it ends, an optional field set at the end (resolution, outcome, closedAt, resolvedAt) is empty, and set once it has. An optional field whose description makes it conditional ("empty unless escalated", "only when the priority is critical", "empty while on hold") is empty while the condition is false and set while it holds. The condition has to name something the record has: a yes/no property ("escalated" for a boolean escalated or isEscalated), a value of a property that lists values ("critical" for a priority with that value, "on hold" for a state on_hold), the record being closed or open, or another property (set when that one is), and "not" turns it. A condition that names nothing the record has never holds: an "empty unless" or "only when" field stays empty on every row, and the run names it under `unresolved` so the description can name a property or value, or a field function can set it. Times follow the life their names say: created, then assigned, then resolved, an update last, spread over the 120 days before today (the day it runs, midnight UTC, so the same seed gives the same rows that day). An open record with a due time (due, deadline, SLA, target, expected by) was raised in the last ten days and is due around today: about a quarter overdue by hours to days, the rest due within a week. A closed record was due days after it started. A time from before the record\'s own life follows its name instead: installed, commissioned, bought or built (an asset) from six months to 15 years back, founded or established from 2 to 60 years, hired up to 20 years; a birth date makes an adult.',
2325
+ 'Every column a record may leave empty is declared `nullable: true` in the definition\'s `columns`, so a CSV made again still fits the dataset.',
2326
+ 'Only sample data is written: a dataset this command made (its description says it was made by `frontera local data --synthetic`, with the sample rules its rows follow), one with no rows yet, and the scaffold\'s sample tickets while they are unedited. Any other dataset (one you wrote or edited, one a Source loads, one you brought into the folder) is kept as it is, and the sample rows of other types link to its records. `frontera local dev` and `frontera local status` say when sample data was made by older rules than today\'s, and running this command again remakes it.',
2327
+ 'data/generate.ts adjusts it: `export default function scenario({ faker })` returns `{ rows?, types?: { <ObjectType>: { rows?, fields?: { <property>: (row, index, ctx) => value } } } }`. `rows` is the count (per type, or for every type); a field function gives that property\'s value and receives the whole row (`row`: every property already generated, its own default included, so a function can read any property whatever the order the object type lists them in, or adjust its own), the row\'s position (`index`, from 0) and the rows of other types (`ctx.rowsOf(\'Site\')`: every generated Site row, read-only; `ctx.lookup(\'Site\', row.siteId)`: the Site whose primary key is that, or `ctx.lookup(\'Site\', value, \'property\')` by another property; undefined when none is). Types are generated in link order, a type before the types that point at it, so the types a row links to are there; its own type and the ones generated after it give [] and undefined. Functions run in the order the type lists their properties, every date and time after the others; a function that reads another function\'s value sees it, and one whose read value changes later is run again (at most three more times) until what it read is final. Return \'\' or null for an empty value. A field with a function is left as the function made it; the fields without one follow the functions\' values: the state, link, text, unit and point rules above apply again (a state a function sets empties or fills the fields it decides, and changes the times when the record\'s phase changes). `faker` is the CLI\'s own Faker (https://fakerjs.dev/api/), seeded, so the same --seed gives the same rows.',
2328
+ ]
2329
+
337
2330
  /** The scenario template written the first time, for the FDE or a coding agent to edit. */
338
2331
  export const SCENARIO_TEMPLATE = `/**
339
2332
  * Adjusts the sample data \`frontera local data --synthetic\` writes into
340
- * datasets/. Return rows per object type and, per property, a function that
341
- * gives its value; anything left out is generated from the property's type,
342
- * name and description. \`faker\` is seeded, so the same seed gives the same rows.
2333
+ * datasets/ (\`frontera local data --help\` says how the rest is made).
2334
+ *
2335
+ * Return rows per object type and, per property, a function that gives its
2336
+ * value; anything left out is generated from the property's semantic type,
2337
+ * description, type and name, with links and states kept consistent.
2338
+ *
2339
+ * A field function is called as \`(row, index, ctx)\`:
2340
+ * row the whole record, every property already generated (its own
2341
+ * default too), whatever order the object type lists them in; a
2342
+ * function that reads another function's value sees that value
2343
+ * index the row's position, from 0
2344
+ * ctx the rows of other types, read-only: \`ctx.rowsOf('Site')\` is
2345
+ * every generated Site, \`ctx.lookup('Site', row.siteId)\` the one
2346
+ * with that primary key (undefined when none is). The types a row
2347
+ * links to are generated before it; its own type gives none.
2348
+ * Return '' or null for an empty value. A field with a function is left as
2349
+ * it made it; the fields without one follow it (a state a function sets
2350
+ * empties or fills the fields it decides; links, text and points agree).
343
2351
  *
344
2352
  * frontera local data --synthetic --seed 7
345
2353
  *
346
- * \`faker\` is the CLI's own (https://fakerjs.dev/api/), so the project
347
- * installs nothing for it.
2354
+ * \`faker\` is the CLI's own (https://fakerjs.dev/api/), seeded, so the same
2355
+ * seed gives the same rows and the project installs nothing for it.
348
2356
  */
2357
+ type Row = Record<string, unknown>
2358
+ type Context = { rowsOf: (type: string) => readonly Row[]; lookup: (type: string, value: unknown, property?: string) => Row | undefined }
2359
+
349
2360
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
350
2361
  export default function scenario({ faker }: { faker: any }) {
351
2362
  return {
@@ -355,6 +2366,12 @@ export default function scenario({ faker }: { faker: any }) {
355
2366
  // rows: 300,
356
2367
  // fields: {
357
2368
  // priority: () => faker.helpers.weightedArrayElement([{ value: 'urgent', weight: 1 }, { value: 'normal', weight: 6 }]),
2369
+ // // Reads another property of the same row, wherever the type lists it.
2370
+ // subject: (row: Row) => (row.priority === 'urgent' ? 'Service down' : 'Question about an invoice'),
2371
+ // // Reads the generated times, and adjusts its own default.
2372
+ // resolvedAt: (row: Row) => (row.status === 'closed' ? row.updatedAt : null),
2373
+ // // Reads the record a link points at: the customer's tier decides the priority.
2374
+ // // priority: (row: Row, _i: number, ctx: Context) => (ctx.lookup('Customer', row.customerId)?.tier === 'enterprise' ? 'urgent' : 'normal'),
358
2375
  // },
359
2376
  // },
360
2377
  },