meadow 2.0.36 → 2.0.38

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",
3
- "version": "2.0.36",
3
+ "version": "2.0.38",
4
4
  "description": "A data access library.",
5
5
  "main": "source/Meadow.js",
6
6
  "scripts": {
@@ -83,22 +83,22 @@
83
83
  "alasql": "^4.17.0",
84
84
  "better-sqlite3": "^12.6.2",
85
85
  "dgraph-js-http": "^21.3.0",
86
- "fable": "^3.1.70",
86
+ "fable": "^3.1.71",
87
87
  "gulp-util": "^3.0.8",
88
- "meadow-connection-dgraph": "^1.0.2",
89
- "meadow-connection-mongodb": "^1.0.2",
90
- "meadow-connection-mssql": "^1.0.21",
91
- "meadow-connection-mysql": "^1.0.17",
92
- "meadow-connection-postgresql": "^1.0.3",
93
- "meadow-connection-rocksdb": "^0.0.2",
94
- "meadow-connection-solr": "^1.0.2",
95
- "meadow-connection-sqlite": "^1.0.18",
88
+ "meadow-connection-dgraph": "^1.0.3",
89
+ "meadow-connection-mongodb": "^1.0.3",
90
+ "meadow-connection-mssql": "^1.0.23",
91
+ "meadow-connection-mysql": "^1.0.19",
92
+ "meadow-connection-postgresql": "^1.0.5",
93
+ "meadow-connection-rocksdb": "^1.0.0",
94
+ "meadow-connection-solr": "^1.0.3",
95
+ "meadow-connection-sqlite": "^1.0.19",
96
96
  "meadow-connection-sqlite-browser": "^1.0.2",
97
97
  "mongodb": "^6.12.0",
98
98
  "mysql2": "^3.18.2",
99
99
  "pict-docuserve": "^0.1.5",
100
100
  "puppeteer": "^24.38.0",
101
- "quackage": "^1.1.2",
101
+ "quackage": "^1.2.3",
102
102
  "solr-client": "^0.9.0"
103
103
  },
