meadow-integration 1.1.1 → 1.1.3

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.
@@ -1,5 +1,6 @@
1
1
  const libFableServiceProviderBase = require('fable-serviceproviderbase');
2
2
  const libMeadowOperation = require('./Meadow-Service-Operation.js');
3
+ const libSyncPoolLimit = require('./Meadow-Service-Sync-PoolLimit.js');
3
4
 
4
5
  class MeadowSyncEntityInitial extends libFableServiceProviderBase
5
6
  {
@@ -48,6 +49,12 @@ class MeadowSyncEntityInitial extends libFableServiceProviderBase
48
49
  // override the automatic Deleted=0 filter (e.g. "includeDeleted=true").
49
50
  this.SyncDeletedRecordsQueryString = this.options.SyncDeletedRecordsQueryString || '';
50
51
 
52
+ // How many records to upsert concurrently within a table. Defaults to the
53
+ // DB connection pool size (capped) so fan-out never oversubscribes the pool
54
+ // nor runs unbounded on a very large pool. Overridable globally or
55
+ // per-table via SyncEntityOptions.SyncRecordConcurrency.
56
+ this.SyncRecordConcurrency = this._resolveSyncRecordConcurrency(this.options.SyncRecordConcurrency);
57
+
51
58
  this.Meadow = false;
52
59
 
53
60
  this.operation = new libMeadowOperation(this.fable);
@@ -55,6 +62,27 @@ class MeadowSyncEntityInitial extends libFableServiceProviderBase
55
62
  this.skipSync = false;
56
63
  }
57
64
 
65
+ /**
66
+ * Resolve the per-record fan-out concurrency for this entity.
67
+ *
68
+ * Precedence: an explicit configured value (global or per-table) wins and is
69
+ * used as-is; otherwise fall back to the automatic default (pool size, capped)
70
+ * — see resolveDefaultConcurrency in Meadow-Service-Sync-PoolLimit.js.
71
+ *
72
+ * @param {number|string} pConfigured - configured concurrency, if any
73
+ * @returns {number} a positive integer concurrency
74
+ */
75
+ _resolveSyncRecordConcurrency(pConfigured)
76
+ {
77
+ let tmpConfigured = parseInt(pConfigured, 10);
78
+ if (!isNaN(tmpConfigured) && tmpConfigured > 0)
79
+ {
80
+ return tmpConfigured;
81
+ }
82
+
83
+ return libSyncPoolLimit.resolveDefaultConcurrency(this.fable);
84
+ }
85
+
58
86
  initialize(fCallback)
