@frontera-sdk/cli 1.52.38 → 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.
- package/README.md +9 -0
- package/package.json +6 -4
- package/src/adopt.ts +6 -2
- package/src/api/apps-api.ts +86 -4
- package/src/api/blueprint-authoring-api.ts +35 -11
- package/src/api/dataset-api.ts +29 -0
- package/src/api/governed-action-api.ts +90 -2
- package/src/api/platform-api.ts +87 -2
- package/src/api/workflow-api.ts +33 -2
- package/src/app-contract.ts +156 -0
- package/src/app-lock.ts +125 -0
- package/src/args.ts +14 -3
- package/src/auth-verify.ts +10 -0
- package/src/automation-template.ts +12 -1
- package/src/blueprint/action-rules.ts +516 -0
- package/src/blueprint/action-semantic-fit.ts +205 -0
- package/src/blueprint/compile.ts +51 -2
- package/src/blueprint/diff.ts +34 -2
- package/src/blueprint/metric-query.ts +215 -0
- package/src/blueprint/metric-rules.ts +122 -0
- package/src/blueprint/object-sets.ts +116 -0
- package/src/blueprint/ontology-edit-plan.ts +48 -4
- package/src/blueprint/projection.ts +24 -1
- package/src/blueprint/scaffold.ts +84 -42
- package/src/commands/action/available.ts +1 -1
- package/src/commands/action/criteria-refusal.ts +84 -0
- package/src/commands/action/decide.ts +5 -2
- package/src/commands/action/deploy.ts +13 -4
- package/src/commands/action/list.ts +134 -19
- package/src/commands/action/requests.ts +34 -6
- package/src/commands/action/submit.ts +28 -13
- package/src/commands/agent/index-commands.ts +69 -7
- package/src/commands/agent/resolve.ts +2 -0
- package/src/commands/app/checks.ts +44 -0
- package/src/commands/app/deploy.ts +243 -60
- package/src/commands/app/deployments.ts +97 -0
- package/src/commands/app/dev.ts +103 -11
- package/src/commands/app/init.ts +36 -3
- package/src/commands/app/promote.ts +45 -19
- package/src/commands/app/pull.ts +3 -1
- package/src/commands/app/rollback.ts +46 -0
- package/src/commands/app/sdk.ts +54 -8
- package/src/commands/app/unpin.ts +27 -0
- package/src/commands/app/versions.ts +1 -1
- package/src/commands/automation/bindings.ts +4 -2
- package/src/commands/automation/build-entry.ts +4 -1
- package/src/commands/automation/index-commands.ts +3 -1
- package/src/commands/automation/pull.ts +6 -1
- package/src/commands/automation/run.ts +34 -9
- package/src/commands/blueprint/bind.ts +2 -1
- package/src/commands/blueprint/declarative.ts +331 -31
- package/src/commands/blueprint/get.ts +6 -2
- package/src/commands/blueprint/grants.ts +64 -10
- package/src/commands/blueprint/metric.ts +108 -0
- package/src/commands/blueprint/object-sets.ts +71 -0
- package/src/commands/blueprint/query.ts +155 -31
- package/src/commands/dataset/index-commands.ts +156 -7
- package/src/commands/knowledge/index-commands.ts +122 -20
- package/src/commands/local/check.ts +657 -44
- package/src/commands/local/data.ts +33 -12
- package/src/commands/local/dev.ts +875 -88
- package/src/commands/local/docs-mcp.ts +10 -18
- package/src/commands/local/index-commands.ts +300 -63
- package/src/commands/local/init.ts +18 -5
- package/src/commands/local/key.ts +0 -3
- package/src/commands/local/providers.ts +0 -7
- package/src/commands/local/remove.ts +116 -0
- package/src/commands/local/schema.ts +3 -4
- package/src/commands/local/sdk-refresh.ts +59 -0
- package/src/commands/local/snapshot.ts +100 -0
- package/src/commands/local/sync-request.ts +320 -13
- package/src/commands/local/sync.ts +102 -5
- package/src/commands/mcp/serve.ts +34 -0
- package/src/commands/registry.ts +52 -8
- package/src/commands/types.ts +29 -0
- package/src/commands/workflow/archive.ts +46 -0
- package/src/commands/workflow/get.ts +2 -1
- package/src/commands/workflow/index-commands.ts +5 -0
- package/src/commands/workflow/push.ts +49 -2
- package/src/commands/workflow/run.ts +65 -7
- package/src/commands/workflow/runs.ts +9 -6
- package/src/commands/workflow/webhook-test.ts +166 -0
- package/src/config.ts +43 -6
- package/src/dev-broker.ts +52 -0
- package/src/exit.ts +103 -4
- package/src/flag-help.ts +77 -17
- package/src/forge/touches.ts +15 -12
- package/src/function-outside-imports.ts +89 -0
- package/src/harness.ts +42 -24
- package/src/heal.ts +112 -1
- package/src/local/fix-words.ts +12 -0
- package/src/local/function-bundle-recovery.ts +775 -0
- package/src/local/local-actions.ts +103 -10
- package/src/local/local-agent-action-grants.ts +210 -0
- package/src/local/local-agent-checks.ts +232 -0
- package/src/local/local-agent-grants.ts +29 -7
- package/src/local/local-agent-lean-plugins.ts +68 -0
- package/src/local/local-agent-missing-tools.ts +68 -0
- package/src/local/local-agent-name.ts +27 -0
- package/src/local/local-app-code.ts +191 -0
- package/src/local/local-app-dev-files.ts +186 -0
- package/src/local/local-app-dev-session.ts +88 -0
- package/src/local/local-app-lock-install.ts +114 -0
- package/src/local/local-app-pulled.ts +167 -0
- package/src/local/local-app-restore.ts +111 -0
- package/src/local/local-app-runtime.ts +68 -0
- package/src/local/local-app-sdk-paths.ts +149 -0
- package/src/local/local-app-sdk.ts +509 -0
- package/src/local/local-applied.ts +211 -21
- package/src/local/local-apply-history.ts +84 -0
- package/src/local/local-apply-summary.ts +86 -0
- package/src/local/local-apply-watchdog.ts +95 -0
- package/src/local/local-apps.ts +180 -7
- package/src/local/local-blueprint-errors.ts +198 -0
- package/src/local/local-blueprint-outline.ts +31 -12
- package/src/local/local-blueprint-schemas.ts +582 -0
- package/src/local/local-build-inputs.ts +48 -0
- package/src/local/local-build-summary.ts +495 -28
- package/src/local/local-case-evidence.ts +120 -0
- package/src/local/local-case-not-run.ts +153 -0
- package/src/local/local-chat-apps.ts +93 -16
- package/src/local/local-check-inherited.ts +83 -0
- package/src/local/local-cities.ts +89 -0
- package/src/local/local-component-health.ts +119 -0
- package/src/local/local-control.ts +34 -7
- package/src/local/local-db-snapshot.ts +348 -0
- package/src/local/local-dev-references.ts +346 -35
- package/src/local/local-dev-sync.ts +136 -7
- package/src/local/local-docker.ts +35 -1
- package/src/local/local-docs-search.ts +1 -1
- package/src/local/local-docs.ts +281 -2
- package/src/local/local-empty-workflows.ts +92 -0
- package/src/local/local-env.ts +16 -1
- package/src/local/local-file-checks.ts +104 -0
- package/src/local/local-function-ids.ts +141 -0
- package/src/local/local-function-recovery.ts +141 -0
- package/src/local/local-function-state.ts +87 -0
- package/src/local/local-keys.ts +7 -5
- package/src/local/local-knowledge-ingestion.ts +225 -0
- package/src/local/local-knowledge-uploads.ts +217 -0
- package/src/local/local-media-placeholders.ts +182 -0
- package/src/local/local-media-sets.ts +90 -10
- package/src/local/local-model-parameters.ts +89 -0
- package/src/local/local-object-sets.ts +18 -6
- package/src/local/local-operating.ts +35 -1
- package/src/local/local-org-settings.ts +205 -7
- package/src/local/local-orphans.ts +321 -0
- package/src/local/local-personas.ts +296 -0
- package/src/local/local-portability.ts +141 -16
- package/src/local/local-ports.ts +99 -7
- package/src/local/local-project-notices.ts +234 -0
- package/src/local/local-project-secrets.ts +133 -0
- package/src/local/local-project.ts +12 -0
- package/src/local/local-provider-refusal.ts +66 -0
- package/src/local/local-request-evidence.ts +112 -0
- package/src/local/local-runtime-health.ts +123 -0
- package/src/local/local-runtime-history.ts +50 -0
- package/src/local/local-runtime-target.ts +42 -0
- package/src/local/local-sample-data.ts +141 -0
- package/src/local/local-sample-remake.ts +138 -0
- package/src/local/local-scaffold.ts +336 -53
- package/src/local/local-schema-check.ts +148 -0
- package/src/local/local-schemas.ts +742 -25
- package/src/local/local-secret-carry.ts +107 -0
- package/src/local/local-secrets.ts +38 -8
- package/src/local/local-seed.ts +102 -26
- package/src/local/local-stand-ins.ts +1 -1
- package/src/local/local-state.ts +43 -2
- package/src/local/local-studio-server.ts +170 -7
- package/src/local/local-supervisor.ts +44 -6
- package/src/local/local-sync-names.ts +2 -0
- package/src/local/local-sync-plan.ts +11 -0
- package/src/local/local-sync-push.ts +10 -7
- package/src/local/local-sync-refs.ts +49 -3
- package/src/local/local-sync-remote.ts +343 -32
- package/src/local/local-sync-snapshot.ts +15 -10
- package/src/local/local-synthetic-data.ts +2116 -99
- package/src/local/local-test-baseline.ts +113 -0
- package/src/local/local-test-cases.ts +76 -1
- package/src/local/local-test-runner.ts +582 -91
- package/src/local/local-test-runs.ts +13 -5
- package/src/local/local-vendored-sdk.ts +183 -0
- package/src/local/local-workflow-state.ts +16 -3
- package/src/local/local-workflow-tests.ts +482 -0
- package/src/local/local-workspace-grants.ts +81 -0
- package/src/main.ts +50 -9
- package/src/mcp/a11y-scan.ts +55 -0
- package/src/mcp/browser/ax-snapshot.ts +181 -0
- package/src/mcp/browser/credentials.ts +78 -0
- package/src/mcp/browser/frames.ts +336 -0
- package/src/mcp/browser/headless-session.ts +413 -0
- package/src/mcp/browser/headless-tab.ts +575 -0
- package/src/mcp/browser/keys.ts +70 -0
- package/src/mcp/browser/page-scripts.ts +129 -0
- package/src/mcp/browser/wait.ts +73 -0
- package/src/mcp/forge-link.ts +126 -0
- package/src/mcp/headless.ts +199 -0
- package/src/mcp/mcp-apps.ts +435 -0
- package/src/mcp/mcp-chat.ts +226 -0
- package/src/mcp/mcp-cli.ts +53 -0
- package/src/mcp/mcp-protocol.ts +122 -0
- package/src/mcp/mcp-runtime.ts +113 -0
- package/src/mcp/mcp-server.ts +108 -0
- package/src/mcp/mcp-traces.ts +409 -0
- package/src/mcp/page-observation.ts +193 -0
- package/src/mcp/tools/agent-tools.ts +700 -0
- package/src/mcp/tools/app-tools.ts +241 -0
- package/src/mcp/tools/browser-tools.ts +310 -0
- package/src/mcp/tools/docs-tools.ts +32 -0
- package/src/mcp/tools/project-tools.ts +205 -0
- package/src/mcp/tools/test-tools.ts +339 -0
- package/src/mcp/tools/workflow-tools.ts +611 -0
- package/src/output.ts +1 -1
- package/src/pack.ts +8 -3
- package/src/packaging.ts +1 -1
- package/src/project-bootstrap.ts +107 -4
- package/src/project-context.ts +18 -1
- package/src/project.ts +27 -2
- package/src/provenance.ts +25 -0
- package/src/render-evidence.ts +65 -7
- package/src/template.ts +14 -8
- package/src/templates/next-app-files.ts +35 -6
- package/src/templates/next-skills.ts +61 -27
- package/src/vendor/kit-assets.json +6 -6
- 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.
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
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,
|
|
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
|
-
/**
|
|
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 {
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
122
|
-
|
|
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 === '
|
|
133
|
-
|
|
134
|
-
|
|
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
|
-
|
|
161
|
-
|
|
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
|
|
164
|
-
|
|
165
|
-
|
|
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
|
-
|
|
170
|
-
if (
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
if (
|
|
175
|
-
if (
|
|
176
|
-
if (/
|
|
177
|
-
if (
|
|
178
|
-
if (/
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
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(
|
|
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
|
|
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 (
|
|
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 (
|
|
209
|
-
|
|
210
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
//
|
|
219
|
-
if (/
|
|
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
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
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
|
|
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
|
|
245
|
-
|
|
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
|
-
|
|
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(
|
|
250
|
-
const
|
|
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
|
-
|
|
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
|
|
261
|
-
|
|
262
|
-
const
|
|
263
|
-
const
|
|
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
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
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
|
-
|
|
281
|
-
|
|
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.
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
341
|
-
*
|
|
342
|
-
*
|
|
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
|
|
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
|
},
|