meadow-integration 1.0.42 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "meadow-integration",
3
- "version": "1.0.42",
3
+ "version": "1.1.0",
4
4
  "description": "Meadow Data Integration",
5
5
  "retoldBeacon": {
6
6
  "displayName": "Meadow Integration",
@@ -0,0 +1,47 @@
1
+ // Meadow-Integration-Engine — the browser-safe, dependency-light entry point for the comprehension
2
+ // transform / push ENGINE classes, decoupled from the full meadow-integration package.
3
+ //
4
+ // The package `main` (Meadow-Integration.js) and the `browser` entry pull in orator, the meadow ORM,
5
+ // the DB connection drivers, xlsx, etc. — none of which the engine itself needs. These three services
6
+ // depend ONLY on fable-serviceproviderbase (and each other), so an in-browser consumer — e.g.
7
+ // pict-section-dataimport — can `require('meadow-integration/source/Meadow-Integration-Engine.js')`
8
+ // to get the engine WITHOUT dragging any server-only code into its bundle.
9
+ //
10
+ // This file is the stable public contract for in-browser engine consumers: if the internal layout
11
+ // under source/ ever changes, update the requires HERE and consumers keep importing this one path.
12
+ //
13
+ // const { MeadowIntegrationTabularTransform, MeadowIntegrationAdapter, MeadowGUIDMap, getAdapter }
14
+ // = require('meadow-integration/source/Meadow-Integration-Engine.js');
15
+ //
16
+ // - MeadowIntegrationTabularTransform : maps tabular rows -> a Comprehension (transformRecord per row;
17
+ // runs Solvers + MultipleGUIDUniqueness fan-out).
18
+ // - MeadowIntegrationAdapter : GUID marshaling, FK resolution, bulk upsert (setRestClient,
19
+ // addSourceRecord, integrateRecords). Use getAdapter(fable, entity, prefix, options) for the
20
+ // per-entity singleton.
21
+ // - MeadowGUIDMap : the in-session external-GUID <-> Meadow-ID map.
22
+
23
+ const MeadowIntegrationTabularTransform = require('./services/tabular/Service-TabularTransform.js');
24
+ const MeadowIntegrationAdapter = require('./Meadow-Service-Integration-Adapter.js');
25
+ const MeadowGUIDMap = require('./Meadow-Service-Integration-GUIDMap.js');
26
+
27
+ // Opt-in context-aware GUID layer (pure, dependency-light): compose deterministic, length-safe GUIDs that
28
+ // embed parent context (e.g. `UI_C10_P01278_LI8675309`) + a compiler that turns a per-entity strategy
29
+ // config into the structured spec the TabularTransform stamps. Reusable by the CLI, the server endpoints,
30
+ // and the browser import wizard alike.
31
+ const MeadowIntegrationGUIDComposer = require('./services/guid/Meadow-Integration-GUIDComposer.js');
32
+ const MeadowIntegrationGUIDStrategy = require('./services/guid/Meadow-Integration-GUIDStrategy.js');
33
+
34
+ module.exports =
35
+ {
36
+ MeadowIntegrationTabularTransform,
37
+ MeadowIntegrationAdapter,
38
+ MeadowGUIDMap,
39
+ // The per-entity adapter factory (caches on fable.servicesMap.IntegrationAdapter[Entity]).
40
+ getAdapter: MeadowIntegrationAdapter.getAdapter,
41
+
42
+ // Context-aware GUID composition + strategy compilation.
43
+ MeadowIntegrationGUIDComposer,
44
+ MeadowIntegrationGUIDStrategy,
45
+ composeGUID: MeadowIntegrationGUIDComposer.composeGUID,
46
+ compileGUIDStrategy: MeadowIntegrationGUIDStrategy.compile,
47
+ };
@@ -1,5 +1,6 @@
1
1
  const libFableServiceProviderBase = require('fable-serviceproviderbase');
2
2
  const libGUIDMap = require('./Meadow-Service-Integration-GUIDMap.js');
3
+ const libGUIDComposer = require('./services/guid/Meadow-Integration-GUIDComposer.js');
3
4
 
4
5
  const defaultMeadowIntegrationAdapterOptions = (
5
6
  {
@@ -27,6 +28,11 @@ const defaultMeadowIntegrationAdapterOptions = (
27
28
  // When true, the prefix is truncated to fit while preserving the full external GUID.
28
29
  "AllowGUIDTruncation": false,
29
30
 
31
+ // How to shrink an over-length marshaled GUID when AllowGUIDTruncation is on:
32
+ // "substring" (default) — truncate the prefix, keep the external GUID whole (current behavior).
33
+ // "hash" — keep the prefix, deterministically hash the external GUID to fit (stable).
34
+ "GUIDTruncationStrategy": "substring",
35
+
30
36
  // When true, only marshal fields that are present in the schema (no passthrough of unknown fields).
31
37
  "SimpleMarshal": false,
32
38
 
@@ -105,6 +111,7 @@ class MeadowIntegrationAdapter extends libFableServiceProviderBase
105
111
  this.GUIDMaxLength = this.options.DefaultGUIDColumnSize;
106
112
  }
107
113
  this.AllowGUIDTruncation = this.options.AllowGUIDTruncation;
114
+ this.GUIDTruncationStrategy = this.options.GUIDTruncationStrategy || 'substring';
108
115
 
109
116
  // Integration Adapter Controls
110
117
  this._PerformUpserts = this.options.PerformUpserts;
@@ -214,19 +221,33 @@ class MeadowIntegrationAdapter extends libFableServiceProviderBase
214
221
  }
215
222
 
216
223
  // AllowGUIDTruncation is on — the external GUID is sacrosanct, so truncate the prefix instead.
217
- let tmpAvailablePrefixLength = this.GUIDMaxLength - pExternalGUID.length;
218
-
219
- if (tmpAvailablePrefixLength <= 0)
224
+ if (this.GUIDTruncationStrategy === 'hash')
220
225
  {
221
- // External GUID alone meets or exceeds the limit; drop the prefix entirely.
222
- this.log.warn(`External GUID [${pExternalGUID}] for [${this.Entity}] is ${pExternalGUID.length} characters which meets or exceeds the GUID max length of ${this.GUIDMaxLength}. Using external GUID with no prefix.`);
223
- tmpFullGUID = pExternalGUID.substring(0, this.GUIDMaxLength);
226
+ // Hash strategy: keep the prefix; deterministically hash the external GUID into the remaining
227
+ // budget so the result stays stable across runs (idempotent) rather than losing prefix chars.
228
+ let tmpHashBudget = Math.max(8, this.GUIDMaxLength - this.GUIDPrefix.length);
229
+ let tmpHashedExternal = libGUIDComposer.hashSegment(pExternalGUID, tmpHashBudget);
230
+ tmpFullGUID = `${this.GUIDPrefix}${tmpHashedExternal}`;
231
+ if (tmpFullGUID.length > this.GUIDMaxLength) { tmpFullGUID = tmpFullGUID.substring(0, this.GUIDMaxLength); }
232
+ this.log.warn(`Generated GUID for [${this.Entity}] exceeded ${this.GUIDMaxLength} chars; external GUID hashed (GUIDTruncationStrategy=hash) to [${tmpHashedExternal}].`);
224
233
  }
225
234
  else
226
235
  {
227
- let tmpTruncatedPrefix = this.GUIDPrefix.substring(0, tmpAvailablePrefixLength);
228
- tmpFullGUID = `${tmpTruncatedPrefix}${pExternalGUID}`;
229
- this.log.warn(`Generated GUID for [${this.Entity}] would be ${this.GUIDPrefix.length + pExternalGUID.length} characters (limit ${this.GUIDMaxLength}); prefix truncated from [${this.GUIDPrefix}] to [${tmpTruncatedPrefix}].`);
236
+ // Substring strategy (default): the external GUID is sacrosanct, so truncate the prefix instead.
237
+ let tmpAvailablePrefixLength = this.GUIDMaxLength - pExternalGUID.length;
238
+
239
+ if (tmpAvailablePrefixLength <= 0)
240
+ {
241
+ // External GUID alone meets or exceeds the limit; drop the prefix entirely.
242
+ this.log.warn(`External GUID [${pExternalGUID}] for [${this.Entity}] is ${pExternalGUID.length} characters which meets or exceeds the GUID max length of ${this.GUIDMaxLength}. Using external GUID with no prefix.`);
243
+ tmpFullGUID = pExternalGUID.substring(0, this.GUIDMaxLength);
244
+ }
245
+ else
246
+ {
247
+ let tmpTruncatedPrefix = this.GUIDPrefix.substring(0, tmpAvailablePrefixLength);
248
+ tmpFullGUID = `${tmpTruncatedPrefix}${pExternalGUID}`;
249
+ this.log.warn(`Generated GUID for [${this.Entity}] would be ${this.GUIDPrefix.length + pExternalGUID.length} characters (limit ${this.GUIDMaxLength}); prefix truncated from [${this.GUIDPrefix}] to [${tmpTruncatedPrefix}].`);
250
+ }
230
251
  }
231
252
  }
232
253
 
@@ -0,0 +1,131 @@
1
+ // Meadow-Integration-GUIDComposer — deterministic, length-safe composition of context-aware GUIDs.
2
+ //
3
+ // A "context-aware" GUID embeds its parents' identifiers so it is globally unique without a true UUID,
4
+ // e.g. a Line Item 8675309 on Contract 10 / Project 01278 composes to `UI_C10_P01278_LI8675309`. The
5
+ // hard part is staying inside the destination GUID column's width: when the composed string is too long
6
+ // this DETERMINISTICALLY hashes segments (interior context first, then the own segment, then the first)
7
+ // until it fits — `UI_C10_HASH_LI8675309` → `UI_C10_HASH_HASH` — so the same source row always yields the
8
+ // same GUID (idempotent upsert) even after shrinking.
9
+ //
10
+ // PURE + dependency-light on purpose: no node `crypto`, no fable, no DOM. It runs unchanged in the browser
11
+ // engine bundle (the Meadow-Integration-Engine boundary) and is trivially unit-testable in isolation.
12
+
13
+ const _DEFAULT_SEPARATOR = '_';
14
+ const _DEFAULT_HASH_LENGTH = 10;
15
+
16
+ /**
17
+ * Deterministic 53-bit string hash (cyrb53) rendered as a fixed-length base36 token. Same input → same
18
+ * output in any JS runtime, forever — which is what makes a shrunk GUID still match on re-import.
19
+ * @param {string} pValue
20
+ * @param {number} [pLength] - max token length (default 10)
21
+ * @returns {string}
22
+ */
23
+ function hashSegment(pValue, pLength)
24
+ {
25
+ const tmpLength = (typeof pLength === 'number' && pLength > 0) ? pLength : _DEFAULT_HASH_LENGTH;
26
+ const tmpString = String((pValue === null || pValue === undefined) ? '' : pValue);
27
+ let tmpH1 = 0xdeadbeef;
28
+ let tmpH2 = 0x41c6ce57;
29
+ for (let i = 0; i < tmpString.length; i++)
30
+ {
31
+ const tmpCharCode = tmpString.charCodeAt(i);
32
+ tmpH1 = Math.imul(tmpH1 ^ tmpCharCode, 2654435761);
33
+ tmpH2 = Math.imul(tmpH2 ^ tmpCharCode, 1597334677);
34
+ }
35
+ tmpH1 = Math.imul(tmpH1 ^ (tmpH1 >>> 16), 2246822507) ^ Math.imul(tmpH2 ^ (tmpH2 >>> 13), 3266489909);
36
+ tmpH2 = Math.imul(tmpH2 ^ (tmpH2 >>> 16), 2246822507) ^ Math.imul(tmpH1 ^ (tmpH1 >>> 13), 3266489909);
37
+ // 53-bit unsigned integer (safe-integer range) → base36 token.
38
+ const tmpHashNumber = 4294967296 * (2097151 & tmpH2) + (tmpH1 >>> 0);
39
+ const tmpToken = tmpHashNumber.toString(36);
40
+ return (tmpToken.length > tmpLength) ? tmpToken.slice(0, tmpLength) : tmpToken;
41
+ }
42
+
43
+ /** Render one segment as `<abbrev><value>` (e.g. `P01278`). */
44
+ function segmentString(pSegment)
45
+ {
46
+ const tmpSegment = pSegment || {};
47
+ const tmpAbbrev = (tmpSegment.abbrev === null || tmpSegment.abbrev === undefined) ? '' : String(tmpSegment.abbrev);
48
+ const tmpValue = (tmpSegment.value === null || tmpSegment.value === undefined) ? '' : String(tmpSegment.value);
49
+ return `${tmpAbbrev}${tmpValue}`;
50
+ }
51
+
52
+ /** Join the prefix (if any) + the segment strings with the separator. */
53
+ function _assemble(pPrefix, pSegmentStrings, pSeparator)
54
+ {
55
+ const tmpParts = pPrefix ? [ pPrefix ].concat(pSegmentStrings) : pSegmentStrings.slice();
56
+ return tmpParts.join(pSeparator);
57
+ }
58
+
59
+ /**
60
+ * The order to hash segments when shrinking: interior context segments first (left→right), then the own
61
+ * (last) segment, then the first context segment — so the prefix + first context + own stay readable
62
+ * longest. Deterministic, so the same overflow always shrinks the same way.
63
+ * @param {number} pCount
64
+ * @returns {Array<number>}
65
+ */
66
+ function _shrinkOrder(pCount)
67
+ {
68
+ const tmpOrder = [];
69
+ for (let i = 1; i < pCount - 1; i++) { tmpOrder.push(i); }
70
+ if (pCount - 1 >= 1) { tmpOrder.push(pCount - 1); }
71
+ if (pCount >= 1) { tmpOrder.push(0); }
72
+ const tmpSeen = {};
73
+ return tmpOrder.filter((pIndex) => (tmpSeen[pIndex] ? false : (tmpSeen[pIndex] = true)));
74
+ }
75
+
76
+ /**
77
+ * Compose a deterministic, length-safe context-aware GUID.
78
+ * @param {object} pSpec
79
+ * @param {string} [pSpec.prefix] - the leading token (e.g. `UI`); omitted if falsy
80
+ * @param {Array<{abbrev:string, value:*}>} pSpec.segments - ordered context segments + the own segment last
81
+ * @param {string} [pSpec.separator] - default `_`
82
+ * @param {number} [pSpec.maxLength] - the destination GUID column width; <=0 means unbounded
83
+ * @param {number} [pSpec.hashLength] - per-segment hash token length (default 10)
84
+ * @returns {string}
85
+ */
86
+ function composeGUID(pSpec)
87
+ {
88
+ const tmpSpec = pSpec || {};
89
+ const tmpPrefix = tmpSpec.prefix ? String(tmpSpec.prefix) : '';
90
+ const tmpSeparator = (typeof tmpSpec.separator === 'string') ? tmpSpec.separator : _DEFAULT_SEPARATOR;
91
+ const tmpMaxLength = (typeof tmpSpec.maxLength === 'number') ? tmpSpec.maxLength : 0;
92
+ const tmpHashLength = (typeof tmpSpec.hashLength === 'number' && tmpSpec.hashLength > 0) ? tmpSpec.hashLength : _DEFAULT_HASH_LENGTH;
93
+ const tmpSegments = Array.isArray(tmpSpec.segments) ? tmpSpec.segments : [];
94
+
95
+ const tmpSegmentStrings = tmpSegments.map(segmentString);
96
+ let tmpFull = _assemble(tmpPrefix, tmpSegmentStrings, tmpSeparator);
97
+ if (tmpMaxLength <= 0 || tmpFull.length <= tmpMaxLength)
98
+ {
99
+ return tmpFull;
100
+ }
101
+
102
+ // Too long — hash segment VALUES (keeping each segment's abbreviation for readability) in shrink order
103
+ // until it fits. The hash is deterministic, so this stays stable across runs.
104
+ const tmpShrinkOrder = _shrinkOrder(tmpSegments.length);
105
+ for (let k = 0; k < tmpShrinkOrder.length; k++)
106
+ {
107
+ const tmpIndex = tmpShrinkOrder[k];
108
+ const tmpSegment = tmpSegments[tmpIndex] || {};
109
+ const tmpAbbrev = (tmpSegment.abbrev === null || tmpSegment.abbrev === undefined) ? '' : String(tmpSegment.abbrev);
110
+ tmpSegmentStrings[tmpIndex] = `${tmpAbbrev}${hashSegment(segmentString(tmpSegment), tmpHashLength)}`;
111
+ tmpFull = _assemble(tmpPrefix, tmpSegmentStrings, tmpSeparator);
112
+ if (tmpFull.length <= tmpMaxLength)
113
+ {
114
+ return tmpFull;
115
+ }
116
+ }
117
+
118
+ // Last resort (even all-hashed is too long, or a tiny column): collapse the whole body after the prefix
119
+ // into a single hash sized to the remaining budget.
120
+ const tmpBody = _assemble(tmpPrefix, tmpSegments.map(segmentString), tmpSeparator);
121
+ const tmpPrefixCost = tmpPrefix ? (tmpPrefix.length + tmpSeparator.length) : 0;
122
+ const tmpBudget = Math.max(6, tmpMaxLength - tmpPrefixCost);
123
+ tmpFull = (tmpPrefix ? `${tmpPrefix}${tmpSeparator}` : '') + hashSegment(tmpBody, tmpBudget);
124
+ return (tmpFull.length > tmpMaxLength) ? tmpFull.slice(0, tmpMaxLength) : tmpFull;
125
+ }
126
+
127
+ module.exports = {
128
+ composeGUID,
129
+ hashSegment,
130
+ segmentString,
131
+ };
@@ -0,0 +1,238 @@
1
+ // Meadow-Integration-GUIDStrategy — compiles a high-level, per-entity GUID strategy into the structured
2
+ // spec the transform consumes (which `Service-TabularTransform.createRecordFromMapping` resolves per row
3
+ // via the GUID composer). This is the configurable brain behind context-aware import GUIDs.
4
+ //
5
+ // It does NOT touch fable or the DOM and produces only plain data (segment value-TEMPLATES, field names,
6
+ // length budgets) — so it is reusable by the CLI, the server endpoints, and the browser import wizard
7
+ // alike, and is trivially unit-testable.
8
+ //
9
+ // Three modes per entity AND per join, mapped onto the adapter's existing FK conventions:
10
+ // - prefixed (default): compose `UI_<context…>_<own>`; own → `GUID<Entity>`. A join to a parent that
11
+ // was uploaded in THIS run → `GUID<Parent>` (sync session GUIDMap); a join to a PRE-EXISTING parent
12
+ // (crossSession) → `_GUID<Parent>` (async server `getEntityByGUID` lookup).
13
+ // - raw: the source already carries a true Meadow GUID → `_GUID<Parent>` (join) / `GUID<Entity>` (own).
14
+ // - rawid: the source already carries a real `ID<Parent>` → used directly.
15
+
16
+ const _DEFAULT_PREFIX = 'UI';
17
+ const _DEFAULT_SEPARATOR = '_';
18
+ const _DEFAULT_HASH_LENGTH = 10;
19
+
20
+ /** A `{~D:Record.<column>~}` value template (what the transform resolves per row). */
21
+ function valueTemplate(pColumn)
22
+ {
23
+ return `{~D:Record.${pColumn}~}`;
24
+ }
25
+
26
+ /** Derive a short uppercase abbreviation for an entity with no catalog entry (initials / first letters). */
27
+ function _deriveAbbreviation(pEntityName)
28
+ {
29
+ const tmpName = String(pEntityName || '');
30
+ const tmpCapitals = tmpName.replace(/[^A-Z]/g, '');
31
+ if (tmpCapitals.length >= 2)
32
+ {
33
+ return tmpCapitals.slice(0, 3);
34
+ }
35
+ return tmpName.slice(0, 3).toUpperCase();
36
+ }
37
+
38
+ /**
39
+ * @param {string} pEntityName @param {Record<string, any>} pContext
40
+ * @returns {string} the host-fixed abbreviation (catalog) or a derived fallback
41
+ */
42
+ function abbreviationFor(pEntityName, pContext)
43
+ {
44
+ const tmpCatalog = (pContext && pContext.Catalog) || {};
45
+ const tmpEntry = tmpCatalog[pEntityName];
46
+ if (tmpEntry && tmpEntry.Abbrev)
47
+ {
48
+ return String(tmpEntry.Abbrev);
49
+ }
50
+ return _deriveAbbreviation(pEntityName);
51
+ }
52
+
53
+ /** The GUID column width for an entity (from the live schema), or 0 (unbounded) if unknown. */
54
+ function _maxLengthFor(pEntityName, pContext)
55
+ {
56
+ const tmpSizes = (pContext && pContext.SchemaSizes) || {};
57
+ const tmpSize = Number(tmpSizes[pEntityName] || 0);
58
+ return (tmpSize > 0) ? tmpSize : 0;
59
+ }
60
+
61
+ /** Build a compose spec (prefix + ordered segments + length budget) for an entity's fullGUID. */
62
+ function _composeSpec(pEntityName, pSegments, pContext)
63
+ {
64
+ return {
65
+ prefix: (pContext && (pContext.Prefix !== undefined)) ? pContext.Prefix : _DEFAULT_PREFIX,
66
+ separator: (pContext && pContext.Separator) || _DEFAULT_SEPARATOR,
67
+ hashLength: (pContext && pContext.HashLength) || _DEFAULT_HASH_LENGTH,
68
+ maxLength: _maxLengthFor(pEntityName, pContext),
69
+ segments: pSegments,
70
+ };
71
+ }
72
+
73
+ /**
74
+ * The ordered context + own segments for an entity's OWN fullGUID.
75
+ * @returns {{segments:Array<any>, warnings:Array<string>}}
76
+ */
77
+ function _ownSegments(pEntityName, pEntityConfig, pContext)
78
+ {
79
+ const tmpWarnings = [];
80
+ const tmpSegments = [];
81
+ const tmpContextEntities = Array.isArray(pEntityConfig.ContextEntities) ? pEntityConfig.ContextEntities : [];
82
+ const tmpContextKeyColumns = pEntityConfig.ContextKeyColumns || {};
83
+
84
+ tmpContextEntities.forEach((pParentEntity) =>
85
+ {
86
+ const tmpKeyColumn = tmpContextKeyColumns[pParentEntity];
87
+ if (!tmpKeyColumn)
88
+ {
89
+ tmpWarnings.push(`Context entity "${pParentEntity}" for "${pEntityName}" has no key column mapped — its segment will be empty.`);
90
+ }
91
+ tmpSegments.push({ abbrev: abbreviationFor(pParentEntity, pContext), valueTemplate: tmpKeyColumn ? valueTemplate(tmpKeyColumn) : '' });
92
+ });
93
+
94
+ if (!pEntityConfig.OwnKeyColumn)
95
+ {
96
+ tmpWarnings.push(`"${pEntityName}" has no own-key column mapped — its GUID cannot be stable, so re-imports will create duplicates. Map a natural-key column (e.g. a code).`);
97
+ }
98
+ tmpSegments.push({ abbrev: abbreviationFor(pEntityName, pContext), valueTemplate: pEntityConfig.OwnKeyColumn ? valueTemplate(pEntityConfig.OwnKeyColumn) : '' });
99
+
100
+ return { segments: tmpSegments, warnings: tmpWarnings };
101
+ }
102
+
103
+ /** Compile the OWN GUID descriptor (field name + compose spec / raw template) for one entity. */
104
+ function _compileOwn(pEntityName, pEntityConfig, pContext)
105
+ {
106
+ const tmpGUIDName = `GUID${pEntityName}`;
107
+ const tmpMode = pEntityConfig.Mode || 'prefixed';
108
+
109
+ if (tmpMode === 'raw')
110
+ {
111
+ // Source carries a true Meadow GUID; with empty marshal prefixes it passes through GUID<Entity>.
112
+ return { Own: { Mode: 'raw', FieldName: tmpGUIDName, ValueTemplate: valueTemplate(pEntityConfig.OwnGUIDColumn || pEntityConfig.OwnKeyColumn) }, warnings: [] };
113
+ }
114
+ if (tmpMode === 'rawid')
115
+ {
116
+ // rawid OWN is deferred: the comprehension is keyed by GUID<Entity>, so an ID-only own record has
117
+ // no comprehension key. Use a join in rawid mode instead, or import such sheets via the raw path.
118
+ return { Own: { Mode: 'prefixed', FieldName: tmpGUIDName, Compose: _composeSpec(pEntityName, _ownSegments(pEntityName, pEntityConfig, pContext).segments, pContext) },
119
+ warnings: [ `"${pEntityName}" requested raw-ID own mode, which is not yet supported for the entity's own GUID — fell back to prefixed.` ] };
120
+ }
121
+
122
+ // prefixed (default)
123
+ const tmpOwn = _ownSegments(pEntityName, pEntityConfig, pContext);
124
+ return { Own: { Mode: 'prefixed', FieldName: tmpGUIDName, Compose: _composeSpec(pEntityName, tmpOwn.segments, pContext) }, warnings: tmpOwn.warnings };
125
+ }
126
+
127
+ /** The FK field name for a join, per the adapter's conventions. */
128
+ function joinFieldName(pParentEntity, pMode, pCrossSession)
129
+ {
130
+ if (pMode === 'rawid')
131
+ {
132
+ return `ID${pParentEntity}`;
133
+ }
134
+ if (pMode === 'raw')
135
+ {
136
+ return `_GUID${pParentEntity}`;
137
+ }
138
+ // prefixed: sync (same-upload) vs async server lookup (pre-existing parent)
139
+ return pCrossSession ? `_GUID${pParentEntity}` : `GUID${pParentEntity}`;
140
+ }
141
+
142
+ /** Compile one join (FK) descriptor for an entity. */
143
+ function _compileJoin(pParentEntity, pJoin, pContext)
144
+ {
145
+ const tmpMode = pJoin.Mode || 'prefixed';
146
+ const tmpCrossSession = !!pJoin.CrossSession;
147
+ const tmpFieldName = joinFieldName(pParentEntity, tmpMode, tmpCrossSession);
148
+ const tmpWarnings = [];
149
+
150
+ if (tmpMode === 'raw' || tmpMode === 'rawid')
151
+ {
152
+ const tmpColumn = pJoin.GUIDColumn || pJoin.IDColumn || pJoin.KeyColumn;
153
+ if (!tmpColumn)
154
+ {
155
+ tmpWarnings.push(`Join "${pParentEntity}" (${tmpMode}) has no source column mapped.`);
156
+ }
157
+ return { Join: { ParentEntity: pParentEntity, Mode: tmpMode, FieldName: tmpFieldName, ValueTemplate: tmpColumn ? valueTemplate(tmpColumn) : '' }, warnings: tmpWarnings };
158
+ }
159
+
160
+ // prefixed: recompute the PARENT's fullGUID from this row's columns (so it matches the parent's own GUID).
161
+ if (!pJoin.KeyColumn)
162
+ {
163
+ tmpWarnings.push(`Join "${pParentEntity}" (prefixed) has no parent key column mapped — the foreign key will not resolve.`);
164
+ }
165
+ const tmpParentContextEntities = Array.isArray(pJoin.ContextEntities) ? pJoin.ContextEntities : [];
166
+ const tmpParentContextKeyColumns = pJoin.ContextKeyColumns || {};
167
+ const tmpSegments = tmpParentContextEntities.map((pGrandparent) => (
168
+ { abbrev: abbreviationFor(pGrandparent, pContext), valueTemplate: tmpParentContextKeyColumns[pGrandparent] ? valueTemplate(tmpParentContextKeyColumns[pGrandparent]) : '' }));
169
+ tmpSegments.push({ abbrev: abbreviationFor(pParentEntity, pContext), valueTemplate: pJoin.KeyColumn ? valueTemplate(pJoin.KeyColumn) : '' });
170
+
171
+ return { Join: { ParentEntity: pParentEntity, Mode: 'prefixed', FieldName: tmpFieldName, Compose: _composeSpec(pParentEntity, tmpSegments, pContext) }, warnings: tmpWarnings };
172
+ }
173
+
174
+ /**
175
+ * Compile one entity's strategy (own GUID + joins).
176
+ * @param {string} pEntityName @param {Record<string, any>} pEntityConfig @param {Record<string, any>} pContext
177
+ * @returns {{Strategy:Record<string, any>, Warnings:Array<string>}}
178
+ */
179
+ function compileEntity(pEntityName, pEntityConfig, pContext)
180
+ {
181
+ const tmpConfig = pEntityConfig || {};
182
+ const tmpWarnings = [];
183
+
184
+ const tmpOwn = _compileOwn(pEntityName, tmpConfig, pContext);
185
+ tmpWarnings.push(...tmpOwn.warnings);
186
+
187
+ const tmpJoins = [];
188
+ (Array.isArray(tmpConfig.Joins) ? tmpConfig.Joins : []).forEach((pJoin) =>
189
+ {
190
+ if (!pJoin || !pJoin.ParentEntity) { return; }
191
+ const tmpCompiled = _compileJoin(pJoin.ParentEntity, pJoin, pContext);
192
+ tmpJoins.push(tmpCompiled.Join);
193
+ tmpWarnings.push(...tmpCompiled.warnings);
194
+ });
195
+
196
+ return {
197
+ Strategy: { Entity: pEntityName, GUIDName: `GUID${pEntityName}`, Own: tmpOwn.Own, Joins: tmpJoins },
198
+ Warnings: tmpWarnings,
199
+ };
200
+ }
201
+
202
+ /**
203
+ * Compile a whole import's GUID strategy.
204
+ * @param {Record<string, any>} pConfig - { Prefix?, Separator?, HashLength?, Entities:{ <name>: <entityConfig> } }
205
+ * @param {Record<string, any>} pContext - { Catalog:{ <entity>:{Abbrev,KeyField} }, SchemaSizes:{ <entity>:size } }
206
+ * @returns {{Strategies:Record<string, any>, Warnings:Array<string>}}
207
+ */
208
+ function compile(pConfig, pContext)
209
+ {
210
+ const tmpConfig = pConfig || {};
211
+ const tmpContext = Object.assign(
212
+ {
213
+ Prefix: (tmpConfig.Prefix !== undefined) ? tmpConfig.Prefix : _DEFAULT_PREFIX,
214
+ Separator: tmpConfig.Separator || _DEFAULT_SEPARATOR,
215
+ HashLength: tmpConfig.HashLength || _DEFAULT_HASH_LENGTH,
216
+ },
217
+ pContext || {});
218
+
219
+ const tmpEntities = tmpConfig.Entities || {};
220
+ const tmpStrategies = {};
221
+ const tmpWarnings = [];
222
+ Object.keys(tmpEntities).forEach((pEntityName) =>
223
+ {
224
+ const tmpCompiled = compileEntity(pEntityName, tmpEntities[pEntityName], tmpContext);
225
+ tmpStrategies[pEntityName] = tmpCompiled.Strategy;
226
+ tmpWarnings.push(...tmpCompiled.Warnings);
227
+ });
228
+
229
+ return { Strategies: tmpStrategies, Warnings: tmpWarnings };
230
+ }
231
+
232
+ module.exports = {
233
+ compile,
234
+ compileEntity,
235
+ joinFieldName,
236
+ abbreviationFor,
237
+ valueTemplate,
238
+ };
@@ -1,5 +1,7 @@
1
1
  const libFableService = require('fable-serviceproviderbase');
2
2
 
3
+ const libGUIDComposer = require('../guid/Meadow-Integration-GUIDComposer.js');
4
+
3
5
  /*
4
6
  // Comprehension Parameters
5
7
  // This can be *either* a mapping file, in the following format, or a set of parameters listed below. The mapping file lets you map columns way easier!
@@ -134,7 +136,17 @@ class MeadowIntegrationTabularTransform extends libFableService
134
136
  {
135
137
  let tmpRecord = ((typeof(pRecordPrototype) == 'object') && (pRecordPrototype != null)) ? JSON.parse(JSON.stringify(pRecordPrototype)) : {};
136
138
 
137
- tmpRecord[pMapping.GUIDName] = this.fable.parseTemplate(pMapping.GUIDTemplate, pRecord);
139
+ // Opt-in context-aware GUID strategy: compose the entity's own GUID + its foreign-key GUID/ID
140
+ // fields from a structured spec (length-safe + deterministic). Falls back to the flat GUIDTemplate
141
+ // when no strategy is attached, so every existing mapping behaves exactly as before.
142
+ if (pMapping.GUIDStrategy)
143
+ {
144
+ this._applyGUIDStrategy(tmpRecord, pMapping.GUIDStrategy, pRecord);
145
+ }
146
+ else
147
+ {
148
+ tmpRecord[pMapping.GUIDName] = this.fable.parseTemplate(pMapping.GUIDTemplate, pRecord);
149
+ }
138
150
 
139
151
  let tmpKeys = Object.keys(pMapping.Mappings);
140
152
  for (let i = 0; i < tmpKeys.length; i++)
@@ -153,6 +165,56 @@ class MeadowIntegrationTabularTransform extends libFableService
153
165
  return tmpRecord;
154
166
  }
155
167
 
168
+ /**
169
+ * Resolve a GUID compose spec's segment value-templates against the row, then run the deterministic
170
+ * length-safe composer. The compiler (Meadow-Integration-GUIDStrategy) produced the spec.
171
+ * @param {Record<string, any>} pComposeSpec @param {Record<string, any>} pRecord
172
+ * @returns {string}
173
+ */
174
+ composeGUIDFromSpec(pComposeSpec, pRecord)
175
+ {
176
+ let tmpSegments = (Array.isArray(pComposeSpec.segments) ? pComposeSpec.segments : []).map((pSegment) =>
177
+ {
178
+ return {
179
+ abbrev: pSegment.abbrev,
180
+ value: pSegment.valueTemplate ? this.fable.parseTemplate(pSegment.valueTemplate, pRecord) : '',
181
+ };
182
+ });
183
+ return libGUIDComposer.composeGUID(
184
+ {
185
+ prefix: pComposeSpec.prefix,
186
+ separator: pComposeSpec.separator,
187
+ maxLength: pComposeSpec.maxLength,
188
+ hashLength: pComposeSpec.hashLength,
189
+ segments: tmpSegments,
190
+ });
191
+ }
192
+
193
+ /**
194
+ * Stamp an entity's own GUID + its foreign-key fields onto the record from a compiled GUID strategy.
195
+ * Own / each join is one of: prefixed (Compose spec) or raw/rawid (a resolved source-column template).
196
+ * @param {Record<string, any>} pTargetRecord @param {Record<string, any>} pStrategy @param {Record<string, any>} pRecord
197
+ */
198
+ _applyGUIDStrategy(pTargetRecord, pStrategy, pRecord)
199
+ {
200
+ let tmpOwn = pStrategy.Own || {};
201
+ if (tmpOwn.FieldName)
202
+ {
203
+ pTargetRecord[tmpOwn.FieldName] = tmpOwn.Compose
204
+ ? this.composeGUIDFromSpec(tmpOwn.Compose, pRecord)
205
+ : (tmpOwn.ValueTemplate ? this.fable.parseTemplate(tmpOwn.ValueTemplate, pRecord) : '');
206
+ }
207
+ let tmpJoins = Array.isArray(pStrategy.Joins) ? pStrategy.Joins : [];
208
+ for (let i = 0; i < tmpJoins.length; i++)
209
+ {
210
+ let tmpJoin = tmpJoins[i];
211
+ if (!tmpJoin || !tmpJoin.FieldName) { continue; }
212
+ pTargetRecord[tmpJoin.FieldName] = tmpJoin.Compose
213
+ ? this.composeGUIDFromSpec(tmpJoin.Compose, pRecord)
214
+ : (tmpJoin.ValueTemplate ? this.fable.parseTemplate(tmpJoin.ValueTemplate, pRecord) : '');
215
+ }
216
+ }
217
+
156
218
  addRecordToComprehension(pIncomingRecord, pMappingOutcome, pNewRecordPrototype, pGUIDUniquenessString)
157
219
  {
158
220
  let tmpNewRecordPrototype = (typeof(pNewRecordPrototype) == 'object') ? pNewRecordPrototype : {};
@@ -0,0 +1,162 @@
1
+ /*
2
+ Unit tests for the context-aware GUID composer (Meadow-Integration-GUIDComposer).
3
+
4
+ Pure module — no fable, no network. Verifies deterministic composition, the length-safe shrink-by-hash
5
+ behavior across the real GUID column widths (36 / 64 / 128), and idempotency (same input → same GUID).
6
+ */
7
+
8
+ const Chai = require('chai');
9
+ const Expect = Chai.expect;
10
+
11
+ const libComposer = require('../source/services/guid/Meadow-Integration-GUIDComposer.js');
12
+
13
+ const projectSpec = (pCode, pMaxLength) => (
14
+ {
15
+ prefix: 'UI',
16
+ maxLength: pMaxLength,
17
+ segments: [ { abbrev: 'P', value: pCode } ],
18
+ });
19
+
20
+ const lineItemSpec = (pContract, pProject, pLineId, pMaxLength) => (
21
+ {
22
+ prefix: 'UI',
23
+ maxLength: pMaxLength,
24
+ segments: [ { abbrev: 'C', value: pContract }, { abbrev: 'P', value: pProject }, { abbrev: 'LI', value: pLineId } ],
25
+ });
26
+
27
+ suite
28
+ (
29
+ 'Meadow Integration — GUID Composer',
30
+ () =>
31
+ {
32
+ suite
33
+ (
34
+ 'composition',
35
+ () =>
36
+ {
37
+ test
38
+ (
39
+ 'composes prefix + segments with underscores (the headline example)',
40
+ () =>
41
+ {
42
+ const tmpGUID = libComposer.composeGUID(lineItemSpec('10', '01278', '8675309', 64));
43
+ Expect(tmpGUID).to.equal('UI_C10_P01278_LI8675309');
44
+ }
45
+ );
46
+ test
47
+ (
48
+ 'a top-level entity composes prefix + own segment',
49
+ () =>
50
+ {
51
+ Expect(libComposer.composeGUID(projectSpec('01278', 64))).to.equal('UI_P01278');
52
+ }
53
+ );
54
+ test
55
+ (
56
+ 'omits the prefix (and its separator) when falsy',
57
+ () =>
58
+ {
59
+ const tmpGUID = libComposer.composeGUID({ prefix: '', maxLength: 64, segments: [ { abbrev: 'P', value: '01278' } ] });
60
+ Expect(tmpGUID).to.equal('P01278');
61
+ }
62
+ );
63
+ test
64
+ (
65
+ 'a child FK recomputed from the parent spec matches the parent\'s own GUID',
66
+ () =>
67
+ {
68
+ // Parent (Project) own GUID, and the same Project spec evaluated on the child's row values.
69
+ const tmpParentOwn = libComposer.composeGUID(projectSpec('01278', 64));
70
+ const tmpChildFK = libComposer.composeGUID(projectSpec('01278', 64));
71
+ Expect(tmpChildFK).to.equal(tmpParentOwn);
72
+ }
73
+ );
74
+ }
75
+ );
76
+ suite
77
+ (
78
+ 'determinism',
79
+ () =>
80
+ {
81
+ test
82
+ (
83
+ 'same input → same GUID, every time',
84
+ () =>
85
+ {
86
+ const tmpA = libComposer.composeGUID(lineItemSpec('10', 'VERYLONGPROJECTCODE-001278-REGION-NORTH', '8675309', 36));
87
+ const tmpB = libComposer.composeGUID(lineItemSpec('10', 'VERYLONGPROJECTCODE-001278-REGION-NORTH', '8675309', 36));
88
+ Expect(tmpA).to.equal(tmpB);
89
+ }
90
+ );
91
+ test
92
+ (
93
+ 'hashSegment is deterministic + length-bounded',
94
+ () =>
95
+ {
96
+ Expect(libComposer.hashSegment('P01278', 10)).to.equal(libComposer.hashSegment('P01278', 10));
97
+ Expect(libComposer.hashSegment('P01278', 10).length).to.be.at.most(10);
98
+ Expect(libComposer.hashSegment('A')).to.not.equal(libComposer.hashSegment('B'));
99
+ }
100
+ );
101
+ }
102
+ );
103
+ suite
104
+ (
105
+ 'length-safety (real column widths)',
106
+ () =>
107
+ {
108
+ test
109
+ (
110
+ 'fits inside a 36-char column by hashing, staying prefixed + stable',
111
+ () =>
112
+ {
113
+ const tmpSpec = lineItemSpec('CONTRACT-2026-NORTH', 'PROJECT-01278-HIGHWAY-10-REPAVE', 'LINEITEM-8675309-CONCRETE', 36);
114
+ const tmpGUID = libComposer.composeGUID(tmpSpec);
115
+ Expect(tmpGUID.length, tmpGUID).to.be.at.most(36);
116
+ Expect(tmpGUID.indexOf('UI_'), 'keeps the UI prefix').to.equal(0);
117
+ // idempotent
118
+ Expect(libComposer.composeGUID(tmpSpec)).to.equal(tmpGUID);
119
+ }
120
+ );
121
+ test
122
+ (
123
+ 'a 64-char column keeps the readable form when it already fits',
124
+ () =>
125
+ {
126
+ Expect(libComposer.composeGUID(lineItemSpec('10', '01278', '8675309', 64))).to.equal('UI_C10_P01278_LI8675309');
127
+ }
128
+ );
129
+ test
130
+ (
131
+ 'a 128-char column has ample room (PhysicalAsset-like)',
132
+ () =>
133
+ {
134
+ const tmpGUID = libComposer.composeGUID(lineItemSpec('10', '01278', '8675309', 128));
135
+ Expect(tmpGUID).to.equal('UI_C10_P01278_LI8675309');
136
+ Expect(tmpGUID.length).to.be.at.most(128);
137
+ }
138
+ );
139
+ test
140
+ (
141
+ 'an unbounded (maxLength <= 0) compose never hashes',
142
+ () =>
143
+ {
144
+ const tmpGUID = libComposer.composeGUID(lineItemSpec('CONTRACT-2026', 'PROJECT-01278-HIGHWAY', 'LINEITEM-8675309', 0));
145
+ Expect(tmpGUID).to.equal('UI_CCONTRACT-2026_PPROJECT-01278-HIGHWAY_LILINEITEM-8675309');
146
+ }
147
+ );
148
+ test
149
+ (
150
+ 'a pathologically tiny column still produces a stable, in-budget GUID',
151
+ () =>
152
+ {
153
+ const tmpSpec = lineItemSpec('CONTRACT-LONG', 'PROJECT-LONG', 'LINEITEM-LONG', 16);
154
+ const tmpGUID = libComposer.composeGUID(tmpSpec);
155
+ Expect(tmpGUID.length, tmpGUID).to.be.at.most(16);
156
+ Expect(libComposer.composeGUID(tmpSpec)).to.equal(tmpGUID);
157
+ }
158
+ );
159
+ }
160
+ );
161
+ }
162
+ );
@@ -0,0 +1,144 @@
1
+ /*
2
+ Integration test: the GUID strategy flows end-to-end through the real TabularTransform.
3
+
4
+ Compiler (config → spec) → transform (resolves segment templates per row) → composer (length-safe) →
5
+ comprehension record with the entity's own GUID + its foreign-key GUID/ID fields. Uses a pict instance
6
+ as the host fable (pict provides `parseTemplate`) — the same shape the browser import wizard passes.
7
+ */
8
+
9
+ const Chai = require('chai');
10
+ const Expect = Chai.expect;
11
+
12
+ const libPict = require('pict');
13
+
14
+ const libEngine = require('../source/Meadow-Integration-Engine.js');
15
+
16
+ const newTransform = () =>
17
+ {
18
+ const tmpPict = new libPict({ Product: 'GUIDStrategyTransformTest', LogStreams: [ { streamtype: 'console', level: 'error' } ] });
19
+ return new libEngine.MeadowIntegrationTabularTransform(tmpPict);
20
+ };
21
+
22
+ const CATALOG =
23
+ {
24
+ Project: { Abbrev: 'P' },
25
+ Contract: { Abbrev: 'C' },
26
+ LineItem: { Abbrev: 'LI' },
27
+ };
28
+ const SIZES = { Project: 64, Contract: 64, LineItem: 64 };
29
+
30
+ // Build the comprehension for one entity mapping over one row (mirrors DataImport-ComprehensionBuilder).
31
+ const runOneEntity = (pTransform, pMapping, pRow) =>
32
+ {
33
+ const tmpOutcome = pTransform.newMappingOutcomeObject();
34
+ tmpOutcome.ImplicitConfiguration = {};
35
+ tmpOutcome.ExplicitConfiguration = pMapping;
36
+ pTransform.initializeMappingOutcomeObject(tmpOutcome);
37
+ pTransform.transformRecord(pRow, tmpOutcome);
38
+ return tmpOutcome.Comprehension[pMapping.Entity] || {};
39
+ };
40
+
41
+ suite
42
+ (
43
+ 'Meadow Integration — GUID strategy through the transform',
44
+ () =>
45
+ {
46
+ test
47
+ (
48
+ 'composes a top-level entity own GUID from the strategy',
49
+ () =>
50
+ {
51
+ const tmpTransform = newTransform();
52
+ const tmpStrategy = libEngine.compileGUIDStrategy(
53
+ { Prefix: 'UI', Entities: { Project: { Mode: 'prefixed', OwnKeyColumn: 'ProjectCode' } } },
54
+ { Catalog: CATALOG, SchemaSizes: SIZES }).Strategies.Project;
55
+ const tmpMapping = { Entity: 'Project', GUIDName: 'GUIDProject', GUIDStrategy: tmpStrategy, Mappings: { Name: '{~D:Record.ProjectName~}' } };
56
+
57
+ const tmpComprehension = runOneEntity(tmpTransform, tmpMapping, { ProjectCode: '01278', ProjectName: 'Highway 10 Repave' });
58
+ const tmpKeys = Object.keys(tmpComprehension);
59
+ Expect(tmpKeys).to.deep.equal([ 'UI_P01278' ]);
60
+ Expect(tmpComprehension['UI_P01278'].GUIDProject).to.equal('UI_P01278');
61
+ Expect(tmpComprehension['UI_P01278'].Name).to.equal('Highway 10 Repave');
62
+ }
63
+ );
64
+ test
65
+ (
66
+ 'composes a child own GUID (context chain) + cross-session FK fields',
67
+ () =>
68
+ {
69
+ const tmpTransform = newTransform();
70
+ const tmpStrategy = libEngine.compileGUIDStrategy(
71
+ {
72
+ Prefix: 'UI',
73
+ Entities:
74
+ {
75
+ LineItem:
76
+ {
77
+ Mode: 'prefixed',
78
+ OwnKeyColumn: 'LineId',
79
+ ContextEntities: [ 'Contract', 'Project' ],
80
+ ContextKeyColumns: { Contract: 'ContractNum', Project: 'ProjectCode' },
81
+ Joins:
82
+ [
83
+ { ParentEntity: 'Project', Mode: 'prefixed', KeyColumn: 'ProjectCode', CrossSession: true },
84
+ { ParentEntity: 'Contract', Mode: 'prefixed', KeyColumn: 'ContractNum', CrossSession: true },
85
+ ],
86
+ },
87
+ },
88
+ },
89
+ { Catalog: CATALOG, SchemaSizes: SIZES }).Strategies.LineItem;
90
+ const tmpMapping = { Entity: 'LineItem', GUIDName: 'GUIDLineItem', GUIDStrategy: tmpStrategy, Mappings: { Quantity: '{~D:Record.qty~}' } };
91
+
92
+ const tmpRow = { ContractNum: '10', ProjectCode: '01278', LineId: '8675309', qty: '5' };
93
+ const tmpComprehension = runOneEntity(tmpTransform, tmpMapping, tmpRow);
94
+ const tmpRecord = tmpComprehension['UI_C10_P01278_LI8675309'];
95
+ Expect(tmpRecord, 'record keyed by composed own GUID').to.be.an('object');
96
+ Expect(tmpRecord.GUIDLineItem).to.equal('UI_C10_P01278_LI8675309');
97
+ // Cross-session FK fields use the `_GUID<Parent>` async-lookup convention + the parent's own GUID.
98
+ Expect(tmpRecord._GUIDProject).to.equal('UI_P01278');
99
+ Expect(tmpRecord._GUIDContract).to.equal('UI_C10');
100
+ Expect(tmpRecord.Quantity).to.equal('5');
101
+ }
102
+ );
103
+ test
104
+ (
105
+ 'the child FK equals the parent\'s own GUID (so the join resolves) — and is idempotent',
106
+ () =>
107
+ {
108
+ const tmpTransform = newTransform();
109
+ const tmpCompiled = libEngine.compileGUIDStrategy(
110
+ {
111
+ Prefix: 'UI',
112
+ Entities:
113
+ {
114
+ Project: { Mode: 'prefixed', OwnKeyColumn: 'ProjectCode' },
115
+ LineItem: { Mode: 'prefixed', OwnKeyColumn: 'LineId', Joins: [ { ParentEntity: 'Project', Mode: 'prefixed', KeyColumn: 'ProjectCode', CrossSession: false } ] },
116
+ },
117
+ },
118
+ { Catalog: CATALOG, SchemaSizes: SIZES }).Strategies;
119
+
120
+ const tmpProjectComp = runOneEntity(tmpTransform, { Entity: 'Project', GUIDName: 'GUIDProject', GUIDStrategy: tmpCompiled.Project, Mappings: {} }, { ProjectCode: '01278' });
121
+ const tmpLineComp = runOneEntity(tmpTransform, { Entity: 'LineItem', GUIDName: 'GUIDLineItem', GUIDStrategy: tmpCompiled.LineItem, Mappings: {} }, { ProjectCode: '01278', LineId: '42' });
122
+
123
+ const tmpProjectGUID = Object.keys(tmpProjectComp)[0];
124
+ // same-upload join → sync `GUID<Parent>` field, value == the Project's own GUID
125
+ Expect(tmpLineComp['UI_LI42'].GUIDProject).to.equal(tmpProjectGUID);
126
+ // re-running yields the identical record (idempotent)
127
+ const tmpLineComp2 = runOneEntity(newTransform(), { Entity: 'LineItem', GUIDName: 'GUIDLineItem', GUIDStrategy: tmpCompiled.LineItem, Mappings: {} }, { ProjectCode: '01278', LineId: '42' });
128
+ Expect(Object.keys(tmpLineComp2)).to.deep.equal([ 'UI_LI42' ]);
129
+ }
130
+ );
131
+ test
132
+ (
133
+ 'a non-strategy mapping still uses the flat GUIDTemplate (no behavior change)',
134
+ () =>
135
+ {
136
+ const tmpTransform = newTransform();
137
+ const tmpMapping = { Entity: 'Airport', GUIDName: 'GUIDAirport', GUIDTemplate: 'Airport_{~D:Record.iata~}', Mappings: { Name: '{~D:Record.name~}' } };
138
+ const tmpComprehension = runOneEntity(tmpTransform, tmpMapping, { iata: 'BTR', name: 'Baton Rouge' });
139
+ Expect(Object.keys(tmpComprehension)).to.deep.equal([ 'Airport_BTR' ]);
140
+ Expect(tmpComprehension['Airport_BTR'].Name).to.equal('Baton Rouge');
141
+ }
142
+ );
143
+ }
144
+ );
@@ -0,0 +1,150 @@
1
+ /*
2
+ Unit tests for the GUID strategy compiler (Meadow-Integration-GUIDStrategy).
3
+
4
+ Pure module — turns a per-entity strategy config + a host context catalog + schema GUID sizes into the
5
+ structured spec the transform consumes. Verifies own/context composition, the per-join FK field-name
6
+ selection (mapping to the adapter's GUID / _GUID / ID conventions), and the no-key warning.
7
+ */
8
+
9
+ const Chai = require('chai');
10
+ const Expect = Chai.expect;
11
+
12
+ const libStrategy = require('../source/services/guid/Meadow-Integration-GUIDStrategy.js');
13
+ const libComposer = require('../source/services/guid/Meadow-Integration-GUIDComposer.js');
14
+
15
+ const CATALOG =
16
+ {
17
+ Project: { Abbrev: 'P', KeyField: 'Code' },
18
+ Contract: { Abbrev: 'C', KeyField: 'Number' },
19
+ LineItem: { Abbrev: 'LI', KeyField: 'Code' },
20
+ };
21
+ const SIZES = { Project: 64, Contract: 64, LineItem: 64 };
22
+
23
+ const CONFIG =
24
+ {
25
+ Prefix: 'UI',
26
+ Entities:
27
+ {
28
+ Project: { Mode: 'prefixed', OwnKeyColumn: 'ProjectCode' },
29
+ LineItem:
30
+ {
31
+ Mode: 'prefixed',
32
+ OwnKeyColumn: 'LineId',
33
+ ContextEntities: [ 'Contract', 'Project' ],
34
+ ContextKeyColumns: { Contract: 'ContractNum', Project: 'ProjectCode' },
35
+ Joins:
36
+ [
37
+ { ParentEntity: 'Project', Mode: 'prefixed', KeyColumn: 'ProjectCode', CrossSession: true },
38
+ { ParentEntity: 'Contract', Mode: 'prefixed', KeyColumn: 'ContractNum', CrossSession: true },
39
+ ],
40
+ },
41
+ },
42
+ };
43
+
44
+ suite
45
+ (
46
+ 'Meadow Integration — GUID Strategy compiler',
47
+ () =>
48
+ {
49
+ test
50
+ (
51
+ 'compiles a top-level entity to a single-segment own GUID spec',
52
+ () =>
53
+ {
54
+ const tmpResult = libStrategy.compile(CONFIG, { Catalog: CATALOG, SchemaSizes: SIZES });
55
+ const tmpProject = tmpResult.Strategies.Project;
56
+ Expect(tmpProject.GUIDName).to.equal('GUIDProject');
57
+ Expect(tmpProject.Own.FieldName).to.equal('GUIDProject');
58
+ Expect(tmpProject.Own.Compose.prefix).to.equal('UI');
59
+ Expect(tmpProject.Own.Compose.maxLength).to.equal(64);
60
+ Expect(tmpProject.Own.Compose.segments).to.have.length(1);
61
+ Expect(tmpProject.Own.Compose.segments[0]).to.deep.equal({ abbrev: 'P', valueTemplate: '{~D:Record.ProjectCode~}' });
62
+ }
63
+ );
64
+ test
65
+ (
66
+ 'composes the headline LineItem own GUID from context + own segments',
67
+ () =>
68
+ {
69
+ const tmpLineItem = libStrategy.compile(CONFIG, { Catalog: CATALOG, SchemaSizes: SIZES }).Strategies.LineItem;
70
+ const tmpSegments = tmpLineItem.Own.Compose.segments;
71
+ Expect(tmpSegments).to.have.length(3);
72
+ Expect(tmpSegments.map((s) => s.abbrev)).to.deep.equal([ 'C', 'P', 'LI' ]);
73
+ // Resolve the templates against a sample row + run the composer → the headline string.
74
+ const tmpRow = { ContractNum: '10', ProjectCode: '01278', LineId: '8675309' };
75
+ const tmpResolved = tmpSegments.map((s) => ({ abbrev: s.abbrev, value: tmpRow[s.valueTemplate.replace('{~D:Record.', '').replace('~}', '')] }));
76
+ Expect(libComposer.composeGUID({ prefix: 'UI', maxLength: 64, segments: tmpResolved })).to.equal('UI_C10_P01278_LI8675309');
77
+ }
78
+ );
79
+ test
80
+ (
81
+ 'a cross-session prefixed join emits `_GUID<Parent>` (async server lookup) + the parent compose spec',
82
+ () =>
83
+ {
84
+ const tmpLineItem = libStrategy.compile(CONFIG, { Catalog: CATALOG, SchemaSizes: SIZES }).Strategies.LineItem;
85
+ const tmpProjectJoin = tmpLineItem.Joins.find((j) => j.ParentEntity === 'Project');
86
+ Expect(tmpProjectJoin.FieldName).to.equal('_GUIDProject');
87
+ Expect(tmpProjectJoin.Compose.segments).to.deep.equal([ { abbrev: 'P', valueTemplate: '{~D:Record.ProjectCode~}' } ]);
88
+ Expect(tmpProjectJoin.Compose.maxLength).to.equal(64);
89
+ }
90
+ );
91
+ test
92
+ (
93
+ 'join field-name selection covers all modes',
94
+ () =>
95
+ {
96
+ Expect(libStrategy.joinFieldName('Project', 'prefixed', false)).to.equal('GUIDProject'); // same-upload, sync
97
+ Expect(libStrategy.joinFieldName('Project', 'prefixed', true)).to.equal('_GUIDProject'); // cross-session, async
98
+ Expect(libStrategy.joinFieldName('Project', 'raw', false)).to.equal('_GUIDProject'); // raw meadow GUID
99
+ Expect(libStrategy.joinFieldName('Project', 'rawid', false)).to.equal('IDProject'); // raw id
100
+ }
101
+ );
102
+ test
103
+ (
104
+ 'a same-upload prefixed join emits sync `GUID<Parent>`',
105
+ () =>
106
+ {
107
+ const tmpConfig = { Prefix: 'UI', Entities: { LineItem: { OwnKeyColumn: 'LineId', Joins: [ { ParentEntity: 'Project', Mode: 'prefixed', KeyColumn: 'ProjectCode', CrossSession: false } ] } } };
108
+ const tmpJoin = libStrategy.compile(tmpConfig, { Catalog: CATALOG, SchemaSizes: SIZES }).Strategies.LineItem.Joins[0];
109
+ Expect(tmpJoin.FieldName).to.equal('GUIDProject');
110
+ }
111
+ );
112
+ test
113
+ (
114
+ 'raw + rawid joins resolve a source column directly',
115
+ () =>
116
+ {
117
+ const tmpConfig =
118
+ {
119
+ Entities:
120
+ {
121
+ LineItem:
122
+ {
123
+ OwnKeyColumn: 'LineId',
124
+ Joins:
125
+ [
126
+ { ParentEntity: 'Project', Mode: 'raw', GUIDColumn: 'ProjectGUID' },
127
+ { ParentEntity: 'Contract', Mode: 'rawid', IDColumn: 'ContractID' },
128
+ ],
129
+ },
130
+ },
131
+ };
132
+ const tmpJoins = libStrategy.compile(tmpConfig, { Catalog: CATALOG, SchemaSizes: SIZES }).Strategies.LineItem.Joins;
133
+ Expect(tmpJoins[0]).to.deep.include({ FieldName: '_GUIDProject', Mode: 'raw', ValueTemplate: '{~D:Record.ProjectGUID~}' });
134
+ Expect(tmpJoins[1]).to.deep.include({ FieldName: 'IDContract', Mode: 'rawid', ValueTemplate: '{~D:Record.ContractID~}' });
135
+ }
136
+ );
137
+ test
138
+ (
139
+ 'warns (does not throw) when an entity has no own-key column — the dup-on-reimport fallback',
140
+ () =>
141
+ {
142
+ const tmpResult = libStrategy.compile({ Entities: { Widget: { Mode: 'prefixed' } } }, { Catalog: {}, SchemaSizes: {} });
143
+ Expect(tmpResult.Warnings.join(' ')).to.match(/no own-key column/i);
144
+ // still produces a usable strategy (derived abbreviation, unbounded length)
145
+ Expect(tmpResult.Strategies.Widget.Own.FieldName).to.equal('GUIDWidget');
146
+ Expect(tmpResult.Strategies.Widget.Own.Compose.segments[0].abbrev).to.equal('WID');
147
+ }
148
+ );
149
+ }
150
+ );
@@ -0,0 +1,71 @@
1
+ /*
2
+ Unit tests for the adapter's opt-in GUIDTruncationStrategy (the marshaling-path length safety).
3
+
4
+ "substring" (default) preserves the external GUID and truncates the prefix — existing behavior.
5
+ "hash" keeps the prefix and deterministically hashes the external GUID to fit — stable across runs.
6
+ */
7
+
8
+ const Chai = require('chai');
9
+ const Expect = Chai.expect;
10
+
11
+ const libFable = require('fable');
12
+
13
+ const libEngine = require('../source/Meadow-Integration-Engine.js');
14
+
15
+ const newAdapter = (pOptions) =>
16
+ {
17
+ const tmpFable = new libFable({ Product: 'GUIDTruncTest', LogStreams: [ { streamtype: 'console', level: 'error' } ] });
18
+ return new libEngine.MeadowIntegrationAdapter(tmpFable, Object.assign(
19
+ {
20
+ Entity: 'Specification',
21
+ AllowGUIDTruncation: true,
22
+ GUIDMaxLength: 36,
23
+ AdapterSetGUIDMarshalPrefix: 'HLICLI',
24
+ EntityGUIDMarshalPrefix: 'E-Specification',
25
+ }, pOptions || {}), 'GUIDTruncTest');
26
+ };
27
+
28
+ const EXTERNAL = 'SpecSet-2026-001278'; // 19 chars; prefix + this exceeds 36, exercising truncation
29
+
30
+ suite
31
+ (
32
+ 'Meadow Integration — adapter GUID truncation strategy',
33
+ () =>
34
+ {
35
+ test
36
+ (
37
+ 'substring (default) keeps the external GUID whole + truncates the prefix',
38
+ () =>
39
+ {
40
+ const tmpAdapter = newAdapter({ GUIDTruncationStrategy: 'substring' });
41
+ const tmpGUID = tmpAdapter.generateMeadowGUIDFromExternalGUID(EXTERNAL);
42
+ Expect(tmpGUID.length, tmpGUID).to.be.at.most(36);
43
+ Expect(tmpGUID.endsWith(EXTERNAL), 'external GUID preserved at the tail').to.equal(true);
44
+ }
45
+ );
46
+ test
47
+ (
48
+ 'hash keeps the prefix whole + hashes the external GUID to fit, deterministically',
49
+ () =>
50
+ {
51
+ const tmpAdapter = newAdapter({ GUIDTruncationStrategy: 'hash' });
52
+ const tmpGUID = tmpAdapter.generateMeadowGUIDFromExternalGUID(EXTERNAL);
53
+ Expect(tmpGUID.length, tmpGUID).to.be.at.most(36);
54
+ Expect(tmpGUID.indexOf(tmpAdapter.GUIDPrefix), 'prefix preserved at the head').to.equal(0);
55
+ Expect(tmpGUID).to.not.equal(EXTERNAL);
56
+ // deterministic across adapter instances
57
+ Expect(newAdapter({ GUIDTruncationStrategy: 'hash' }).generateMeadowGUIDFromExternalGUID(EXTERNAL)).to.equal(tmpGUID);
58
+ }
59
+ );
60
+ test
61
+ (
62
+ 'a short GUID is unchanged regardless of strategy',
63
+ () =>
64
+ {
65
+ const tmpShort = 'ABC';
66
+ Expect(newAdapter({ GUIDTruncationStrategy: 'hash' }).generateMeadowGUIDFromExternalGUID(tmpShort))
67
+ .to.equal(newAdapter({ GUIDTruncationStrategy: 'substring' }).generateMeadowGUIDFromExternalGUID(tmpShort));
68
+ }
69
+ );
70
+ }
71
+ );