59
87
  {
60
88
  if (this.fable.hasOwnProperty('Meadow'))
@@ -80,66 +108,18 @@ class MeadowSyncEntityInitial extends libFableServiceProviderBase
80
108
 
81
109
  return tmpProvider.createTable(this.EntitySchema, (pCreateError) =>
82
110
  {
83
-
84
111
  if (pCreateError)
85
112
  {
86
113
  this.log.warn(`${this.EntitySchema.TableName}: createTable returned error: ${pCreateError}`);
87
114
  }
88
115
 
89
- // Sync-entity init intentionally does not create indexes here.
90
- // In the DataCloner flow there is no MeadowConnectionManager
91
- // service registered, so the legacy createIndex path was dead
92
- // code that just logged noise ("No connection manager
93
- // available; skipping index creation for X") for every table.
94
- // Indexes are created via the provider's own createIndices /
95
- // generateCreateIndexStatements methods, invoked by the
96
- // DataCloner's dedicated /indices/create endpoint after the
97
- // initial sync is complete — that's the point where it makes
98
- // sense to build indexes, since they're expensive on populated
99
- // tables.
100
- //
101
- // Preserve the legacy MeadowConnectionManager path for the CLI
102
- // Meadow-Integration-Command-DataClone.js flow (which does
103
- // register that service), but do it silently when neither
104
- // indexable column nor connection manager is available.
105
- const tmpGUIDColumn = this.EntitySchema.Columns.find((c) => c.DataType == 'GUID');
106
- const tmpDeletedColumn = this.EntitySchema.Columns.find((c) => c.Column == 'Deleted');
107
-
108
- let tmpCanCreateIndexes = (tmpGUIDColumn || tmpDeletedColumn)
109
- && this.fable.MeadowConnectionManager
110
- && this.fable.MeadowConnectionManager.ConnectionPool
111
- && typeof(this.fable.MeadowConnectionManager.createIndex) === 'function';
112
-
113
- if (!tmpCanCreateIndexes)
114
- {
115
- return fCallback(pCreateError);
116
- }
117
-
118
- let tmpAnticipate = this.fable.newAnticipate();
119
- if (tmpGUIDColumn)
120
- {
121
- tmpAnticipate.anticipate(
122
- (fNext) =>
123
- {
124
- return this.fable.MeadowConnectionManager.createIndex(this.EntitySchema, tmpGUIDColumn, true, fNext);
125
- });
126
- }
127
- if (tmpDeletedColumn)
128
- {
129
- tmpAnticipate.anticipate(
130
- (fNext) =>
131
- {
132
- return this.fable.MeadowConnectionManager.createIndex(this.EntitySchema, tmpDeletedColumn, false, fNext);
133
- });
134
- }
135
- tmpAnticipate.wait((pIndexError) =>
136
- {
137
- if (pIndexError)
138
- {
139
- this.log.warn(`${this.EntitySchema.TableName}: Index creation error: ${pIndexError}`);
140
- }
141
- return fCallback(pIndexError || pCreateError);
142
- });
116
+ // Init only ensures the table exists. Index provisioning is
117
+ // handled separately and authoritatively by the index-convergence
118
+ // step (Meadow-Service-IndexConvergence), so indexes have a single
119
+ // writer the legacy per-column MeadowConnectionManager.createIndex
120
+ // path was retired to avoid two mechanisms fighting over the same
121
+ // indexes.
122
+ return fCallback(pCreateError);
143
123
  });
144
124
  }
145
125
 
@@ -249,7 +229,7 @@ class MeadowSyncEntityInitial extends libFableServiceProviderBase
249
229
  return fPageComplete();
250
230
  }
251
231
 