104
104
  "dependencies": {
@@ -39,12 +39,18 @@ start_dgraph() {
39
39
  exit 1
40
40
  fi
41
41
 
42
- echo "Waiting for DGraph to be ready..."
42
+ # Two-stage readiness probe. /health turns 200 as soon as Zero is up, but
43
+ # Alpha (the write/schema endpoint) can take many seconds longer to start
44
+ # accepting /alter — under Colima/QEMU this gap is wide enough that the
45
+ # downstream test's drop_all + applyDGraphSchema hangs and burns the
46
+ # 30s mocha hook budget. Probe /alter with a no-op so we don't return
47
+ # until the full write path is alive.
48
+ echo "Waiting for DGraph /health..."
43
49
  RETRIES=30
44
- until curl -sf "http://localhost:${DGRAPH_HTTP_PORT}/health" > /dev/null 2>&1; do
50
+ until curl -sf --max-time 2 "http://localhost:${DGRAPH_HTTP_PORT}/health" > /dev/null 2>&1; do
45
51
  RETRIES=$((RETRIES - 1))
46
52
  if [ $RETRIES -le 0 ]; then
47
- echo "ERROR: DGraph failed to become ready in time."
53
+ echo "ERROR: DGraph /health failed to become ready in time."
48
54
  docker logs "${CONTAINER_NAME}" 2>&1 | tail -20
49
55
  exit 1
50
56
  fi
@@ -52,6 +58,20 @@ start_dgraph() {
52
58
  sleep 2
53
59
  done
54
60
 
61
+ echo "Waiting for DGraph /alter (Alpha write path)..."
62
+ RETRIES=30
63
+ # Empty schema is a valid alter — round-trips through Alpha without changing state.
64
+ until curl -sf --max-time 5 -X POST -H "Content-Type: application/json" -d '{"schema":""}' "http://localhost:${DGRAPH_HTTP_PORT}/alter" > /dev/null 2>&1; do
65
+ RETRIES=$((RETRIES - 1))
66
+ if [ $RETRIES -le 0 ]; then
67
+ echo "ERROR: DGraph /alter never accepted writes in time."
68
+ docker logs "${CONTAINER_NAME}" 2>&1 | tail -30
69
+ exit 1
70
+ fi
71
+ echo " ...waiting on alter (${RETRIES} retries left)"
72
+ sleep 2
73
+ done
74
+
55
75
  # Apply schema predicates for the FableTest type and MeadowCounter type
56
76
  # Note: FableTest fields use unscoped predicate names to match FoxHound DGraph dialect output
57
77
  echo "Applying DGraph schema..."
@@ -0,0 +1,207 @@
1
+ #!/usr/bin/env node
2
+ // mssql-runner.js — sqlcmd-equivalent for the test-database scripts.
3
+ //
4
+ // Replaces `docker exec <container> /opt/mssql-tools.../sqlcmd ...` with a
5
+ // host-side connection over the mapped port. Avoids depending on tooling
6
+ // the SQL Server image happens to bundle (which varies — Azure SQL Edge's
7
+ // :latest, for example, has dropped the in-container sqlcmd binary).
8
+ //
9
+ // Modes:
10
+ // --readiness Probe-and-retry until the engine accepts a
11
+ // login + SELECT 1. Exit 0 when ready.
12
+ // --query "<SQL>" Run a single statement and exit.
13
+ // (stdin, no mode flag) Read SQL from stdin, split on `^GO$` lines,
14
+ // execute each batch in order.
15
+ //
16
+ // Connection parameters via env (with defaults that match mssql-test-db.sh):
17
+ // MSSQL_HOST 127.0.0.1
18
+ // MSSQL_PORT 31433
19
+ // MSSQL_USER sa
20
+ // MSSQL_PASSWORD 1234567890abc.
21
+ // MSSQL_DATABASE master (override with --db <name>)
22
+
23
+ const libTedious = require('tedious');
24
+
25
+ const tmpArgs = process.argv.slice(2);
26
+ const tmpMode = (() =>
27
+ {
28
+ if (tmpArgs.includes('--readiness')) return 'readiness';
29
+ const tmpQueryIdx = tmpArgs.indexOf('--query');
30
+ if (tmpQueryIdx >= 0) return 'query';
31
+ return 'stdin';
32
+ })();
33
+
34
+ const tmpDBIdx = tmpArgs.indexOf('--db');
35
+ const tmpDatabase = (tmpDBIdx >= 0 && tmpArgs[tmpDBIdx + 1]) ? tmpArgs[tmpDBIdx + 1] : (process.env.MSSQL_DATABASE || 'master');
36
+
37
+ const tmpQueryIdx = tmpArgs.indexOf('--query');
38
+ const tmpInlineQuery = (tmpQueryIdx >= 0 && tmpArgs[tmpQueryIdx + 1]) ? tmpArgs[tmpQueryIdx + 1] : null;
39
+
40
+ const tmpConfig =
41
+ {
42
+ server: process.env.MSSQL_HOST || '127.0.0.1',
43
+ authentication:
44
+ {
45
+ type: 'default',
46
+ options:
47
+ {
48
+ userName: process.env.MSSQL_USER || 'sa',
49
+ password: process.env.MSSQL_PASSWORD || '1234567890abc.'
50
+ }
51
+ },
52
+ options:
53
+ {
54
+ port: parseInt(process.env.MSSQL_PORT || '31433', 10),
55
+ database: tmpDatabase,
56
+ // TLS with cert trust — matches sqlcmd -C and what newer SQL Server
57
+ // / Azure SQL Edge images expect by default. trustServerCertificate
58
+ // is required for the self-signed cert the container generates.
59
+ encrypt: true,
60
+ trustServerCertificate: true,
61
+ // Default 15s timeout cuts off readiness probing on slow startups.
62
+ // Bump it so the probe loop owns the retry cadence, not tedious.
63
+ connectTimeout: 60000,
64
+ requestTimeout: 60000,
65
+ rowCollectionOnRequestCompletion: false
66
+ }
67
+ };
68
+
69
+ function connect()
70
+ {
71
+ return new Promise((fResolve, fReject) =>
72
+ {
73
+ const tmpConnection = new libTedious.Connection(tmpConfig);
74
+ tmpConnection.on('connect', (pError) =>
75
+ {
76
+ if (pError) return fReject(pError);
77
+ return fResolve(tmpConnection);
78
+ });
79
+ tmpConnection.connect();
80
+ });
81
+ }
82
+
83
+ function execSql(pConnection, pSQL)
84
+ {
85
+ return new Promise((fResolve, fReject) =>
86
+ {
87
+ const tmpRequest = new libTedious.Request(pSQL, (pError) =>
88
+ {
89
+ if (pError) return fReject(pError);
90
+ return fResolve();
91
+ });
92
+ pConnection.execSql(tmpRequest);
93
+ });
94
+ }
95
+
96
+ function close(pConnection)
97
+ {
98
+ return new Promise((fResolve) =>
99
+ {
100
+ pConnection.on('end', fResolve);
101
+ pConnection.close();
102
+ });
103
+ }
104
+
105
+ // Split a multi-batch script on `^GO$` lines (case-insensitive, trimmed).
106
+ // sqlcmd's GO separator is a batch delimiter, not a SQL keyword — it must
107
+ // appear alone on its own line.
108
+ function splitBatches(pScript)
109
+ {
110
+ const tmpLines = pScript.split(/\r?\n/);
111
+ const tmpBatches = [];
112
+ let tmpCurrent = [];
113
+ for (const tmpLine of tmpLines)
114
+ {
115
+ if (/^\s*GO\s*$/i.test(tmpLine))
116
+ {
117
+ const tmpJoined = tmpCurrent.join('\n').trim();
118
+ if (tmpJoined.length > 0) tmpBatches.push(tmpJoined);
119
+ tmpCurrent = [];
120
+ }
121
+ else
122
+ {
123
+ tmpCurrent.push(tmpLine);
124
+ }
125
+ }
126
+ const tmpTail = tmpCurrent.join('\n').trim();
127
+ if (tmpTail.length > 0) tmpBatches.push(tmpTail);
128
+ return tmpBatches;
129
+ }
130
+
131
+ async function runReadiness()
132
+ {
133
+ const tmpMaxRetries = parseInt(process.env.MSSQL_RUNNER_RETRIES || '60', 10);
134
+ const tmpSleepMs = parseInt(process.env.MSSQL_RUNNER_SLEEP_MS || '2000', 10);
135
+ for (let i = 0; i < tmpMaxRetries; i++)
136
+ {
137
+ try
138
+ {
139
+ const tmpConn = await connect();
140
+ await execSql(tmpConn, 'SELECT 1');
141
+ await close(tmpConn);
142
+ return;
143
+ }
144
+ catch (pError)
145
+ {
146
+ const tmpRemaining = tmpMaxRetries - 1 - i;
147
+ if (tmpRemaining <= 0)
148
+ {
149
+ console.error(`ERROR: MSSQL not ready after ${tmpMaxRetries} attempts: ${pError.message}`);
150
+ process.exit(1);
151
+ }
152
+ console.log(` ...waiting (${tmpRemaining} retries left)`);
153
+ await new Promise((fResolve) => setTimeout(fResolve, tmpSleepMs));
154
+ }
155
+ }
156
+ }
157
+
158
+ async function runQuery(pSQL)
159
+ {
160
+ const tmpConn = await connect();
161
+ try
162
+ {
163
+ await execSql(tmpConn, pSQL);
164
+ }
165
+ finally
166
+ {
167
+ await close(tmpConn);
168
+ }
169
+ }
170
+
171
+ async function runStdin()
172
+ {
173
+ const tmpChunks = [];
174
+ for await (const tmpChunk of process.stdin) tmpChunks.push(tmpChunk);
175
+ const tmpScript = Buffer.concat(tmpChunks).toString('utf8');
176
+ const tmpBatches = splitBatches(tmpScript);
177
+ if (tmpBatches.length < 1) return;
178
+
179
+ const tmpConn = await connect();
180
+ try
181
+ {
182
+ for (const tmpBatch of tmpBatches)
183
+ {
184
+ await execSql(tmpConn, tmpBatch);
185
+ }
186
+ }
187
+ finally
188
+ {
189
+ await close(tmpConn);
190
+ }
191
+ }
192
+
193
+ (async () =>
194
+ {
195
+ try
196
+ {
197
+ if (tmpMode === 'readiness') await runReadiness();
198
+ else if (tmpMode === 'query') await runQuery(tmpInlineQuery);
199
+ else await runStdin();
200
+ process.exit(0);
201
+ }
202
+ catch (pError)
203
+ {
204
+ console.error(`mssql-runner: ${pError.message}`);
205
+ process.exit(1);
206
+ }
207
+ })();
@@ -15,10 +15,27 @@ CONTAINER_NAME="meadow-mssql-test"
15
15
  SA_PASSWORD="1234567890abc."
16
16
  MSSQL_DATABASE="bookstore"
17
17
  MSSQL_PORT="31433"
18
- MSSQL_IMAGE="mcr.microsoft.com/mssql/server:2022-latest"
18
+ # Azure SQL Edge — multi-arch (amd64 + arm64). The full SQL Server image
19
+ # (mcr.microsoft.com/mssql/server) ships amd64 only, which crashes on
20
+ # Apple Silicon under Colima/QEMU because of address-layout assumptions
21
+ # the SQL Server binary makes. Azure SQL Edge speaks the same wire
22
+ # protocol and T-SQL surface the meadow tests need.
23
+ MSSQL_IMAGE="mcr.microsoft.com/azure-sql-edge:latest"
19
24
 
20
25
  SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
21
26
  SEED_GENERATOR="${SCRIPT_DIR}/bookstore-seed.js"
27
+ # Host-side TDS runner — replaces `docker exec <ctn> sqlcmd ...`. Recent
28
+ # Azure SQL Edge images strip the in-container sqlcmd binary, and the
29
+ # stand-alone `mcr.microsoft.com/mssql-tools` image is amd64-only (crashes
30
+ # under Colima/QEMU on Apple Silicon). Going host-side via tedious sidesteps
31
+ # both — connection works against the mapped port regardless of image arch.
32
+ MSSQL_RUNNER="${SCRIPT_DIR}/mssql-runner.js"
33
+
34
+ # Connection params for the runner (and matched by the test config).
35
+ export MSSQL_HOST="127.0.0.1"
36
+ export MSSQL_PORT
37
+ export MSSQL_USER="sa"
38
+ export MSSQL_PASSWORD="${SA_PASSWORD}"
22
39
 
23
40
  start_mssql() {
24
41
  # Check if container already exists
@@ -46,30 +63,22 @@ start_mssql() {
46
63
  fi
47
64
 
48
65
  echo "Waiting for MSSQL to be ready..."
49
- RETRIES=30
50
- until docker exec "${CONTAINER_NAME}" /opt/mssql-tools18/bin/sqlcmd -S localhost -U sa -P "${SA_PASSWORD}" -C -Q "SELECT 1" > /dev/null 2>&1; do
51
- RETRIES=$((RETRIES - 1))
52
- if [ $RETRIES -le 0 ]; then
53
- echo "ERROR: MSSQL failed to become ready in time."
54
- docker logs "${CONTAINER_NAME}" 2>&1 | tail -20
55
- exit 1
56
- fi
57
- echo " ...waiting (${RETRIES} retries left)"
58
- sleep 2
59
- done
66
+ if ! node "${MSSQL_RUNNER}" --readiness; then
67
+ echo "ERROR: MSSQL failed to become ready in time."
68
+ docker logs "${CONTAINER_NAME}" 2>&1 | tail -20
69
+ exit 1
70
+ fi
60
71
 
61
72
  # Create the bookstore database
62
73
  echo "Creating database '${MSSQL_DATABASE}'..."
63
- docker exec "${CONTAINER_NAME}" /opt/mssql-tools18/bin/sqlcmd \
64
- -S localhost -U sa -P "${SA_PASSWORD}" -C \
65
- -Q "IF NOT EXISTS(SELECT * FROM sys.databases WHERE name = '${MSSQL_DATABASE}') BEGIN CREATE DATABASE [${MSSQL_DATABASE}] END"
74
+ node "${MSSQL_RUNNER}" --db master --query \
75
+ "IF NOT EXISTS(SELECT * FROM sys.databases WHERE name = '${MSSQL_DATABASE}') BEGIN CREATE DATABASE [${MSSQL_DATABASE}] END"
66
76
 
67
77
  # Load bookstore schema and seed data (GUIDs minted at generation time via fable-uuid)
68
78
  if [ -f "${SEED_GENERATOR}" ]; then
69
79
  echo "Loading bookstore schema and seed data..."
70
80
  node "${SEED_GENERATOR}" --dialect mssql | \
71
- docker exec -i "${CONTAINER_NAME}" /opt/mssql-tools18/bin/sqlcmd \
72
- -S localhost -U sa -P "${SA_PASSWORD}" -C -d "${MSSQL_DATABASE}"
81
+ node "${MSSQL_RUNNER}" --db "${MSSQL_DATABASE}"
73
82
  if [ $? -ne 0 ]; then
74
83
  echo "WARNING: Failed to load seed data. Tests requiring pre-populated data may fail."
75
84
  else
@@ -0,0 +1,281 @@
1
+ // ##### Part of the **[retold](https://stevenvelozo.github.io/retold/)** system
2
+ /**
3
+ * @license MIT
4
+ * @author <steven@velozo.com>
5
+ */
6
+ var libCrypto = require('crypto');
7
+
8
+ /**
9
+ * Meadow Behavior - Soft-Deleted Collision Rename
10
+ *
11
+ * When an INSERT (or upsert-create branch) would collide on a UNIQUE
12
+ * constraint AND the conflicting existing row is soft-deleted (Deleted=1),
13
+ * rename the soft-deleted row's conflicting column(s) so the new row can
14
+ * take that slot. Frees downstream schemas from needing dialect-specific
15
+ * `WHERE Deleted=0` partial-index syntax to keep soft-deleted rows out of
16
+ * the unique-index domain.
17
+ *
18
+ * Renamed-value format: `__mdsd_{16hex}` where the hex is the first 16
19
+ * characters of sha1("{IDRecord}:{Column}:{OriginalValue}"). Deterministic,
20
+ * bounded length (~22 chars), and forensics-traceable: given a soft-deleted
21
+ * row's pk + original value, recompute the hash to verify the renamed slot.
22
+ *
23
+ * The schema opts in via per-column `Unique: true` (single-column constraint)
24
+ * or `UniqueGroup: "<name>"` (composite index — all columns sharing a group
25
+ * name). Columns of `Type: AutoGUID` are treated as implicitly unique. If a
26
+ * schema declares no unique columns, this is a no-op.
27
+ */
28
+
29
+ var COLLISION_RENAME_PREFIX = '__mdsd_';
30
+ var COLLISION_RENAME_HASH_LENGTH = 16;
31
+
32
+ var buildRenamedValue = function (pIDRecord, pColumn, pOriginalValue)
33
+ {
34
+ var tmpInput = String(pIDRecord) + ':' + String(pColumn) + ':' + String(pOriginalValue);
35
+ var tmpHash = libCrypto.createHash('sha1').update(tmpInput).digest('hex').slice(0, COLLISION_RENAME_HASH_LENGTH);
36
+ return COLLISION_RENAME_PREFIX + tmpHash;
37
+ };
38
+
39
+ /**
40
+ * Walk a meadow schema and collect unique-constraint groupings.
41
+ *
42
+ * Returns an array of { Columns, GroupName }, where Columns is the list of
43
+ * columns participating in the constraint and GroupName is null for single
44
+ * columns or the user's UniqueGroup name for composite constraints.
45
+ */
46
+ var collectUniqueConstraints = function (pSchema)
47
+ {
48
+ var tmpConstraints = [];
49
+ if (!Array.isArray(pSchema))
50
+ {
51
+ return tmpConstraints;
52
+ }
53
+
54
+ var tmpGroups = {};
55
+
56
+ for (var i = 0; i < pSchema.length; i++)
57
+ {
58
+ var tmpEntry = pSchema[i];
59
+
60
+ // AutoGUID columns are implicitly unique. The pre-flight handles
61
+ // BOTH soft-deleted-rename AND live-conflict error here, replacing
62
+ // the old standalone GUID-conflict check in Meadow-Create — one
63
+ // Read covers both behaviors instead of two.
64
+ if (tmpEntry.Type === 'AutoGUID')
65
+ {
66
+ tmpConstraints.push({ Columns: [tmpEntry.Column], GroupName: '__autoguid_' + tmpEntry.Column, AutoGUID: true });
67
+ }
68
+
69
+ if (tmpEntry.Unique === true)
70
+ {
71
+ tmpConstraints.push({ Columns: [tmpEntry.Column], GroupName: null, AutoGUID: false });
72
+ }
73
+
74
+ if (typeof (tmpEntry.UniqueGroup) === 'string' && tmpEntry.UniqueGroup.length > 0)
75
+ {
76
+ if (!tmpGroups[tmpEntry.UniqueGroup])
77
+ {
78
+ tmpGroups[tmpEntry.UniqueGroup] = { Columns: [], GroupName: tmpEntry.UniqueGroup, AutoGUID: false };
79
+ }
80
+ tmpGroups[tmpEntry.UniqueGroup].Columns.push(tmpEntry.Column);
81
+ }
82
+ }
83
+
84
+ var tmpGroupNames = Object.keys(tmpGroups);
85
+ for (var g = 0; g < tmpGroupNames.length; g++)
86
+ {
87
+ tmpConstraints.push(tmpGroups[tmpGroupNames[g]]);
88
+ }
89
+
90
+ return tmpConstraints;
91
+ };
92
+
93
+ /**
94
+ * Find the schema entry for the soft-delete flag column, if any.
95
+ * Returns the column name (e.g. "Deleted") or null when no soft-delete
96
+ * tracking is configured.
97
+ */
98
+ var findDeletedColumn = function (pSchema)
99
+ {
100
+ if (!Array.isArray(pSchema))
101
+ {
102
+ return null;
103
+ }
104
+ for (var i = 0; i < pSchema.length; i++)
105
+ {
106
+ if (pSchema[i].Type === 'Deleted')
107
+ {
108
+ return pSchema[i].Column;
109
+ }
110
+ }
111
+ return null;
112
+ };
113
+
114
+ /**
115
+ * For one constraint: scan for matching rows (with delete tracking
116
+ * disabled), and rename any that are soft-deleted on the constraint
117
+ * columns. Live conflicts pass through untouched so the regular DB
118
+ * unique-index error fires.
119
+ */
120
+ var processConstraint = function (pMeadow, pNewRecord, pConstraint, pDeletedColumn, fCallback)
121
+ {
122
+ for (var i = 0; i < pConstraint.Columns.length; i++)
123
+ {
124
+ var tmpColumn = pConstraint.Columns[i];
125
+ // If the new record doesn't supply a value for every column in the
126
+ // constraint, no meaningful conflict query can be formed — skip.
127
+ if (!Object.prototype.hasOwnProperty.call(pNewRecord, tmpColumn))
128
+ {
129
+ return fCallback();
130
+ }
131
+ var tmpValue = pNewRecord[tmpColumn];
132
+ if (tmpValue === '' || tmpValue === null || typeof (tmpValue) === 'undefined')
133
+ {
134
+ return fCallback();
135
+ }
136
+ // AutoGUID gating mirrors the old Meadow-Create Step 0 check: only
137
+ // scan when the value is at least 5 chars (FoxHound's GUID min) and
138
+ // not the schema's '0x0000000000000000' placeholder. Without these,
139
+ // schemas with placeholder defaults would trigger spurious renames.
140
+ if (pConstraint.AutoGUID && (typeof (tmpValue) !== 'string' || tmpValue.length < 5 || tmpValue === '0x0000000000000000'))
141
+ {
142
+ return fCallback();
143
+ }
144
+ }
145
+
146
+ var tmpReadQuery = pMeadow.query.clone().setDisableDeleteTracking(true);
147
+ for (var c = 0; c < pConstraint.Columns.length; c++)
148
+ {
149
+ tmpReadQuery.addFilter(pConstraint.Columns[c], pNewRecord[pConstraint.Columns[c]]);
150
+ }
151
+
152
+ pMeadow.provider.Read(tmpReadQuery, function ()
153
+ {
154
+ if (tmpReadQuery.error)
155
+ {
156
+ return fCallback(tmpReadQuery.error);
157
+ }
158
+
159
+ var tmpRows = tmpReadQuery.result.value;
160
+ if (!Array.isArray(tmpRows) || tmpRows.length < 1)
161
+ {
162
+ return fCallback();
163
+ }
164
+
165
+ var tmpSoftDeletedRows = [];
166
+ for (var r = 0; r < tmpRows.length; r++)
167
+ {
168
+ var tmpFlag = pDeletedColumn ? tmpRows[r][pDeletedColumn] : 0;
169
+ // Backends are inconsistent: SQLite returns the integer 1, MySQL
170
+ // can return a Buffer or boolean, MongoDB a boolean, etc. Treat
171
+ // any truthy value as soft-deleted.
172
+ if (tmpFlag == 1 || tmpFlag === true)
173
+ {
174
+ tmpSoftDeletedRows.push(tmpRows[r]);
175
+ }
176
+ }
177
+
178
+ if (tmpSoftDeletedRows.length < 1)
179
+ {
180
+ // AutoGUID columns get an explicit pre-flight error to preserve
181
+ // the legacy Meadow-Create Step 0 contract: callers expect the
182
+ // "Record with GUID X already exists!" string before the INSERT
183
+ // fires, not a downstream DB unique-index error. User-defined
184
+ // Unique columns keep deferring to the DB so the error surface
185
+ // for those isn't expanded by this refactor.
186
+ if (pConstraint.AutoGUID)
187
+ {
188
+ var tmpGUIDValue = pNewRecord[pConstraint.Columns[0]];
189
+ return fCallback('Record with GUID ' + tmpGUIDValue + ' already exists!');
190
+ }
191
+ return fCallback();
192
+ }
193
+
194
+ var tmpRenameNext = function (pIndex)
195
+ {
196
+ if (pIndex >= tmpSoftDeletedRows.length)
197
+ {
198
+ return fCallback();
199
+ }
200
+
201
+ var tmpRow = tmpSoftDeletedRows[pIndex];
202
+ var tmpRowID = tmpRow[pMeadow.defaultIdentifier];
203
+ var tmpRenameRecord = {};
204
+ tmpRenameRecord[pMeadow.defaultIdentifier] = tmpRowID;
205
+ for (var k = 0; k < pConstraint.Columns.length; k++)
206
+ {
207
+ var tmpCol = pConstraint.Columns[k];
208
+ tmpRenameRecord[tmpCol] = buildRenamedValue(tmpRowID, tmpCol, tmpRow[tmpCol]);
209
+ }
210
+
211
+ var tmpUpdateQuery = pMeadow.query.clone()
212
+ .setDisableDeleteTracking(true)
213
+ .addRecord(tmpRenameRecord)
214
+ .addFilter(pMeadow.defaultIdentifier, tmpRowID);
215
+
216
+ // Use the meadow's IDUser so audit columns get a sensible value
217
+ // — the rename is a side effect of the new record's create, so
218
+ // the same user identity drives both.
219
+ tmpUpdateQuery.query.IDUser = pMeadow.userIdentifier;
220
+
221
+ pMeadow.provider.Update(tmpUpdateQuery, function ()
222
+ {
223
+ if (tmpUpdateQuery.error)
224
+ {
225
+ return fCallback(tmpUpdateQuery.error);
226
+ }
227
+ return tmpRenameNext(pIndex + 1);
228
+ });
229
+ };
230
+
231
+ tmpRenameNext(0);
232
+ });
233
+ };
234
+
235
+ /**
236
+ * Top-level: walk all unique constraints in the schema and rename any
237
+ * soft-deleted conflicts so the imminent INSERT can take the slot.
238
+ */
239
+ var renameSoftDeletedConflicts = function (pMeadow, pNewRecord, fCallback)
240
+ {
241
+ var tmpConstraints = collectUniqueConstraints(pMeadow.schema);
242
+ if (tmpConstraints.length < 1)
243
+ {
244
+ return fCallback();
245
+ }
246
+
247
+ var tmpDeletedColumn = findDeletedColumn(pMeadow.schema);
248
+ // If there's no Deleted column in the schema, soft-delete tracking
249
+ // isn't a concept here — nothing can be soft-deleted, so nothing to
250
+ // rename. Bail out early.
251
+ if (!tmpDeletedColumn)
252
+ {
253
+ return fCallback();
254
+ }
255
+
256
+ var tmpProcessNext = function (pIndex)
257
+ {
258
+ if (pIndex >= tmpConstraints.length)
259
+ {
260
+ return fCallback();
261
+ }
262
+
263
+ processConstraint(pMeadow, pNewRecord, tmpConstraints[pIndex], tmpDeletedColumn, function (pError)
264
+ {
265
+ if (pError)
266
+ {
267
+ return fCallback(pError);
268
+ }
269
+ return tmpProcessNext(pIndex + 1);
270
+ });
271
+ };
272
+
273
+ tmpProcessNext(0);
274
+ };
275
+
276
+ module.exports = renameSoftDeletedConflicts;
277
+ module.exports.buildRenamedValue = buildRenamedValue;
278
+ module.exports.collectUniqueConstraints = collectUniqueConstraints;
279
+ module.exports.findDeletedColumn = findDeletedColumn;
280
+ module.exports.COLLISION_RENAME_PREFIX = COLLISION_RENAME_PREFIX;
281
+ module.exports.COLLISION_RENAME_HASH_LENGTH = COLLISION_RENAME_HASH_LENGTH;
@@ -5,6 +5,8 @@
5
5
  */
6
6
  var libAsyncWaterfall = require('async/waterfall');
7
7
 
8
+ var libRenameSoftDeletedConflicts = require('./Meadow-CollisionRename.js');
9
+
8
10
  /**
9
11
  * Meadow Behavior - Create
10
12
  *
@@ -14,51 +16,39 @@ var meadowBehaviorCreate = function(pMeadow, pQuery, fCallBack)
14
16
  {
15
17
  libAsyncWaterfall(
16
18
  [
17
- // Step 0: If GUID is specified, make sure the record does not already exist
19
+ // Step 0: Unique-constraint pre-flight. Single Read per constraint
20
+ // (AutoGUID + any user-declared Unique/UniqueGroup columns) covers
21
+ // both behaviors that used to live here:
22
+ // - soft-deleted collisions get the conflicting columns renamed
23
+ // so the new INSERT can take the slot (the soft-delete /
24
+ // dialect-specific WHERE-Deleted=0 partial-index workaround
25
+ // downstream consumers used to need)
26
+ // - live AutoGUID collisions return the legacy "Record with
27
+ // GUID X already exists!" error before the INSERT fires
28
+ // No-op when the schema declares no Unique/UniqueGroup columns
29
+ // AND no Deleted-tracking column. Live conflicts on user-declared
30
+ // Unique columns still defer to the DB unique-index error.
18
31
  function (fStageComplete)
19
32
  {
20
- // Make sure the user submitted a record
21
- if (!pQuery.query.records)
33
+ // Reject missing records here — the old Step 0 owned this
34
+ // error and downstream steps assume pQuery.query.records[0]
35
+ // exists. Same string and trailing-args shape as before so
36
+ // existing callers' expectations are preserved.
37
+ if (!pQuery.query.records || !pQuery.query.records[0])
22
38
  {
23
39
  return fStageComplete('No record submitted', pQuery, false);
24
40
  }
25
41
 
26
- if (pQuery.query.records[0][pMeadow.defaultGUIdentifier] &&
27
- pQuery.query.records[0][pMeadow.defaultGUIdentifier].length >= 5) //see Foxhound mysql build create query: GUID min len must be 5
42
+ libRenameSoftDeletedConflicts(pMeadow, pQuery.query.records[0], function (pPreflightError)
28
43
  {
29
- var tmpGUIDRecord = pQuery.query.records[0][pMeadow.defaultGUIdentifier];
30
-
31
- var tmpQueryRead = pQuery.clone().addFilter(pMeadow.defaultGUIdentifier, tmpGUIDRecord)
32
- .setDisableDeleteTracking(true); //this check is to guarantee uniqueness across the entire table, so always do this
33
-
34
- if (pMeadow.rawQueries.checkQuery('Read'))
44
+ if (pPreflightError)
35
45
  {
36
- tmpQueryRead.parameters.queryOverride = pMeadow.rawQueries.getQuery('Read');
46
+ pMeadow.fable.log.warn('Error during the unique-constraint pre-flight',
47
+ { Error: pPreflightError, Message: pPreflightError && pPreflightError.message });
48
+ return fStageComplete(pPreflightError, pQuery, false);
37
49
  }
38
- pMeadow.provider.Read(tmpQueryRead, function()
39
- {
40
- var tmpError = tmpQueryRead.error;
41
-
42
- if (!tmpError &&
43
- tmpQueryRead.result.value.length > 0)
44
- {
45
- tmpError = 'Record with GUID ' + tmpGUIDRecord + ' already exists!';
46
- }
47
-
48
- if (tmpError)
49
- {
50
- return fStageComplete(tmpError, tmpQueryRead, tmpQueryRead, null);
51
- }
52
- else
53
- {
54
- return fStageComplete();
55
- }
56
- });
57
- }
58
- else
59
- {
60
50
  return fStageComplete();
61
- }
51
+ });
62
52
  },
63
53
  // Step 1: Create the record in the data source
64
54
  function (fStageComplete)
@@ -43,10 +43,22 @@ var MeadowProvider = function ()
43
43
  return fCallback();
44
44
  }
45
45
 
46
- var tmpPath = pQuery.query.body;
47
- if (tmpPath && tmpPath.charAt(0) !== '/')
46
+ // Build the path with the connection hash prefix so the remote
47
+ // databeacon's hash-namespaced route receives the request.
48
+ var tmpHash = tmpConn._TargetConnectionHash || '';
49
+ var tmpRelative = pQuery.query.body || '';
50
+ var tmpPath;
51
+ if (tmpHash)
48
52
  {
49
- tmpPath = '/1.0/' + tmpPath;
53
+ tmpPath = '/1.0/' + tmpHash + '/' + tmpRelative;
54
+ }
55
+ else if (tmpRelative.charAt(0) !== '/')
56
+ {
57
+ tmpPath = '/1.0/' + tmpRelative;
58
+ }
59
+ else
60
+ {
61
+ tmpPath = tmpRelative;
50
62
  }
51
63
 
52
64
  var tmpBody = null;
@@ -94,6 +94,13 @@ var _AnimalDefault = (
94
94
  Type: 'Unclassified'
95
95
  });
96
96
 
97
+ // Per-request timeout cap. A stuck Alpha (e.g. /alter accepted by the
98
+ // kernel but never answered while DGraph finishes initializing) used to
99
+ // silently consume the whole 30s mocha hook budget here. With this
100
+ // cap, a hang surfaces as a clear callback error in ~10s and the hook
101
+ // fails fast with a useful message instead of hitting the mocha timeout.
102
+ var DGRAPH_HTTP_TIMEOUT_MS = 10000;
103
+
97
104
  // Helper to drop all DGraph data via the HTTP API
98
105
  var dropAllDGraphData = function (fCallback)
99
106
  {
@@ -103,7 +110,8 @@ var dropAllDGraphData = function (fCallback)
103
110
  port: 38080,
104
111
  path: '/alter',
105
112
  method: 'POST',
106
- headers: { 'Content-Type': 'application/json' }
113
+ headers: { 'Content-Type': 'application/json' },
114
+ timeout: DGRAPH_HTTP_TIMEOUT_MS
107
115
  };
108
116
  var tmpReq = http.request(tmpOptions, function (pRes)
109
117
  {
@@ -111,6 +119,7 @@ var dropAllDGraphData = function (fCallback)
111
119
  pRes.on('end', function () { fCallback(); });
112
120
  });
113
121
  tmpReq.on('error', function (pError) { fCallback(pError); });
122
+ tmpReq.on('timeout', function () { tmpReq.destroy(new Error(`DGraph /alter (drop_all) timed out after ${DGRAPH_HTTP_TIMEOUT_MS}ms`)); });
114
123
  tmpReq.write(JSON.stringify({ drop_all: true }));
115
124
  tmpReq.end();
116
125
  };
@@ -159,7 +168,8 @@ var applyDGraphSchema = function (fCallback)
159
168
  port: 38080,
160
169
  path: '/alter',
161
170
  method: 'POST',
162
- headers: { 'Content-Type': 'application/octet-stream' }
171
+ headers: { 'Content-Type': 'application/octet-stream' },
172
+ timeout: DGRAPH_HTTP_TIMEOUT_MS
163
173
  };
164
174
  var tmpReq = http.request(tmpOptions, function (pRes)
165
175
  {
@@ -167,6 +177,7 @@ var applyDGraphSchema = function (fCallback)
167
177
  pRes.on('end', function () { fCallback(); });
168
178
  });
169
179
  tmpReq.on('error', function (pError) { fCallback(pError); });
180
+ tmpReq.on('timeout', function () { tmpReq.destroy(new Error(`DGraph /alter (schema apply) timed out after ${DGRAPH_HTTP_TIMEOUT_MS}ms`)); });
170
181
  tmpReq.write(tmpSchema);
171
182
  tmpReq.end();
172
183
  };
@@ -131,6 +131,13 @@ suite
131
131
  (
132
132
  function (fDone)
133
133
  {
134
+ // Container-backed setup: connect + create-db + create-
135
+ // table + 5 inserts is ~9 sequential round trips. The
136
+ // global mocha timeout (5s) is too tight when the
137
+ // container is freshly started (cold connection adds
138
+ // 2-3s on its own under emulation). Match the DGraph
139
+ // hook's 30s budget.
140
+ this.timeout(30000);
134
141
  // Only do this for the first test.
135
142
  if (!_SpooledUp)
136
143
  {
@@ -26,6 +26,7 @@ var Expect = Chai.expect;
26
26
  var libHTTP = require('http');
27
27
  var libFS = require('fs');
28
28
  var libPath = require('path');
29
+ var libChildProcess = require('child_process');
29
30
 
30
31
  var _MeadowRoot = libPath.resolve(__dirname, '..');
31
32
  var _MeadowDistDir = libPath.join(_MeadowRoot, 'dist');
@@ -508,12 +509,37 @@ suite
508
509
  ));
509
510
  }
510
511
 
511
- // Verify sql.js dist exists
512
+ // Auto-bootstrap the sister package's sql.js dep if missing.
513
+ // This is monorepo dev tooling — `test-all-providers` is run by
514
+ // the maintainer from a fresh checkout, where transitive deps
515
+ // of sibling packages aren't installed by meadow's own
516
+ // `npm install`. Self-bootstrapping here avoids the manual
517
+ // `cd ../meadow-connection-sqlite-browser && npm install`
518
+ // step. If you want the same treatment for the sister's dist
519
+ // (currently still a manual `npm run build`), extend this hook
520
+ // to invoke quack build the same way.
512
521
  if (!libFS.existsSync(libPath.join(_SqlJsDistDir, 'sql-wasm.js')))
513
522
  {
514
- return fDone(new Error(
515
- 'sql.js dist files not found. Run "npm install" in meadow-connection-sqlite-browser first.'
516
- ));
523
+ console.log(` sql.js not found in ${_ConnectionPackageRoot}; running "npm install" there...`);
524
+ try
525
+ {
526
+ libChildProcess.execSync('npm install', {
527
+ cwd: _ConnectionPackageRoot,
528
+ stdio: 'inherit'
529
+ });
530
+ }
531
+ catch (pInstallError)
532
+ {
533
+ return fDone(new Error(
534
+ `Failed to npm-install sister package at ${_ConnectionPackageRoot}: ${pInstallError.message}`
535
+ ));
536
+ }
537
+ if (!libFS.existsSync(libPath.join(_SqlJsDistDir, 'sql-wasm.js')))
538
+ {
539
+ return fDone(new Error(
540
+ `sql.js dist files still missing after install in ${_ConnectionPackageRoot}.`
541
+ ));
542
+ }
517
543
  }
518
544
 
519
545
  // Start the test server
@@ -965,5 +965,120 @@ suite
965
965
  );
966
966
  }
967
967
  );
968
+ suite
969
+ (
970
+ 'Soft-Deleted Collision Rename (DAL layer)',
971
+ function ()
972
+ {
973
+ // Schema for a separate Gadget table that exercises the rename
974
+ // at the meadow DAL layer (no meadow-endpoints in the loop).
975
+ // Uses a plain UNIQUE INDEX on Code — no `WHERE Deleted=0`
976
+ // partial-index syntax — so the rename is what keeps the slot
977
+ // reusable after a soft-delete.
978
+ var _GadgetSchema =
979
+ [
980
+ { Column: 'IDGadget', Type: 'AutoIdentity' },
981
+ { Column: 'GUIDGadget', Type: 'AutoGUID' },
982
+ { Column: 'CreateDate', Type: 'CreateDate' },
983
+ { Column: 'CreatingIDUser', Type: 'CreateIDUser' },
984
+ { Column: 'UpdateDate', Type: 'UpdateDate' },
985
+ { Column: 'UpdatingIDUser', Type: 'UpdateIDUser' },
986
+ { Column: 'Deleted', Type: 'Deleted' },
987
+ { Column: 'DeleteDate', Type: 'DeleteDate' },
988
+ { Column: 'DeletingIDUser', Type: 'DeleteIDUser' },
989
+ { Column: 'Code', Type: 'String', Unique: true },
990
+ { Column: 'Name', Type: 'String' }
991
+ ];
992
+ var _GadgetDefault =
993
+ {
994
+ IDGadget: 0,
995
+ GUIDGadget: '',
996
+ CreateDate: false,
997
+ CreatingIDUser: 0,
998
+ UpdateDate: false,
999
+ UpdatingIDUser: 0,
1000
+ Deleted: 0,
1001
+ DeleteDate: false,
1002
+ DeletingIDUser: 0,
1003
+ Code: '',
1004
+ Name: ''
1005
+ };
1006
+
1007
+ var newGadgetMeadow = function ()
1008
+ {
1009
+ return require('../source/Meadow.js').new(libFable, 'Gadget')
1010
+ .setProvider('SQLite')
1011
+ .setSchema(_GadgetSchema)
1012
+ .setJsonSchema({ title: 'Gadget', type: 'object', properties: {}, required: [] })
1013
+ .setDefaultIdentifier('IDGadget')
1014
+ .setDefault(_GadgetDefault);
1015
+ };
1016
+
1017
+ suiteSetup(function (fDone)
1018
+ {
1019
+ var tmpDB = libFable.MeadowSQLiteProvider.db;
1020
+ tmpDB.exec(
1021
+ 'CREATE TABLE IF NOT EXISTS Gadget (' +
1022
+ ' IDGadget INTEGER PRIMARY KEY AUTOINCREMENT,' +
1023
+ ' GUIDGadget TEXT NOT NULL DEFAULT \'\',' +
1024
+ ' CreateDate TEXT,' +
1025
+ ' CreatingIDUser INTEGER NOT NULL DEFAULT 0,' +
1026
+ ' UpdateDate TEXT,' +
1027
+ ' UpdatingIDUser INTEGER NOT NULL DEFAULT 0,' +
1028
+ ' Deleted INTEGER NOT NULL DEFAULT 0,' +
1029
+ ' DeleteDate TEXT,' +
1030
+ ' DeletingIDUser INTEGER NOT NULL DEFAULT 0,' +
1031
+ ' Code TEXT NOT NULL DEFAULT \'\',' +
1032
+ ' Name TEXT NOT NULL DEFAULT \'\'' +
1033
+ ');' +
1034
+ 'CREATE UNIQUE INDEX IF NOT EXISTS gadget_code_unique ON Gadget(Code);'
1035
+ );
1036
+ return fDone();
1037
+ });
1038
+
1039
+ test
1040
+ (
1041
+ 'Create → soft-delete → Create-with-same-Code: rename frees the slot at the DAL layer',
1042
+ function (fDone)
1043
+ {
1044
+ var libRename = require('../source/behaviors/Meadow-CollisionRename.js');
1045
+ var tmpMeadow = newGadgetMeadow().setIDUser(1);
1046
+
1047
+ var tmpFirstQuery = tmpMeadow.query.addRecord({ Code: 'orig', Name: 'first' });
1048
+ tmpMeadow.doCreate(tmpFirstQuery, function (pCreateError, pQuery, pQueryRead, pFirstRecord)
1049
+ {
1050
+ Expect(pCreateError, pCreateError && pCreateError.message ? pCreateError.message : pCreateError).to.not.exist;
1051
+ var tmpFirstID = pFirstRecord.IDGadget;
1052
+ Expect(pFirstRecord.Code).to.equal('orig');
1053
+ Expect(tmpFirstID).to.be.above(0);
1054
+
1055
+ var tmpDeleteQuery = tmpMeadow.query.addFilter('IDGadget', tmpFirstID);
1056
+ tmpMeadow.doDelete(tmpDeleteQuery, function (pDeleteError, pDelQuery, pDelCount)
1057
+ {
1058
+ Expect(pDelCount).to.equal(1);
1059
+
1060
+ var tmpSecondQuery = newGadgetMeadow().setIDUser(1).query.addRecord({ Code: 'orig', Name: 'second' });
1061
+ newGadgetMeadow().setIDUser(1).doCreate(tmpSecondQuery, function (pSecondError, pQuery2, pRead2, pSecond)
1062
+ {
1063
+ Expect(pSecondError, pSecondError && pSecondError.message ? pSecondError.message : pSecondError).to.not.exist;
1064
+ Expect(pSecond.Code).to.equal('orig');
1065
+ Expect(pSecond.IDGadget).to.not.equal(tmpFirstID);
1066
+
1067
+ // Direct DB inspection to verify the soft-deleted
1068
+ // row's Code was renamed to the deterministic
1069
+ // __mdsd_<16hex> shape, and that the suffix matches
1070
+ // sha1("{IDRecord}:{Column}:{OriginalValue}").
1071
+ var tmpRow = libFable.MeadowSQLiteProvider.db.prepare('SELECT IDGadget, Code, Deleted FROM Gadget WHERE IDGadget = ?').get(tmpFirstID);
1072
+ Expect(tmpRow.Deleted).to.equal(1);
1073
+ var tmpExpected = libRename.buildRenamedValue(tmpFirstID, 'Code', 'orig');
1074
+ Expect(tmpRow.Code).to.equal(tmpExpected);
1075
+ fDone();
1076
+ });
1077
+ });
1078
+ });
1079
+ }
1080
+ );
1081
+ }
1082
+ );
968
1083
  }
969
1084
  );