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 +11 -11
- package/scripts/dgraph-test-db.sh +23 -3
- package/scripts/mssql-runner.js +207 -0
- package/scripts/mssql-test-db.sh +26 -17
- package/source/behaviors/Meadow-CollisionRename.js +281 -0
- package/source/behaviors/Meadow-Create.js +25 -35
- package/source/providers/Meadow-Provider-RetoldDataBeacon.js +15 -3
- package/test/Meadow-Provider-DGraph_tests.js +13 -2
- package/test/Meadow-Provider-MSSQL_tests.js +7 -0
- package/test/Meadow-Provider-SQLiteBrowser-Headless_tests.js +30 -4
- package/test/Meadow-Provider-SQLite_tests.js +115 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "meadow",
|
|
3
|
-
"version": "2.0.
|
|
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.
|
|
86
|
+
"fable": "^3.1.71",
|
|
87
87
|
"gulp-util": "^3.0.8",
|
|
88
|
-
"meadow-connection-dgraph": "^1.0.
|
|
89
|
-
"meadow-connection-mongodb": "^1.0.
|
|
90
|
-
"meadow-connection-mssql": "^1.0.
|
|
91
|
-
"meadow-connection-mysql": "^1.0.
|
|
92
|
-
"meadow-connection-postgresql": "^1.0.
|
|
93
|
-
"meadow-connection-rocksdb": "^0.0
|
|
94
|
-
"meadow-connection-solr": "^1.0.
|
|
95
|
-
"meadow-connection-sqlite": "^1.0.
|
|
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.
|
|
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
|
-
|
|
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
|
+
})();
|
package/scripts/mssql-test-db.sh
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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
|
-
|
|
64
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
//
|
|
21
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
47
|
-
|
|
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/' +
|
|
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
|
-
//
|
|
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
|
-
|
|
515
|
-
|
|
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
|
);
|