252
- this.fable.Utility.eachLimit(pBody, 5,
232
+ this.fable.Utility.eachLimit(pBody, this.SyncRecordConcurrency,
253
233
  (pEntityRecord, fRecordComplete) =>
254
234
  {
255
235
  const tmpRecordID = pEntityRecord[this.DefaultIdentifier];
@@ -545,9 +525,17 @@ class MeadowSyncEntityInitial extends libFableServiceProviderBase
545
525
  // Shared record-processing function used by both pagination modes
546
526
  const fProcessPageRecords = (pBody, fPageProcessComplete) =>
547
527
  {
548
- this.fable.Utility.eachLimit(pBody, 5,
549
- (pEntityRecord, fEntitySyncComplete) =>
528
+ this.fable.Utility.eachLimit(pBody, this.SyncRecordConcurrency,
529
+ (pEntityRecord, fRawEntitySyncComplete) =>
550
530
  {
531
+ // node:sqlite resolves its callbacks synchronously, so the
532
+ // per-record Meadow behavior chain (doCreate -> CollisionRename
533
+ // -> doRead, threaded through async.waterfall/eachOfLimit) never
534
+ // yields and the call stack grows with the record count until it
535
+ // overflows on large syncs. Defer each record's completion so the
536
+ // stack unwinds between records — the same guard the advanced-ID
537
+ // pagination path applies with setImmediate(fFetchPage) below.
538
+ const fEntitySyncComplete = (pError) => setImmediate(() => fRawEntitySyncComplete(pError));
551
539
  const tmpRecord = pEntityRecord;
552
540
  const tmpQuery = this.Meadow.query;
553
541
 
@@ -1,5 +1,6 @@
1
1
  const libFableServiceProviderBase = require('fable-serviceproviderbase');
2
2
  const libMeadowOperation = require('./Meadow-Service-Operation.js');
3
+ const libSyncPoolLimit = require('./Meadow-Service-Sync-PoolLimit.js');
3
4
 
4
5
  class MeadowSyncEntityOngoing extends libFableServiceProviderBase
5
6
  {
@@ -60,6 +61,13 @@ class MeadowSyncEntityOngoing extends libFableServiceProviderBase
60
61
  ? this.options.DateTimePrecisionMS
61
62
  : 1000;
62
63
 
64
+ // How many records to reconcile/upsert concurrently within a table.
65
+ // Defaults to the DB connection pool size (capped) so fan-out never
66
+ // oversubscribes the pool (a 2-connection clone must not fan out 5-wide)
67
+ // nor runs unbounded on a very large pool. Overridable globally or
68
+ // per-table via SyncEntityOptions.SyncRecordConcurrency.
69
+ this.SyncRecordConcurrency = this._resolveSyncRecordConcurrency(this.options.SyncRecordConcurrency);
70
+
63
71
  this.Meadow = false;
64
72
 
65
73
  this.operation = new libMeadowOperation(this.fable);
@@ -67,6 +75,27 @@ class MeadowSyncEntityOngoing extends libFableServiceProviderBase
67
75
  this.skipSync = false;
68
76
  }
69
77
 
78
+ /**
79
+ * Resolve the per-record fan-out concurrency for this entity.
80
+ *
81
+ * Precedence: an explicit configured value (global or per-table) wins and is
82
+ * used as-is; otherwise fall back to the automatic default (pool size, capped)
83
+ * — see resolveDefaultConcurrency in Meadow-Service-Sync-PoolLimit.js.
84
+ *
85
+ * @param {number|string} pConfigured - configured concurrency, if any
86
+ * @returns {number} a positive integer concurrency
87
+ */
88
+ _resolveSyncRecordConcurrency(pConfigured)
89
+ {
90
+ let tmpConfigured = parseInt(pConfigured, 10);
91
+ if (!isNaN(tmpConfigured) && tmpConfigured > 0)
92
+ {
93
+ return tmpConfigured;
94
+ }
95
+
96
+ return libSyncPoolLimit.resolveDefaultConcurrency(this.fable);
97
+ }
98
+
70
99
  initialize(fCallback)
71
100
  {
72
101
  if (this.fable.hasOwnProperty('Meadow'))
@@ -78,46 +107,14 @@ class MeadowSyncEntityOngoing extends libFableServiceProviderBase
78
107
 
79
108
  if (this.Meadow && this.Meadow.provider)
80
109
  {
110
+ // Init only ensures the table exists. Index provisioning is handled
111
+ // separately and authoritatively by the index-convergence step
112
+ // (Meadow-Service-IndexConvergence), so indexes have a single writer —
113
+ // the legacy per-column MeadowConnectionManager.createIndex path was
114
+ // retired to avoid two mechanisms fighting over the same indexes.
81
115
  return this.Meadow.provider.getProvider().createTable(this.EntitySchema, (pCreateError) =>
82
116
  {
83
- // Sync-entity init intentionally does not create indexes here
84
- // in the DataCloner flow. See the comment in
85
- // Meadow-Service-Sync-Entity-Initial.js for the rationale —
86
- // indexes go through the provider's createIndices path
87
- // (triggered by DataCloner's /indices/create endpoint) rather
88
- // than via MeadowConnectionManager, which isn't registered in
89
- // the DataCloner service container.
90
- const tmpGUIDColumn = this.EntitySchema.Columns.find((c) => c.DataType == 'GUID');
91
- const tmpDeletedColumn = this.EntitySchema.Columns.find((c) => c.Column == 'Deleted');
92
-
93
- let tmpCanCreateIndexes = (tmpGUIDColumn || tmpDeletedColumn)
94
- && this.fable.MeadowConnectionManager
95
- && this.fable.MeadowConnectionManager.ConnectionPool
96
- && typeof(this.fable.MeadowConnectionManager.createIndex) === 'function';
97
-
98
- if (!tmpCanCreateIndexes)
99
- {
100
- return fCallback(pCreateError);
101
- }
102
-
103
- let tmpAnticipate = this.fable.newAnticipate();
104
- if (tmpGUIDColumn)
105
- {
106
- tmpAnticipate.anticipate(
107
- (fNext) =>
108
- {
109
- return this.fable.MeadowConnectionManager.createIndex(this.EntitySchema, tmpGUIDColumn, true, fNext);
110
- });
111
- }
112
- if (tmpDeletedColumn)
113
- {
114
- tmpAnticipate.anticipate(
115
- (fNext) =>
116
- {
117
- return this.fable.MeadowConnectionManager.createIndex(this.EntitySchema, tmpDeletedColumn, false, fNext);
118
- });
119
- }
120
- tmpAnticipate.wait((pIndexError) => { return fCallback(pCreateError); });
117
+ return fCallback(pCreateError);
121
118
  });
122
119
  }
123
120
  return fCallback();
@@ -467,7 +464,7 @@ class MeadowSyncEntityOngoing extends libFableServiceProviderBase
467
464
  return fCallback(null, tmpSyncedCount);
468
465
  }
469
466
 
470
- this.fable.Utility.eachLimit(pRecords, 5,
467
+ this.fable.Utility.eachLimit(pRecords, this.SyncRecordConcurrency,
471
468
  (pRecord, fRecordDone) =>
472
469
  {
473
470
  this._upsertRecord(pRecord,
@@ -723,7 +720,7 @@ class MeadowSyncEntityOngoing extends libFableServiceProviderBase
723
720
  return fCallback();
724
721
  }
725
722
 
726
- this.fable.Utility.eachLimit(pBody, 5,
723
+ this.fable.Utility.eachLimit(pBody, this.SyncRecordConcurrency,
727
724
  (pEntityRecord, fRecordComplete) =>
728
725
  {
729
726
  const tmpRecordID = pEntityRecord[this.DefaultIdentifier];
@@ -260,7 +260,7 @@ class MeadowSyncEntityOngoingEventualConsistency extends libMeadowSyncEntityOngo
260
260
  return fFinish('complete');
261
261
  }
262
262
 
263
- this.fable.Utility.eachLimit(pPageBody, 5,
263
+ this.fable.Utility.eachLimit(pPageBody, this.SyncRecordConcurrency,
264
264
  (pEntityRecord, fRecordComplete) =>
265
265
  {
266
266
  tmpCounters.seen++;
@@ -504,7 +504,7 @@ class MeadowSyncEntityOngoingEventualConsistency extends libMeadowSyncEntityOngo
504
504
  tmpMaxID = pPageBody[0][this.DefaultIdentifier]; // DESC → first row is the highest id
505
505
  }
506
506
 
507
- this.fable.Utility.eachLimit(pPageBody, 5,
507
+ this.fable.Utility.eachLimit(pPageBody, this.SyncRecordConcurrency,
508
508
  (pEntityRecord, fRecordComplete) =>
509
509
  {
510
510
  tmpCounters.seen++;
@@ -130,7 +130,7 @@ class MeadowSyncEntityTrueUp extends libMeadowSyncEntityOngoing
130
130
  return fStageComplete();
131
131
  }
132
132
 
133
- this.fable.Utility.eachLimit(pRecords, 5,
133
+ this.fable.Utility.eachLimit(pRecords, this.SyncRecordConcurrency,
134
134
  (pRecord, fRecordDone) =>
135
135
  {
136
136
  this._upsertRecord(pRecord,
@@ -0,0 +1,76 @@
1
+ 'use strict';
2
+
3
+ // Connection providers that expose a connection pool size. The clone/sync
4
+ // engine defaults its fan-out (per-record reconcile, per-table init) to
5
+ // whichever of these is the active clone target so it never oversubscribes the
6
+ // pool. Only one is instantiated per clone; SQLite is intentionally absent
7
+ // (no pool — single writer).
8
+ const POOLED_CONNECTION_PROVIDER_SERVICES = [ 'MeadowMSSQLProvider', 'MeadowMySQLProvider', 'MeadowPostgreSQLProvider' ];
9
+
10
+ // Fan-out used when no pool size is reported (e.g. SQLite — single writer).
11
+ const DEFAULT_CONCURRENCY = 5;
12
+
13
+ // Ceiling on the AUTO-derived default fan-out. Sizing to the pool avoids
14
+ // oversubscribing a small pool, but a very large pool (say 100) should not make
15
+ // the sync fan out 100-wide by default: the concurrency also gates source-API
16
+ // reads, and that many in-flight reconciles would hammer the source and balloon
17
+ // memory. An explicit SyncRecordConcurrency / SyncTableInitConcurrency bypasses
18
+ // this cap — it only tempers the automatic default.
19
+ const MAX_DEFAULT_CONCURRENCY = 16;
20
+
21
+ /**
22
+ * Resolve the active clone target's connection pool size.
23
+ *
24
+ * Returns the pool size of whichever pooled connection provider is registered on
25
+ * the fable, or 0 when the active provider does not report one (e.g. SQLite) so
26
+ * callers can apply their own default.
27
+ *
28
+ * @param {object} pFable - the fable instance the sync is running on
29
+ * @returns {number} the pool size, or 0 if none is available
30
+ */
31
+ function resolveConnectionPoolLimit(pFable)
32
+ {
33
+ if (!pFable)
34
+ {
35
+ return 0;
36
+ }
37
+ for (let i = 0; i < POOLED_CONNECTION_PROVIDER_SERVICES.length; i++)
38
+ {
39
+ let tmpProvider = pFable[POOLED_CONNECTION_PROVIDER_SERVICES[i]];
40
+ if (tmpProvider && (typeof(tmpProvider.connectionPoolLimit) === 'number') && tmpProvider.connectionPoolLimit > 0)
41
+ {
42
+ return tmpProvider.connectionPoolLimit;
43
+ }
44
+ }
45
+ return 0;
46
+ }
47
+
48
+ /**
49
+ * Resolve the automatic default fan-out concurrency for the active clone target.
50
+ *
51
+ * Sizes to the connection pool so the sync neither oversubscribes a small pool
52
+ * nor fans out unbounded on a very large one (capped at MAX_DEFAULT_CONCURRENCY),
53
+ * and falls back to DEFAULT_CONCURRENCY when the provider reports no pool (e.g.
54
+ * SQLite). Callers apply this only when no explicit concurrency is configured;
55
+ * an explicit value is honored as-is and is not subject to the cap.
56
+ *
57
+ * @param {object} pFable - the fable instance the sync is running on
58
+ * @returns {number} a positive default concurrency
59
+ */
60
+ function resolveDefaultConcurrency(pFable)
61
+ {
62
+ let tmpPoolLimit = resolveConnectionPoolLimit(pFable);
63
+ if (tmpPoolLimit > 0)
64
+ {
65
+ return Math.min(tmpPoolLimit, MAX_DEFAULT_CONCURRENCY);
66
+ }
67
+ return DEFAULT_CONCURRENCY;
68
+ }
69
+
70
+ module.exports = {
71
+ POOLED_CONNECTION_PROVIDER_SERVICES: POOLED_CONNECTION_PROVIDER_SERVICES,
72
+ DEFAULT_CONCURRENCY: DEFAULT_CONCURRENCY,
73
+ MAX_DEFAULT_CONCURRENCY: MAX_DEFAULT_CONCURRENCY,
74
+ resolveConnectionPoolLimit: resolveConnectionPoolLimit,
75
+ resolveDefaultConcurrency: resolveDefaultConcurrency
76
+ };
@@ -4,6 +4,7 @@ const libMeadowSyncEntityOngoing = require('./Meadow-Service-Sync-Entity-Ongoing
4
4
  const libMeadowSyncEntityOngoingEventualConsistency = require('./Meadow-Service-Sync-Entity-OngoingEventualConsistency.js');
5
5
  const libMeadowSyncEntityTrueUp = require('./Meadow-Service-Sync-Entity-TrueUp.js');
6
6
  const libMeadowSyncEntityComparisonOnly = require('./Meadow-Service-Sync-Entity-ComparisonOnly.js');
7
+ const libSyncPoolLimit = require('./Meadow-Service-Sync-PoolLimit.js');
7
8
 
8
9
  class MeadowSync extends libFableServiceProviderBase
9
10
  {
@@ -165,7 +166,16 @@ class MeadowSync extends libFableServiceProviderBase
165
166
  let tmpErrorCount = 0;
166
167
  let tmpSuccessCount = 0;
167
168
 
168
- this.fable.Utility.eachLimit(this.MeadowSchemaTableList, 5,
169
+ // Bound table-initialization fan-out (createTable per table) to the DB
170
+ // connection pool size so deploy doesn't oversubscribe a small pool.
171
+ // Overridable via SyncTableInitConcurrency.
172
+ let tmpTableInitConcurrency = parseInt(this.options.SyncTableInitConcurrency, 10);
173
+ if (isNaN(tmpTableInitConcurrency) || tmpTableInitConcurrency < 1)
174
+ {
175
+ tmpTableInitConcurrency = libSyncPoolLimit.resolveDefaultConcurrency(this.fable);
176
+ }
177
+
178
+ this.fable.Utility.eachLimit(this.MeadowSchemaTableList, tmpTableInitConcurrency,
169
179
  (pEntitySchemaName, fSyncInitializationComplete) =>
170
180
  {
171
181
  tmpEntityIndex++;
@@ -186,6 +196,7 @@ class MeadowSync extends libFableServiceProviderBase
186
196
  TrueUpPageSize: this.TrueUpPageSize,
187
197
  DeleteCursorStatePath: this.options.DeleteCursorStatePath,
188
198
  DeleteResweepIntervalHours: this.options.DeleteResweepIntervalHours,
199
+ SyncRecordConcurrency: this.options.SyncRecordConcurrency,
189
200
  };
190
201
 
191
202
  // Apply per-entity option overrides if configured
@@ -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
  {
@@ -108,11 +135,12 @@ function _ownSegments(pEntityName, pEntityConfig, pContext)
108
135
  tmpSegments.push({ abbrev: abbreviationFor(pParentEntity, pContext), valueTemplate: tmpKeyColumn ? valueTemplate(tmpKeyColumn) : '' });
109
136
  });
110
137
 
111
- if (!pEntityConfig.OwnKeyColumn)
138
+ const tmpOwnValue = ownValueTemplate(pEntityConfig);
139
+ if (!tmpOwnValue)
112
140
  {
113
- 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.`);
114
142
  }
115
- tmpSegments.push({ abbrev: abbreviationFor(pEntityName, pContext), valueTemplate: pEntityConfig.OwnKeyColumn ? valueTemplate(pEntityConfig.OwnKeyColumn) : '' });
143
+ tmpSegments.push({ abbrev: abbreviationFor(pEntityName, pContext), valueTemplate: tmpOwnValue });
116
144
 
117
145
  return { segments: tmpSegments, warnings: tmpWarnings };
118
146
  }
@@ -252,4 +280,5 @@ module.exports = {
252
280
  joinFieldName,
253
281
  abbreviationFor,
254
282
  valueTemplate,
283
+ ownValueTemplate,
255
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: `${__dirname}/../node_modules/retold-harness/source/schemas/bookstore/`,
295
+ FullMeadowSchemaPath: `${libPath.dirname(require.resolve('retold-harness/source/schemas/bookstore/Schema.json'))}/`,
295
296
  FullMeadowSchemaFilename: `Schema.json`,
296
297
 
297
298
  StorageProvider: 'SQLite',
@@ -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
  () =>
@@ -0,0 +1,144 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Live-database smoke test for index convergence.
5
+ *
6
+ * Unlike the stub-provider unit test, this runs the convergence engine against a
7
+ * REAL connector + REAL database (Node's built-in node:sqlite via
8
+ * meadow-connection-sqlite) — exercising the actual introspectTableIndices,
9
+ * generateCreateIndexStatements, createIndex, and the NEW dropIndex end to end.
10
+ * No external server / credentials required.
11
+ */
12
+
13
+ const Chai = require('chai');
14
+ const Expect = Chai.expect;
15
+
16
+ const libFs = require('fs');
17
+ const libPath = require('path');
18
+ const libOs = require('os');
19
+
20
+ const libFable = require('fable');
21
+ const libMeadowConnectionSQLite = require('meadow-connection-sqlite');
22
+ const libIndexPolicy = require('../source/services/clone/Meadow-Service-IndexPolicy.js');
23
+ const libConvergence = require('../source/services/clone/Meadow-Service-IndexConvergence.js');
24
+
25
+ const _Schema =
26
+ {
27
+ TableName: 'Document',
28
+ DefaultIdentifier: 'IDDocument',
29
+ Columns:
30
+ [
31
+ { Column: 'IDDocument', DataType: 'ID' },
32
+ { Column: 'GUIDDocument', DataType: 'GUID', Size: 36 },
33
+ { Column: 'Deleted', DataType: 'Boolean' },
34
+ { Column: 'Name', DataType: 'String', Size: 128 }
35
+ ]
36
+ };
37
+
38
+ const COMPOSITE = 'IX_M_SYNC_Document_Deleted_IDDocument';
39
+ const GUIDIDX = 'IX_M_SYNC_Document_GUIDDocument';
40
+
41
+ suite('Meadow Integration - IndexConvergence (live SQLite)',
42
+ () =>
43
+ {
44
+ let _Fable = null;
45
+ let _Provider = null;
46
+ let _DbPath = '';
47
+
48
+ setup((fDone) =>
49
+ {
50
+ _DbPath = libPath.join(libOs.tmpdir(), `mi-index-converge-${process.pid}-${_Fable ? 1 : 0}.sqlite`);
51
+ if (libFs.existsSync(_DbPath)) { libFs.unlinkSync(_DbPath); }
52
+
53
+ _Fable = new libFable({ Product: 'IndexConvergenceSmoke', SQLite: { SQLiteFilePath: _DbPath } });
54
+ _Fable.serviceManager.addServiceType('MeadowSQLiteProvider', libMeadowConnectionSQLite);
55
+ _Fable.serviceManager.instantiateServiceProvider('MeadowSQLiteProvider');
56
+
57
+ _Fable.MeadowSQLiteProvider.connectAsync((pError) =>
58
+ {
59
+ if (pError) { return fDone(pError); }
60
+ _Provider = _Fable.MeadowSQLiteProvider;
61
+ // Create the table (real DDL) before each test.
62
+ _Provider.db.exec(_Provider.generateCreateTableStatement(_Schema));
63
+ return fDone();
64
+ });
65
+ });
66
+
67
+ let converge = (pOptions, fCallback) =>
68
+ {
69
+ let tmpDesired = libIndexPolicy.resolveDesiredIndexes(_Schema, { StandardOperationalIndexes: true });
70
+ libConvergence.convergeTableIndexes(_Provider, _Schema, tmpDesired,
71
+ Object.assign({ PruneScope: 'managed', log: _Fable.log }, pOptions || {}), fCallback);
72
+ };
73
+
74
+ test('fresh table: creates the operational indexes (composite + non-unique GUID), drops nothing',
75
+ (fDone) =>
76
+ {
77
+ converge({}, (pError, pResult) =>
78
+ {
79
+ if (pError) { return fDone(pError); }
80
+ _Provider.introspectTableIndices('Document', (pIntrospectError, pActual) =>
81
+ {
82
+ try {
83
+ if (pIntrospectError) { return fDone(pIntrospectError); }
84
+ let tmpNames = pActual.map((pIndex) => { return pIndex.Name; });
85
+ Expect(tmpNames, 'composite present').to.include(COMPOSITE);
86
+ Expect(tmpNames, 'GUID index present').to.include(GUIDIDX);
87
+ let tmpGUID = pActual.find((pIndex) => { return pIndex.Name === GUIDIDX; });
88
+ Expect(tmpGUID.Unique, 'GUID index is non-unique').to.not.equal(true);
89
+ Expect(pResult.dropped, 'nothing dropped on a fresh table').to.have.length(0);
90
+ fDone();
91
+ } catch (e) { fDone(e); }
92
+ });
93
+ });
94
+ });
95
+
96
+ test('managed prune: replaces a precursor [Deleted] index, leaves an unmanaged index alone',
97
+ (fDone) =>
98
+ {
99
+ // Seed a precursor-style single-column index + an external/unmanaged one.
100
+ _Provider.db.exec('CREATE INDEX IF NOT EXISTS "Deleted" ON "Document" ("Deleted")');
101
+ _Provider.db.exec('CREATE INDEX IF NOT EXISTS "IX_dba_custom" ON "Document" ("Name")');
102
+
103
+ converge({}, (pError, pResult) =>
104
+ {
105
+ if (pError) { return fDone(pError); }
106
+ _Provider.introspectTableIndices('Document', (pIntrospectError, pActual) =>
107
+ {
108
+ try {
109
+ if (pIntrospectError) { return fDone(pIntrospectError); }
110
+ let tmpNames = pActual.map((pIndex) => { return pIndex.Name; });
111
+ Expect(tmpNames, 'new composite created').to.include(COMPOSITE);
112
+ Expect(tmpNames, 'precursor [Deleted] dropped (managed)').to.not.include('Deleted');
113
+ Expect(tmpNames, 'unmanaged index untouched').to.include('IX_dba_custom');
114
+ Expect(pResult.dropped, 'precursor was dropped').to.include('Deleted');
115
+ fDone();
116
+ } catch (e) { fDone(e); }
117
+ });
118
+ });
119
+ });
120
+
121
+ test('idempotent: a second convergence run creates and drops nothing',
122
+ (fDone) =>
123
+ {
124
+ converge({}, (pFirstError) =>
125
+ {
126
+ if (pFirstError) { return fDone(pFirstError); }
127
+ converge({}, (pError, pResult) =>
128
+ {
129
+ try {
130
+ if (pError) { return fDone(pError); }
131
+ Expect(pResult.created, 'nothing created on 2nd run').to.have.length(0);
132
+ Expect(pResult.dropped, 'nothing dropped on 2nd run').to.have.length(0);
133
+ fDone();
134
+ } catch (e) { fDone(e); }
135
+ });
136
+ });
137
+ });
138
+
139
+ teardown(() =>
140
+ {
141
+ try { if (_Provider && _Provider.db) { _Provider.db.close(); } } catch (e) {}
142
+ try { if (_DbPath && libFs.existsSync(_DbPath)) { libFs.unlinkSync(_DbPath); } } catch (e) {}
143
+ });
144
+ });