meadow-integration 1.1.0 → 1.1.2
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/.github/workflows/publish-image.yml +2 -2
- package/BUILDING-AND-PUBLISHING.md +5 -5
- package/CONTRIBUTING.md +1 -1
- package/README.md +1 -1
- package/package.json +3 -2
- package/source/services/clone/Meadow-Service-Sync-Entity-Initial.js +9 -1
- package/source/services/guid/Meadow-Integration-GUIDStrategy.js +52 -6
- package/test/Meadow-Integration-ComprehensionPush_test.js +2 -1
- package/test/Meadow-Integration-GUIDStrategyTransform_test.js +32 -0
- package/test/Meadow-Integration-GUIDStrategy_test.js +23 -0
|
@@ -9,8 +9,8 @@
|
|
|
9
9
|
# - One step to wire: GITHUB_TOKEN already has `packages: write`
|
|
10
10
|
#
|
|
11
11
|
# Image lands at:
|
|
12
|
-
# ghcr.io/
|
|
13
|
-
# ghcr.io/
|
|
12
|
+
# ghcr.io/fable-retold/meadow-integration:<version>
|
|
13
|
+
# ghcr.io/fable-retold/meadow-integration:latest (only on the
|
|
14
14
|
# highest stable tag)
|
|
15
15
|
#
|
|
16
16
|
# Manual run: `Actions → Publish container image → Run workflow`,
|
|
@@ -170,7 +170,7 @@ postpublish: BUILD_DOCKER=1 → tag + push ← image trigger
|
|
|
170
170
|
↓ (tag arrives at GitHub)
|
|
171
171
|
.github/workflows/publish-image.yml fires:
|
|
172
172
|
- docker buildx build --platform linux/amd64,linux/arm64
|
|
173
|
-
- docker push ghcr.io/
|
|
173
|
+
- docker push ghcr.io/fable-retold/meadow-integration:<version>
|
|
174
174
|
- tags: <version>, <major>.<minor>, <major>, latest
|
|
175
175
|
```
|
|
176
176
|
|
|
@@ -189,14 +189,14 @@ After `release:patch` completes:
|
|
|
189
189
|
2. **GHCR workflow**: visit
|
|
190
190
|
`https://github.com/fable-retold/meadow-integration/actions` and
|
|
191
191
|
confirm the "Publish container image" run succeeded.
|
|
192
|
-
3. **Image**: `docker pull ghcr.io/
|
|
192
|
+
3. **Image**: `docker pull ghcr.io/fable-retold/meadow-integration:<version>`
|
|
193
193
|
should succeed. The image is also tagged as `latest`, `<major>`, and
|
|
194
194
|
`<major>.<minor>`.
|
|
195
195
|
4. **Smoke test** (one-shot — runs the bundled BookStore default schema
|
|
196
196
|
against a non-existent API; expect it to fail at the data-fetch step,
|
|
197
197
|
which proves the binary started correctly):
|
|
198
198
|
```bash
|
|
199
|
-
docker run --rm ghcr.io/
|
|
199
|
+
docker run --rm ghcr.io/fable-retold/meadow-integration:latest
|
|
200
200
|
```
|
|
201
201
|
|
|
202
202
|
---
|
|
@@ -280,7 +280,7 @@ whatever stability level fits.
|
|
|
280
280
|
### Pull and run (one-shot)
|
|
281
281
|
|
|
282
282
|
```bash
|
|
283
|
-
docker pull ghcr.io/
|
|
283
|
+
docker pull ghcr.io/fable-retold/meadow-integration:latest
|
|
284
284
|
|
|
285
285
|
# Single sync run; container exits when done
|
|
286
286
|
docker run --rm \
|
|
@@ -293,7 +293,7 @@ docker run --rm \
|
|
|
293
293
|
-e MEADOW_INTEGRATION_DB_NAME=mydatabase \
|
|
294
294
|
-e MEADOW_INTEGRATION_SCHEMA_PATH=/schemas/my-schema.json \
|
|
295
295
|
-v $(pwd)/schemas:/schemas:ro \
|
|
296
|
-
ghcr.io/
|
|
296
|
+
ghcr.io/fable-retold/meadow-integration:latest
|
|
297
297
|
```
|
|
298
298
|
|
|
299
299
|
### Configuration via env vars
|
package/CONTRIBUTING.md
CHANGED
|
@@ -47,4 +47,4 @@ When in doubt, match what the surrounding code does.
|
|
|
47
47
|
|
|
48
48
|
## Repository Structure
|
|
49
49
|
|
|
50
|
-
Each module is its own git repository. The [retold](https://github.com/
|
|
50
|
+
Each module is its own git repository. The [retold](https://github.com/fable-retold/retold) repository tracks module organization but does not contain module source code. Direct your pull request to the specific module repository where your change belongs.
|
package/README.md
CHANGED
|
@@ -361,4 +361,4 @@ MIT
|
|
|
361
361
|
|
|
362
362
|
## Contributing
|
|
363
363
|
|
|
364
|
-
Pull requests are welcome. For details on our code of conduct, contribution process, and testing requirements, see the [Retold Contributing Guide](https://github.com/
|
|
364
|
+
Pull requests are welcome. For details on our code of conduct, contribution process, and testing requirements, see the [Retold Contributing Guide](https://github.com/fable-retold/retold/blob/main/docs/contributing.md).
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "meadow-integration",
|
|
3
|
-
"version": "1.1.
|
|
3
|
+
"version": "1.1.2",
|
|
4
4
|
"description": "Meadow Data Integration",
|
|
5
5
|
"retoldBeacon": {
|
|
6
6
|
"displayName": "Meadow Integration",
|
|
@@ -60,7 +60,8 @@
|
|
|
60
60
|
"devDependencies": {
|
|
61
61
|
"meadow-connection-sqlite": "^1.0.20",
|
|
62
62
|
"pict-docuserve": "^1.4.19",
|
|
63
|
-
"quackage": "^1.3.0"
|
|
63
|
+
"quackage": "^1.3.0",
|
|
64
|
+
"retold-harness": "^1.1.13"
|
|
64
65
|
},
|
|
65
66
|
"mocha": {
|
|
66
67
|
"diff": true,
|
|
@@ -546,8 +546,16 @@ class MeadowSyncEntityInitial extends libFableServiceProviderBase
|
|
|
546
546
|
const fProcessPageRecords = (pBody, fPageProcessComplete) =>
|
|
547
547
|
{
|
|
548
548
|
this.fable.Utility.eachLimit(pBody, 5,
|
|
549
|
-
(pEntityRecord,
|
|
549
|
+
(pEntityRecord, fRawEntitySyncComplete) =>
|
|
550
550
|
{
|
|
551
|
+
// node:sqlite resolves its callbacks synchronously, so the
|
|
552
|
+
// per-record Meadow behavior chain (doCreate -> CollisionRename
|
|
553
|
+
// -> doRead, threaded through async.waterfall/eachOfLimit) never
|
|
554
|
+
// yields and the call stack grows with the record count until it
|
|
555
|
+
// overflows on large syncs. Defer each record's completion so the
|
|
556
|
+
// stack unwinds between records — the same guard the advanced-ID
|
|
557
|
+
// pagination path applies with setImmediate(fFetchPage) below.
|
|
558
|
+
const fEntitySyncComplete = (pError) => setImmediate(() => fRawEntitySyncComplete(pError));
|
|
551
559
|
const tmpRecord = pEntityRecord;
|
|
552
560
|
const tmpQuery = this.Meadow.query;
|
|
553
561
|
|
|
@@ -23,6 +23,33 @@ function valueTemplate(pColumn)
|
|
|
23
23
|
return `{~D:Record.${pColumn}~}`;
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
+
/**
|
|
27
|
+
* The value template for an entity's OWN key segment — the combinatorial-GUID input. Three shapes, in
|
|
28
|
+
* precedence order: a user-typed pict template `OwnKeyTemplate` (any expression, e.g.
|
|
29
|
+
* `{~D:Record.A~}-{~D:Record.B~}`); multiple columns `OwnKeyColumns` concatenated into one segment; or a
|
|
30
|
+
* single `OwnKeyColumn` (the original behavior). The transform resolves the whole string per row via
|
|
31
|
+
* fable's parseTemplate, so multiple `{~D:Record.X~}` tags in one segment just work.
|
|
32
|
+
* @param {Record<string, any>} pEntityConfig
|
|
33
|
+
* @returns {string}
|
|
34
|
+
*/
|
|
35
|
+
function ownValueTemplate(pEntityConfig)
|
|
36
|
+
{
|
|
37
|
+
const tmpConfig = pEntityConfig || {};
|
|
38
|
+
if (tmpConfig.OwnKeyTemplate)
|
|
39
|
+
{
|
|
40
|
+
return String(tmpConfig.OwnKeyTemplate);
|
|
41
|
+
}
|
|
42
|
+
if (Array.isArray(tmpConfig.OwnKeyColumns) && (tmpConfig.OwnKeyColumns.length > 0))
|
|
43
|
+
{
|
|
44
|
+
return tmpConfig.OwnKeyColumns.map((pColumn) => valueTemplate(pColumn)).join('');
|
|
45
|
+
}
|
|
46
|
+
if (tmpConfig.OwnKeyColumn)
|
|
47
|
+
{
|
|
48
|
+
return valueTemplate(tmpConfig.OwnKeyColumn);
|
|
49
|
+
}
|
|
50
|
+
return '';
|
|
51
|
+
}
|
|
52
|
+
|
|
26
53
|
/** Derive a short uppercase abbreviation for an entity with no catalog entry (initials / first letters). */
|
|
27
54
|
function _deriveAbbreviation(pEntityName)
|
|
28
55
|
{
|
|
@@ -50,12 +77,29 @@ function abbreviationFor(pEntityName, pContext)
|
|
|
50
77
|
return _deriveAbbreviation(pEntityName);
|
|
51
78
|
}
|
|
52
79
|
|
|
53
|
-
/**
|
|
80
|
+
/**
|
|
81
|
+
* The GUID column width for an entity, or 0 (unbounded) if unknown.
|
|
82
|
+
*
|
|
83
|
+
* Prefers the live schema width (`SchemaSizes`, populated for the entity actually being imported), then
|
|
84
|
+
* falls back to the host catalog's declared `GUIDSize`. The fallback is LOAD-BEARING for JOIN compose:
|
|
85
|
+
* when a child references a parent (e.g. Product → Material), only the CHILD's schema is loaded, so
|
|
86
|
+
* `SchemaSizes` has no entry for the parent. Without the catalog width the parent's foreign-key GUID is
|
|
87
|
+
* composed unbounded (never hashed) while the parent's OWN GUID was hashed to fit its column — so a long
|
|
88
|
+
* key makes the two diverge and the cross-session match silently breaks. The catalog `GUIDSize` MUST
|
|
89
|
+
* therefore match the parent's real GUID column width for long keys to resolve.
|
|
90
|
+
*/
|
|
54
91
|
function _maxLengthFor(pEntityName, pContext)
|
|
55
92
|
{
|
|
56
93
|
const tmpSizes = (pContext && pContext.SchemaSizes) || {};
|
|
57
|
-
const
|
|
58
|
-
|
|
94
|
+
const tmpSchemaSize = Number(tmpSizes[pEntityName] || 0);
|
|
95
|
+
if (tmpSchemaSize > 0)
|
|
96
|
+
{
|
|
97
|
+
return tmpSchemaSize;
|
|
98
|
+
}
|
|
99
|
+
const tmpCatalog = (pContext && pContext.Catalog) || {};
|
|
100
|
+
const tmpEntry = tmpCatalog[pEntityName];
|
|
101
|
+
const tmpCatalogSize = tmpEntry ? Number(tmpEntry.GUIDSize || 0) : 0;
|
|
102
|
+
return (tmpCatalogSize > 0) ? tmpCatalogSize : 0;
|
|
59
103
|
}
|
|
60
104
|
|
|
61
105
|
/** Build a compose spec (prefix + ordered segments + length budget) for an entity's fullGUID. */
|
|
@@ -91,11 +135,12 @@ function _ownSegments(pEntityName, pEntityConfig, pContext)
|
|
|
91
135
|
tmpSegments.push({ abbrev: abbreviationFor(pParentEntity, pContext), valueTemplate: tmpKeyColumn ? valueTemplate(tmpKeyColumn) : '' });
|
|
92
136
|
});
|
|
93
137
|
|
|
94
|
-
|
|
138
|
+
const tmpOwnValue = ownValueTemplate(pEntityConfig);
|
|
139
|
+
if (!tmpOwnValue)
|
|
95
140
|
{
|
|
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).`);
|
|
141
|
+
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), several columns, or a template.`);
|
|
97
142
|
}
|
|
98
|
-
tmpSegments.push({ abbrev: abbreviationFor(pEntityName, pContext), valueTemplate:
|
|
143
|
+
tmpSegments.push({ abbrev: abbreviationFor(pEntityName, pContext), valueTemplate: tmpOwnValue });
|
|
99
144
|
|
|
100
145
|
return { segments: tmpSegments, warnings: tmpWarnings };
|
|
101
146
|
}
|
|
@@ -235,4 +280,5 @@ module.exports = {
|
|
|
235
280
|
joinFieldName,
|
|
236
281
|
abbreviationFor,
|
|
237
282
|
valueTemplate,
|
|
283
|
+
ownValueTemplate,
|
|
238
284
|
};
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
const Chai = require('chai');
|
|
11
11
|
const Expect = Chai.expect;
|
|
12
12
|
|
|
13
|
+
const libPath = require('path');
|
|
13
14
|
const libFable = require('fable');
|
|
14
15
|
const libMeadowConnectionSQLite = require('meadow-connection-sqlite');
|
|
15
16
|
const libRetoldDataService = require('retold-data-service');
|
|
@@ -291,7 +292,7 @@ suite
|
|
|
291
292
|
_Fable.serviceManager.addServiceType('RetoldDataService', libRetoldDataService);
|
|
292
293
|
_RetoldDataService = _Fable.serviceManager.instantiateServiceProvider('RetoldDataService',
|
|
293
294
|
{
|
|
294
|
-
FullMeadowSchemaPath: `${
|
|
295
|
+
FullMeadowSchemaPath: `${libPath.dirname(require.resolve('retold-harness/source/schemas/bookstore/Schema.json'))}/`,
|
|
295
296
|
FullMeadowSchemaFilename: `Schema.json`,
|
|
296
297
|
|
|
297
298
|
StorageProvider: 'SQLite',
|
|
@@ -129,6 +129,38 @@ suite
|
|
|
129
129
|
}
|
|
130
130
|
);
|
|
131
131
|
test
|
|
132
|
+
(
|
|
133
|
+
'a long parent key hashes IDENTICALLY for the own GUID and a cross-session FK (catalog GUIDSize fallback)',
|
|
134
|
+
() =>
|
|
135
|
+
{
|
|
136
|
+
// Mirrors the live failure: a parent key long enough to overflow the parent's GUID column.
|
|
137
|
+
// The parent's OWN GUID is hashed to fit (its schema width is loaded during the parent import);
|
|
138
|
+
// the child is imported SEPARATELY, so only the CHILD's schema width is loaded — the parent's
|
|
139
|
+
// width has to come from the host catalog, or the FK is composed unbounded and never matches.
|
|
140
|
+
const tmpCatalog = { Material: { Abbrev: 'M', GUIDSize: 36 }, Product: { Abbrev: 'PR', GUIDSize: 96 } };
|
|
141
|
+
const tmpLongKey = 'ZZHASHKEY_' + 'A'.repeat(32); // UI_M + 42 chars overflows 36 -> must hash
|
|
142
|
+
|
|
143
|
+
// Parent import — Material's 36-char column width is present in SchemaSizes.
|
|
144
|
+
const tmpMaterialStrategy = libEngine.compileGUIDStrategy(
|
|
145
|
+
{ Prefix: 'UI', Entities: { Material: { Mode: 'prefixed', OwnKeyColumn: 'MaterialKey' } } },
|
|
146
|
+
{ Catalog: tmpCatalog, SchemaSizes: { Material: 36 } }).Strategies.Material;
|
|
147
|
+
const tmpMaterialComp = runOneEntity(newTransform(), { Entity: 'Material', GUIDName: 'GUIDMaterial', GUIDStrategy: tmpMaterialStrategy, Mappings: {} }, { MaterialKey: tmpLongKey });
|
|
148
|
+
const tmpMaterialGUID = Object.keys(tmpMaterialComp)[0];
|
|
149
|
+
Expect(tmpMaterialGUID.length, 'parent own GUID fits its 36-char column').to.be.at.most(36);
|
|
150
|
+
Expect(tmpMaterialGUID.indexOf('AAAA'), 'parent own GUID was hashed, not raw').to.equal(-1);
|
|
151
|
+
|
|
152
|
+
// Child import (separate session) — ONLY Product's width is in SchemaSizes; Material's width must
|
|
153
|
+
// come from the catalog for the FK to hash the same way.
|
|
154
|
+
const tmpProductStrategy = libEngine.compileGUIDStrategy(
|
|
155
|
+
{ Prefix: 'UI', Entities: { Product: { Mode: 'prefixed', OwnKeyColumn: 'ProductKey', Joins: [ { ParentEntity: 'Material', Mode: 'prefixed', KeyColumn: 'MaterialKey', CrossSession: true } ] } } },
|
|
156
|
+
{ Catalog: tmpCatalog, SchemaSizes: { Product: 96 } }).Strategies.Product;
|
|
157
|
+
const tmpProductComp = runOneEntity(newTransform(), { Entity: 'Product', GUIDName: 'GUIDProduct', GUIDStrategy: tmpProductStrategy, Mappings: {} }, { ProductKey: 'PRLONG1', MaterialKey: tmpLongKey });
|
|
158
|
+
const tmpProductGUID = Object.keys(tmpProductComp)[0];
|
|
159
|
+
|
|
160
|
+
Expect(tmpProductComp[tmpProductGUID]._GUIDMaterial, 'long-key FK hashes identically to the parent own GUID').to.equal(tmpMaterialGUID);
|
|
161
|
+
}
|
|
162
|
+
);
|
|
163
|
+
test
|
|
132
164
|
(
|
|
133
165
|
'a non-strategy mapping still uses the flat GUIDTemplate (no behavior change)',
|
|
134
166
|
() =>
|
|
@@ -62,6 +62,29 @@ suite
|
|
|
62
62
|
}
|
|
63
63
|
);
|
|
64
64
|
test
|
|
65
|
+
(
|
|
66
|
+
'combinatorial own key: multiple columns concatenate into one own segment',
|
|
67
|
+
() =>
|
|
68
|
+
{
|
|
69
|
+
const tmpConfig = { Prefix: 'UI', Entities: { Project: { Mode: 'prefixed', OwnKeyColumns: [ 'District', 'ProjectCode' ] } } };
|
|
70
|
+
const tmpProject = libStrategy.compile(tmpConfig, { Catalog: CATALOG, SchemaSizes: SIZES }).Strategies.Project;
|
|
71
|
+
Expect(tmpProject.Own.Compose.segments).to.have.length(1);
|
|
72
|
+
Expect(tmpProject.Own.Compose.segments[0].valueTemplate).to.equal('{~D:Record.District~}{~D:Record.ProjectCode~}');
|
|
73
|
+
}
|
|
74
|
+
);
|
|
75
|
+
test
|
|
76
|
+
(
|
|
77
|
+
'combinatorial own key: a user-typed pict template becomes the own segment verbatim (and wins over a single column)',
|
|
78
|
+
() =>
|
|
79
|
+
{
|
|
80
|
+
const tmpConfig = { Prefix: 'UI', Entities: { Project: { Mode: 'prefixed', OwnKeyTemplate: '{~D:Record.District~}-{~D:Record.ProjectCode~}' } } };
|
|
81
|
+
const tmpProject = libStrategy.compile(tmpConfig, { Catalog: CATALOG, SchemaSizes: SIZES }).Strategies.Project;
|
|
82
|
+
Expect(tmpProject.Own.Compose.segments[0].valueTemplate).to.equal('{~D:Record.District~}-{~D:Record.ProjectCode~}');
|
|
83
|
+
const tmpBoth = libStrategy.compile({ Prefix: 'UI', Entities: { Project: { OwnKeyColumn: 'X', OwnKeyTemplate: 'T{~D:Record.Y~}' } } }, { Catalog: CATALOG }).Strategies.Project;
|
|
84
|
+
Expect(tmpBoth.Own.Compose.segments[0].valueTemplate).to.equal('T{~D:Record.Y~}');
|
|
85
|
+
}
|
|
86
|
+
);
|
|
87
|
+
test
|
|
65
88
|
(
|
|
66
89
|
'composes the headline LineItem own GUID from context + own segments',
|
|
67
90
|
() =>
|